Skip to content

fix(runtime): guard boxed-mixed array get/set against the null-container sentinel#591

Merged
nahime0 merged 6 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/585-guard-mixed-array-get-sentinel
Jul 25, 2026
Merged

fix(runtime): guard boxed-mixed array get/set against the null-container sentinel#591
nahime0 merged 6 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/585-guard-mixed-array-get-sentinel

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Summary

A missed indexed read ($rows[5]) forwarded as one arm of a ternary whole-boxes into a
mixed cell whose payload pointer is the in-band null-container sentinel
(NULL_SENTINEL = 0x7ffffffffffffffe) rather than a real array pointer. $r[0] ?? "none"
then crashed in __rt_mixed_array_get, which dispatched on the cell tag, loaded the
container pointer from [cell + 8], and guarded it only with a zero check (cbz/test) —
which the non-zero sentinel slips past. The next load dereferenced the sentinel and
segfaulted (exit 139). The sibling writer __rt_mixed_array_set had the identical hole on
$r[0] = ….

The ternary's nullable-union path bypasses wider_type_for_merge (the match merge path
does not), so the sentinel reaches the helpers in-band. PHP 8.4 warns and yields null /
drops the write; elephc crashed.

Root cause

Both helpers load the container payload from [cell + 8] in each of their indexed / assoc /
object paths and guarded it against a zero pointer only. A missed container read boxed into a
mixed stores the sentinel (not zero, not a valid pointer) in that slot, so the guard is
bypassed and the following dereference faults.

Fix

__rt_mixed_array_get and __rt_mixed_array_set now route a null or null-container
sentinel payload to their existing absent-container paths via the shared
emit_branch_if_null_container helper (issue #533 convention: guards live inside the runtime
helpers), on both aarch64 and x86_64. The read returns Mixed(null) with PHP's undefined-key
warning; the write is dropped, matching elephc's existing null-target behavior.

Test plan

New regressions in tests/codegen/regressions/arrays.rs:

  • exact issue repro (read yields the default + undefined-key warning)
  • present-arm control (valid boxed indexed read unaffected)
  • indexed write into the sentinel container (no crash)
  • heap-clean checks for both the read and the dropped write

Red→green: the four sentinel-path tests crash before the fix and pass after. Focused slices
(ternary, mixed_array, coalesce, null_container, missed, regressions::arrays,
null_sentinel, json, stdclass, spl_, assoc) pass: 958 tests, 0 failures.

Fixes #585

https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L

…ner sentinel

A missed indexed read (`$rows[5]`) forwarded as one arm of a ternary
whole-boxes into a `mixed` cell whose payload pointer is the in-band
null-container sentinel (`NULL_SENTINEL = 0x7ffffffffffffffe`) rather than
a real array pointer. `__rt_mixed_array_get` dispatched on the cell's tag
(indexed/assoc/object), loaded the container pointer from `[cell+8]`, and
guarded it only with a zero check (`cbz`/`test`), which the non-zero
sentinel slips past. The next load dereferenced the sentinel and
segfaulted; the sibling writer `__rt_mixed_array_set` had the identical
hole on `$r[0] = …`.

The ternary's nullable-union path bypasses `wider_type_for_merge` (the
`match` merge path does not), so the sentinel reaches the helpers in-band.

