Skip to content

fix(codegen): guard container consumers against the null-container sentinel (chained-read miss segfault)#533

Merged
nahime0 merged 7 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/526-array-get-miss-null-fallback
Jul 17, 2026
Merged

fix(codegen): guard container consumers against the null-container sentinel (chained-read miss segfault)#533
nahime0 merged 7 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/526-array-get-miss-null-fallback

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Fixes #526 — and the crash class turned out wider than the report: on main, all of these segfault (exit 139), not just the chained read: isset($a[7][1]), $a[7][1] ?? …, $m['nope']['x'], $a[9][0][0], $a[0][9][0], foreach ($a[7] ?? [] as $v), foreach ($a[7] as $v).

Root cause

emit_array_get_null_fallback (src/codegen/lower_inst/arrays.rs) materializes the in-band NULL_SENTINEL (0x7fff_ffff_ffff_fffe) for missed reads of refcounted element types, and every downstream container consumer dereferenced it unguarded: lower_array_get_{aarch64,x86_64} loaded the length header straight from it; the same held for __rt_hash_get, __rt_array_get_mixed_key, __rt_hash_iter_next (null-guarded 0 but not the sentinel), the isset array probe, and the foreach length snapshot. Compounding it, IsNull on statically-container-typed slots was hard-coded false, so $a[7] ?? [] evaluated the miss as non-null and propagated the sentinel into foreach.

Design

Consumer-side guards, keeping the sentinel as the established null-container representation — it is already what ??/isset compare against for scalars, what Throwable::getPrevious() uses for null objects, and what incref/decref already skip via heap-range checks, so it is GC-safe by construction. (Changing the fallback to a different representation was considered and rejected: the sentinel-as-null convention is load-bearing across comparisons, so a representation change would ripple much wider.)

  • New shared emitter helper emit_branch_if_null_container (src/codegen_support/sentinels.rs): branch on reg == 0 || reg == NULL_SENTINEL, symmetric ARM64/x86_64 arms.
  • Guarded: lower_array_get_* (jumps to the fallback past the warning, so the Silent variants stay silent automatically), the isset array probes, IsNull for Array/AssocArray/Iterable/Object, the foreach indexed-length snapshot; plus 3-instruction sentinel extensions of the existing null guards in __rt_hash_get, __rt_array_get_mixed_key, __rt_hash_iter_next (both arches each).
  • One ir_lower change so isset/?? silence propagates through the whole subscript chain (isset($a[7][1]) must not warn at any level — PHP semantics).

Before → after (base v0.26.1 93f576168, macOS arm64; all post-fix runs exit 0 and are PHP-compared)

Case before after
$a[7][1] (issue repro) warn + SIGSEGV warn, x=, done, heap clean
$a[1][7] (contrast) OK byte-identical to baseline
isset($a[7][1]) SIGSEGV false, no warning
$a[7][1] ?? 'dflt' SIGSEGV silent, no crash (yields '' — pre-existing gap, see notes)
$m['nope']['x'] string-keyed SIGSEGV NULL, mirrors the working direction
3-level miss at each level 2 of 3 SIGSEGV NULL ×3, one warning per real miss
foreach ($a[7] ?? [] as $v) / foreach ($a[7] as $v) SIGSEGV iterates empty / warns and skips
Mixed-element miss OK unchanged

