fix(runtime): guard COW helpers against the null-container sentinel#579
Merged
nahime0 merged 4 commits intoJul 23, 2026
Merged
Conversation
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
Member
|
Branch updated in place and re-audited against the current Integration changes
Technical reviewThe runtime fix is sound and addresses the actual null-container contract rather than masking the reproducer:
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
GitHub reports the exact head |
…-missing-element # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #556.
Root cause
A by-reference
foreachwhose 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:0x…fff2isNULL_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:
lower_iter_startsplits 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.by_refarms of the indexedIterNextlowerings 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 hashIterNextpath 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 against0b26415c9b. 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 ashash_iter.rs. This also gives every other COW call site (push/unshift/shift, literal writes) defence in depth.lower_iter_startnormalizes sentinel→0 once per loop in the iterator's source slot only (csel/cmove, new shared helperemit_normalize_null_container_to_zeroinsentinels.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'printswas-null, matching PHP).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
foreach ($a[7] as &$v)done, exit 0doneforeach ($h['nope'] as &$v)(hash)done, exit 0done$arr = $a[7]; foreach ($arr as &$v)done,$arrstays nulldoneforeach ($a[7] ?? [] as &$v)done(already OK on this head)done, exit 0done$k => &$von a missdonedonePHP's live-length semantics are preserved and now pinned by a regression test: appending during a by-ref
foreachstill 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-debugclean on all shapes with--ir-opt=onandoff.cargo fmt --checkclean, 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 theIterNextzero-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 pinningELEPHC_TEST_TARGET, and CI provides the run-time signal.Known limitations
Undefined array key N; PHP instead emitsforeach() argument must be of type array|object, null givenand no undefined-key warning, since a by-reference fetch is not a read. The observable behaviour the issue requires — never dereference the sentinel, skip the loop, keep running — is correct, and consistent with elephc's by-value path.$n = [['x','y']]; foreach ($n[0] as &$w) { $w .= '!'; }yieldsx,y— reproduced identically at based36bc6b64. That is the parent write-back gap in fix(ir,codegen): autovivify missing parents in nested array writes #570/foreachby reference followed by value iteration segfaults on indexed arrays #345 territory; I'll file it separately.convert_dynamic_indexed_source_for_refhas an unguarded header load) — no reachable repro found from this issue's shapes; worth a separate follow-up if you can reach it.var_dump()on an Array-typed sentinel local also still crashes (pre-existing, Chained subscript read where the first index misses segfaults (null-fallback sentinel dereferenced as an array pointer) #526 family).https://claude.ai/code/session_01QyzntRke1Cgxgq1ueuz4X9