Both helpers now route a null OR sentinel container payload to their
existing absent-container paths via the shared
`emit_branch_if_null_container`, on aarch64 and x86_64: the read returns
`Mixed(null)` (with PHP's undefined-key warning) and the write is dropped,
matching PHP's quiet access and elephc's existing null-target behavior.

Regression tests cover the exact issue repro, the present-arm control, the
indexed write, and heap-clean checks for both the read and the dropped
write.

Fixes illegalstudio#585

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
@github-actions github-actions Bot added area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 22, 2026
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

While validating this fix I found an adjacent pre-existing crash on the append path ($r[] = ... on the same sentinel-carrying mixed cell) — it does not go through the helpers guarded here and needs auto-vivify semantics, filed separately as #592.

nahime0 and others added 3 commits July 24, 2026 13:10
…rray-get-sentinel

# Conflicts:
#	src/codegen_support/runtime/objects/mixed_array_set.rs
#	tests/codegen/regressions/arrays.rs
…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
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:eir Touches EIR definitions, lowering, validation, or passes. size:m Medium-sized pull request. and removed size:s Small pull request. labels Jul 24, 2026
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Picking this up. CI is red on the current head bd354a5ee; it was green on 9068d92e6 (2026-07-22), and the failures show on all three architectures (macos-aarch64, linux-x86_64, linux-aarch64).

The failures cluster in the ArrayAccess family: codegen::spl::interfaces::test_array_access_subscript_read_write_isset_unset, codegen::spl::classes::test_phase4_spl_fixed_array_runtime_methods, codegen::spl::classes::test_phase4_spl_fixed_array_serialization_and_from_array_helpers, codegen::spl::heaps::test_spl_object_storage_bulk_operations, codegen::spl::decorators::test_caching_iterator_full_cache_array_access_and_flags, plus several codegen::io::streams::test_phar_oop_* failing with Fatal error: Uncaught Error: Call to a member function getContent() on null.

One concrete data point: test_array_access_subscript_read_write_isset_unset expects SGvE1UE and gets SSE1UE.

All of the above is read off the CI logs; we have not reproduced it locally yet and are not claiming a cause.

…flag operand

The warn-on-missing operand added to boxed-Mixed subscript reads gave those
reads a third operand, which two consumers still read as a write.

ArrayAccess dispatch classified by operand count alone, so `$obj[$k]` matched
the `offsetSet` arm and called the setter with the flag as its value. Reads are
now identified structurally by having a result value (subscript writes lower
through `emit_void`), and the flag operand is stripped before dispatch so
`offsetGet` keeps its single-argument PHP signature.

`lower_list_unpack` still emitted two operands for boxed-Mixed sources, so
`[$a, $b] = $narrowed` failed codegen with "runtime_call missing operand 2".
It now supplies the flag through the same helper as the array-access path.

Claude-Session: https://claude.ai/code/session_01124pGFSEbzYQcGbNykWN7V
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the CI failures on bd354a5ee. There were two independent regressions in that commit, not one — the second was not in what I reported earlier tonight, I found it only by running the full codegen suite rather than the failing clusters.

Both share the same cause: the new warn-on-missing operand gave boxed-Mixed subscript reads a third operand, and two consumers still interpreted three operands as a write.

1. ArrayAccess reads dispatched offsetSet. try_lower_array_access_runtime_call classified the method purely by operand count (3 => "offsetSet"), so echo $obj[$k] called the setter with the warn flag as its $value. That function runs at lower_inst.rs:2050, before the result_php_type != Void disambiguation added at :2053, so that guard never covered ArrayAccess receivers. This is what broke SplFixedArray, SplObjectStorage, CachingIterator, Phar OOP and the test_array_access_subscript_read_write_isset_unset trace (SGvE1UESSE1UE: the read stores (string)true into $stored).

Reads are now identified structurally by having a result value — subscript writes lower through emit_void and carry none — and the flag operand is stripped before dispatch so offsetGet keeps its one-argument signature. I deliberately did not reuse result_php_type != Void here: codegen_repr() collapses null and void, so a read whose declared offsetGet return type has no runtime representation would still have misdispatched silently.

2. [$a, $b] = $narrowed failed to compile. lower_mixed_array_runtime_get now requires the third operand, but only the array-access producer was taught to supply it. lower_list_unpack also emits a boxed-Mixed read whenever the source is not an Array/Hash IR type — precisely a narrowed ?array — and still emitted two operands, so codegen::arrays::list_unpack::test_null_guard_{break,continue}_narrows_list_unpack_rhs died with CodegenIrError: runtime_call missing operand 2. It now supplies the flag through the same helper. Verified byte-identical to php on a short source: Warning: Undefined array key 1 then NULL.

One thing worth your judgement. The dispatch into lower_mixed_array_runtime_get is by type, not by symbol name — lower_binary_runtime_call (lower_inst.rs:2479) routes any RuntimeCall with a Mixed | Union receiver and non-void result there. So the set of producers that must supply the flag is not greppable, and I found two of two by looking. I did not audit all 36 Op::RuntimeCall emission sites, and I'm flagging that as unverified rather than claiming it clean. The failure mode is loud (runtime_call missing operand N at compile time), which is why I kept the strict expect_operand rather than defaulting the flag at the consumer: a tolerant default would be correct for plain reads but wrong for isset() / ??, turning a compile error into a spurious-warning bug in exactly the paths this PR touches. If you'd rather have the tolerant default anyway, it's a two-line change.

Side effect, deliberate: the structural discriminator also makes offsetGet(): void dispatch correctly ("G") where the last green head hard-errored unknown method Box::append. I judged removing a silent-miscompile path worth it, but a checker-level diagnostic may be what you actually want there.

No CHANGELOG entry and no new tests: both regressions were introduced and fixed inside this unmerged PR so nothing user-facing changed, and the tests that caught them are the pre-existing ones that turned red.

Verification, all on macOS aarch64 and all against the exact pushed commit:

suite result
codegen::spl 247 passed, 0 failed
codegen::io::streams 347 passed, 0 failed, 8 ignored
codegen::regressions::arrays 88 passed, 0 failed
array_access filter 26 passed, 0 failed
list_unpack filter 21 passed, 0 failed
cargo test -p elephc --lib 751 passed, 0 failed
error_tests 1109 passed, 0 failed
lexer_tests / parser_tests 208 / 375 passed, 0 failed

codegen::regressions::arrays covers both the 7 tests bd354a5ee added and the 6 issue-#585 regressions, so the original fix is intact.

Two caveats I'd rather state than have you find: the full cargo test --test codegen_tests was still running when I pushed — at 3685 passed with a single failure, test_eval_dispatches_gethostbyname_builtin_call, which is environmental (this machine's DNS resolves NXDOMAIN to 127.0.0.1) and reproduces identically on an untouched bd354a5ee worktree. And Linux x86_64 / aarch64 were not run locally; neither fix is target-specific — both live in target-neutral lowering, not in assembly — so CI is the check for those.

Unrelated pre-existing bug found while validating, reproduces byte-identical on the last green head so it predates this branch: $obj[$k] ??= v on an ArrayAccess receiver calls offsetGet where PHP calls offsetExists (elephc GSG, php ESG). Happy to open an issue if useful.

…rray-get-sentinel

# Conflicts:
#	CHANGELOG.md
@nahime0
nahime0 merged commit 05d3584 into illegalstudio:main Jul 25, 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:eir Touches EIR definitions, lowering, validation, or passes. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:m Medium-sized pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missed array read forwarded through a ternary merge segfaults in __rt_mixed_array_get (unguarded null-container sentinel)

2 participants