--heap-debug: the repro and every previously-crashing variant end clean; the leaks remaining on some second-index-miss paths are byte-identical on unmodified main (pre-existing; they belong to the leak family being fixed in #524/#531/#532 — verified composed: on a local merge of those PRs the same programs end clean).

Cross-arch verification

Both arch arms edited symmetrically in every touched emitter (diff-reviewed). Beyond that, I captured the actual x86_64 assembly on this host (user asm via a runtime-cache pre-seed, runtime asm via an as shim — the toolchain otherwise insists on assembling with the host as) and verified the emitted guards verbatim (test/jz + movabs sentinel + cmp/je) in array_get, the isset probe, is_null_container, the iterator length snapshot, __rt_hash_get, __rt_array_get_mixed_key, __rt_hash_iter_next. Executing x86_64 locally is not possible on this host — relying on CI for the run matrix.

Tests

10 new regression tests in tests/codegen/regressions/arrays.rs (crash repro asserting stdout+warning+exit, second-index guard, heap-clean assertion, isset/?? silence, string-key chain, 3-level chain, foreach direct + coalesce, Mixed-element guard); the exact fixtures exit 139 on main. Focused suites, 0 failures: arrays 347 (incl. the 10 new), array_basics 81, isset 21, nested 119, coalesce 54, foreach 105, is_null 18, previous 3, runtime_gc 122. Zero build warnings; scripts/check_asm_comments.py clean on all touched codegen files.

Notes for the maintainer

…llegalstudio#526)

A chained subscript read whose FIRST index misses materialized the
in-band scalar null sentinel (0x7fff_ffff_ffff_fffe) for refcounted
element types, and the consuming outer read then loaded the array
length header from that sentinel address and crashed with SIGSEGV.

Treat a zero pointer or the null sentinel as the in-band PHP-null
container representation on every read surface, on both ARM64 and
x86_64:

- indexed array_get (warn + silent variants) branches to the null
  fallback, skipping the undefined-key warning, when the receiver is
  null/sentinel
- the isset() array-offset probe reports such receivers as missing and
  recognizes ArrayGetSilent producers
- __rt_hash_get, __rt_array_get_mixed_key, and __rt_hash_iter_next
  extend their existing null-table guards with the sentinel pattern
- IsNull on statically typed containers (Array/AssocArray/Iterable/
  Object) now detects the null/sentinel representation so `?? []`
  yields the default instead of propagating the sentinel into foreach
- foreach's indexed length snapshot treats a null/sentinel source as
  length zero so the loop body is skipped instead of crashing
- isset()/`??` suppress undefined-offset warnings across the whole
  subscript chain, matching PHP's silence for every level

Fixes illegalstudio#526

Claude-Session: https://claude.ai/code/session_01Jfn7uqEH5Dog1nBzTM5DCW
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:eir Touches EIR definitions, lowering, validation, or passes. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 13, 2026

nahime0 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Opened #554 to track the pre-existing statically-Str null-coalescing gap documented in this PR.

It covers both $a[7][1] ?? 'dflt' after this PR's sentinel guard and the already non-crashing $a[1][7] ?? 'dflt' behavior on main, with explicit controls ensuring that a legitimate empty string still does not select the default. The issue also captures the required audit of string null representation, storage classes, ABI paths, and ownership.

Keeping this separate lets #533 remain focused on preventing null-container sentinel dereferences while #554 owns the PHP-semantic follow-up.

nahime0 commented Jul 17, 2026

Copy link
Copy Markdown
Member

I opened separate follow-ups for the two write/by-reference cases called out as out of scope here:

This keeps #533 focused on read/by-value consumer safety. #556 also distinguishes the new crash from the separate by-ref loop issue in #345, while #555 links the existing-parent work in #529/#553.

@github-actions github-actions Bot added size:m Medium-sized pull request. and removed size:s Small pull request. labels Jul 17, 2026
nahime0 pushed a commit that referenced this pull request Jul 22, 2026
Match, ternary, `??`, and `??=` merges of indexed arrays (or hashes)
with different element types let the last arm's type win wholesale, so
the other arm's runtime slots were relabeled and read through the wrong
element repr — SIGSEGV when array<int> was read as array<string>.

`wider_type_for_merge` now merges container types elementwise, reusing
the heterogeneous-literal rules, and the merge-temp store path boxes
typed container slots via ArrayToMixed / HashToMixed. Borrowed sources
retain the payload first so the conversion's copy-on-write split leaves
live caller arrays untouched; whole-boxed `?array` cells flowing
through `??` route through the owned-payload unbox coercion for the
same reason. Null-container sentinels forwarded by missed reads pass
through both conversions unconverted (issue #533 convention).

Closes #549
nahime0 pushed a commit that referenced this pull request Jul 23, 2026
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
nahime0 pushed a commit that referenced this pull request Jul 24, 2026
Add regression coverage for issue #554: `($a[i][j] ?? 'dflt')` must select
the default when the accessed slot is statically typed `Str` and the read
misses, while a real present empty string keeps `''`.

Root cause (already resolved upstream): `??` lowers its left-hand access
silently and tests the result with `IsNull`. Before PR #533,
`emit_is_null_result` let statically-known `PhpType::Str` values fall through
to constant-false, so a missed string slot (fallback `{ptr = NULL_SENTINEL,
len = 0}`) was indistinguishable from an empty string — every miss yielded
`''` instead of `'dflt'`. PR #533 closed the gap by routing `PhpType::Str`
through `emit_string_null_bool`, which compares the string pointer against
`NULL_SENTINEL`; legitimate empty strings never carry that pointer (they use
a null/valid data pointer), so the distinction is sound.

These tests had no dedicated coverage. They pass on current `main` and fail
when #533's `PhpType::Str` arm is reverted to constant-false, guarding the
behavior against silent regression. Indices are bound to `$argc`-derived
locals so the element read stays statically `Str` (an inline arithmetic index
is typed `Mixed` and would route through the boxed-Mixed null path instead).

Covers the issue's regression checklist: first-index miss, second-index miss,
present empty string, string-keyed associative arrays, nullable string
locals/returns, `??`-path silence, and heap-debug cleanliness. Verified under
`--ir-opt=on` and `--ir-opt=off`.

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
nahime0 pushed a commit to mirchaemanuel/elephc that referenced this pull request Jul 24, 2026
…d cell

A missed indexed read forwarded through a ternary/branch merge boxes the
null-container sentinel (0x7ffffffffffffffe) into a `mixed` cell tagged as
an indexed array. `$r[] = v` on that cell (`Op::MixedArrayAppend`) unboxed
the receiver, accepted the tag-4 payload, and read the array header inline
(`ldur x1, [x0, #-8]`) before `__rt_array_to_mixed`. The only pre-deref
guard was a plain-zero check, so the sentinel passed through and the header
read faulted at `sentinel - 8` (issue illegalstudio#592, exit 139). PHP instead
auto-vivifies the null into a fresh `["z"]` array and continues.

`lower_mixed_array_append` (aarch64 + x86_64) now guards the unboxed
payload for both the null pointer and the in-band null-container sentinel
before the header dereference (issue illegalstudio#533 convention). For a
container-shaped tag with such a payload — indexed (4), associative (5), or
null (8) — it auto-vivifies: it allocates a fresh empty indexed array
(`__rt_array_new(0, 8)`, as `__rt_mixed_new_empty_array_cell` does),
transfers that reference into the Mixed cell, retags the cell as an indexed
array, and appends at index 0 through the shared `__rt_mixed_array_set`
tail. Scalars whose payload happens to be zero or the sentinel are excluded
by the tag check and keep the existing drop behavior. Ownership is balanced
(the cell owns the fresh array; the array owns the appended value), so the
autovivify path stays heap-clean.

Adds regression coverage in tests/codegen/arrays/mixed_append_autovivify.rs:
the exact issue repro, the `["z"]` read-back, a heap-clean sentinel variant,
an associative missed-read sibling, a multi-append grow case, and a
real-array Mixed-cell control that guards against a double-conversion
regression. Fixtures make the taken arm `$argc`-dependent to defeat
constant folding.

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
nahime0 pushed a commit that referenced this pull request Jul 25, 2026
…d cell

A missed indexed read forwarded through a ternary/branch merge boxes the
null-container sentinel (0x7ffffffffffffffe) into a `mixed` cell tagged as
an indexed array. `$r[] = v` on that cell (`Op::MixedArrayAppend`) unboxed
the receiver, accepted the tag-4 payload, and read the array header inline
(`ldur x1, [x0, #-8]`) before `__rt_array_to_mixed`. The only pre-deref
guard was a plain-zero check, so the sentinel passed through and the header
read faulted at `sentinel - 8` (issue #592, exit 139). PHP instead
auto-vivifies the null into a fresh `["z"]` array and continues.

`lower_mixed_array_append` (aarch64 + x86_64) now guards the unboxed
payload for both the null pointer and the in-band null-container sentinel
before the header dereference (issue #533 convention). For a
container-shaped tag with such a payload — indexed (4), associative (5), or
null (8) — it auto-vivifies: it allocates a fresh empty indexed array
(`__rt_array_new(0, 8)`, as `__rt_mixed_new_empty_array_cell` does),
transfers that reference into the Mixed cell, retags the cell as an indexed
array, and appends at index 0 through the shared `__rt_mixed_array_set`
tail. Scalars whose payload happens to be zero or the sentinel are excluded
by the tag check and keep the existing drop behavior. Ownership is balanced
(the cell owns the fresh array; the array owns the appended value), so the
autovivify path stays heap-clean.

Adds regression coverage in tests/codegen/arrays/mixed_append_autovivify.rs:
the exact issue repro, the `["z"]` read-back, a heap-clean sentinel variant,
an associative missed-read sibling, a multi-append grow case, and a
real-array Mixed-cell control that guards against a double-conversion
regression. Fixtures make the taken arm `$argc`-dependent to defeat
constant folding.

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
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:eir Touches EIR definitions, lowering, validation, or passes. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:l Large pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chained subscript read where the first index misses segfaults (null-fallback sentinel dereferenced as an array pointer)

2 participants