Skip to content

fix(runtime): guard COW helpers against the null-container sentinel#579

Merged
nahime0 merged 4 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/556-byref-foreach-missing-element
Jul 23, 2026
Merged

fix(runtime): guard COW helpers against the null-container sentinel#579
nahime0 merged 4 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/556-byref-foreach-missing-element

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Fixes #556.

Root cause

A by-reference foreach whose source is a missing array element hands the in-band null-container sentinel (NULL_SENTINEL, src/codegen_support/sentinels.rs) straight to code that dereferences it as a real container. Confirmed under lldb:

stop reason = EXC_BAD_ACCESS (address=0x7ffffffffffffff2)
frame #0: _rt_array_ensure_unique + 4  ->  ldur w9, [x0, #-0xc]

0x…fff2 is NULL_SENTINEL - 0xc, i.e. the refcount read in the uniform heap header, taken from the sentinel.

Two unguarded sites, both reached only on the by-reference path:

  1. lower_iter_start splits the source for by-ref mutation via __rt_array_ensure_unique / __rt_hash_ensure_unique; both helpers test the pointer for zero (cbz x0, test rdi, rdi) but not for the sentinel — first segfault.
  2. The by_ref arms of the indexed IterNext lowerings re-read the live array length from the header on every iteration, also unguarded.

Why the by-value path was already safe: #533 guarded snapshot_indexed_array_length, but all of its call sites are gated on !by_ref — by-reference iteration deliberately takes no snapshot (PHP visits elements appended during the loop), so it never reached that guard. The hash IterNext path was already safe for the same reason in the other direction: #533 put its guard inside __rt_hash_iter_next.

Note on current main (d36bc6b64): only the direct form still crashes; the ?? [] form already passes (it materializes a real empty array, so the sentinel never reaches the iterator). The issue was filed against 0b26415c9b. Two more shapes crash at base and are fixed here: a missing hash key source ($h['nope']), and the missing element assigned to a local first ($arr = $a[7]; foreach ($arr as &$v)).

Fix

Following the convention #533 established for __rt_hash_iter_next, the sentinel guard lives in the runtime helpers, not in the lowering:

  • __rt_array_ensure_unique / __rt_hash_ensure_unique (both arches) now recognize the sentinel right after the existing null check and return it unchanged — same pattern as hash_iter.rs. This also gives every other COW call site (push/unshift/shift, literal writes) defence in depth.
  • lower_iter_start normalizes sentinel→0 once per loop in the iterator's source slot only (csel / cmove, new shared helper emit_normalize_null_container_to_zero in sentinels.rs). The store-back to the user's local happens before normalization, so the variable observably stays null after the skipped loop ($arr ?? 'was-null' prints was-null, matching PHP).
  • Indexed IterNext (by-ref arms) then only needs a zero check on the live-length read — cbz / test+jz, +2 instructions per iteration on aarch64 and +3 on x86_64. The by-value snapshot keeps the full null+sentinel guard from fix(codegen): guard container consumers against the null-container sentinel (chained-read miss segfault) #533, since its source is not normalized.

With length 0 and the cursor starting at -1, the loop body never runs.

Verification

case before after PHP 8.4
foreach ($a[7] as &$v) warning + exit 139 warning + done, exit 0 warning + done
foreach ($h['nope'] as &$v) (hash) exit 139 warning + done, exit 0 warning + done
$arr = $a[7]; foreach ($arr as &$v) exit 139 warning + done, $arr stays null warning + done
foreach ($a[7] ?? [] as &$v) done (already OK on this head) done, exit 0 done
keyed $k => &$v on a miss crash warning + done warning + done

PHP's live-length semantics are preserved and now pinned by a regression test: appending during a by-ref foreach still visits the appended elements (10,20,30, identical to PHP).

Targeted suites, all green: byref_foreach (9), foreach (139), iterable (54), regressions::arrays (77), nested_mixed_write (8 — the sentinel pass-through does not mask the nested-write paths). --heap-debug clean on all shapes with --ir-opt=on and off. cargo fmt --check clean, no new clippy warnings.

The asm-level test asserts, on the runtime asm, that both COW helpers compare against the sentinel and bail before the refcount load at [-12], and on the user asm that IterStart emits the normalization (csel/cmove) and that the IterNext zero-check label is actually defined. Each assert was mutation-tested: removing the sentinel compare, moving the guard after the refcount load, dropping the bail, or dropping the normalization each makes a specific assert fail. Linux binaries were not executed locally (no Docker on this machine); the emitted asm was inspected for both targets by pinning ELEPHC_TEST_TARGET, and CI provides the run-time signal.

Known limitations

https://claude.ai/code/session_01QyzntRke1Cgxgq1ueuz4X9

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 illegalstudio#526/illegalstudio#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 illegalstudio#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 illegalstudio#556

Claude-Session: https://claude.ai/code/session_01QyzntRke1Cgxgq1ueuz4X9
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 20, 2026

nahime0 commented Jul 22, 2026

Copy link
Copy Markdown
Member

Branch updated in place and re-audited against the current main.

Integration changes

Technical review

The runtime fix is sound and addresses the actual null-container contract rather than masking the reproducer:

  • both COW helpers reject NULL_SENTINEL before any header/refcount load, on AArch64 and x86_64;
  • IterStart normalizes only the iterator's private source slot to zero, preserving the user-visible local as PHP null;
  • by-reference IterNext safely reads a zero live length for the skipped loop;
  • real arrays keep normal COW behavior and PHP's live-length semantics, including visiting elements appended during a by-reference loop.

No blocking correctness findings remain. The known diagnostic difference (elephc's undefined-key warning versus PHP's foreach source warning) and the separate dynamic/general sentinel consumers remain follow-up scope; neither invalidates the #556 crash fix.

Validation

  • cargo build
  • cargo test --test codegen_tests byref_foreach: 9/9 with EIR optimization enabled
  • the same 9/9 with ELEPHC_IR_OPT=off
  • assembly guard regression on macOS AArch64, Linux AArch64, and Linux x86_64
  • assembly comment alignment check
  • cargo fmt --all -- --check
  • git diff --check
  • clean merge-tree against current main

GitHub reports the exact head cdf3aa5fe as MERGEABLE. The new full CI matrix is still running on that SHA; there are currently no failing checks. Once that matrix is green, this is ready to merge.

…-missing-element

# Conflicts:
#	CHANGELOG.md
@nahime0
nahime0 merged commit 00a4443 into illegalstudio:main Jul 23, 2026
113 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

By-reference foreach over a missing array element dereferences the null-container sentinel

2 participants