fix(ir_lower): widen loop-reassigned scalar arrays to boxed storage#606
fix(ir_lower): widen loop-reassigned scalar arrays to boxed storage#606mirchaemanuel wants to merge 6 commits into
Conversation
Reassigning a raw-scalar array local to a literal rebuilt from its own elements inside a loop (`$r = [$r[0] - 1, 0]`, or a `[$r[1], $r[0]]` swap) read freed/misrepresented storage: the rebind produces an `array<mixed>` (boxed) or `array<int|null>` (tagged) element, but a read of `$r[0]` at the top of the single-pass loop body was still typed against the entry `array<int>`, so from iteration 2 it read the promoted cell as a raw scalar and surfaced heap addresses (or looped forever when the corrupted read sat in a `while` condition). The existing loop-entry widening (issue illegalstudio#452) only covered in-place growth sites (`$a[] =`, `$a[$i] =`, `array_push`). Extend the shared prescan with `loop_reassigned_mixed_arrays`, which reports a raw-scalar array local reassigned in the loop body to a literal whose element lowers to a boxed `mixed` or tagged `int|null` cell. Both the checker (TypeEnv) and EIR lowering (`local_types` + up-front `ArrayToMixed` promotion) consume it, so every read uses the boxed representation the back edge produces. Same-representation rebinds (`$r = [count($r), 0]`, still `array<int>`) are left untouched, and the promotion converts the entry array in place without leaking or double-freeing the previous one. Fixes illegalstudio#594 Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
|
The three out-of-scope items from the PR description are now filed with reverified repros: #607 (cascading widening through an external variable — needs a fixpoint, not a single-pass prescan), #608 (raw→raw representation rebind, string→int, silently empty output) and #609 (residual 4-block leak in the while variant, pre-existing and independent of this fix). Also posted a status update on #509 with its current-main symptoms — different root cause, not addressed here. |
…al-rebind # Conflicts: # CHANGELOG.md
|
Follow-up after merging the latest The updated implementation now:
Validation completed on the published head:
Issues #509 and #609 remain separate mechanisms and are intentionally not claimed as fixed by this change. |
|
Picking this up. CI is red on the current head Failing tests: As an observation only: the two This comes from the CI logs; we have not reproduced it locally yet and are not claiming a cause. |
|
Hi @mirchaemanuel, I'm ooo. Yes if you can push this to green it'll be great. Thanks |
…ct applies contextualize_local_assignment returned codegen_repr() as the local's PHP type on its fall-through path, collapsing Resource(_) to Int, Union(_) to Mixed/TaggedScalar and False to Bool for every assignment. The collapsed form is only needed for the storage-contract comparisons; the recorded local type feeds is_resource()/gettype() and nullable-union lowering. Claude-Session: https://claude.ai/code/session_01124pGFSEbzYQcGbNykWN7V
|
I dug into the 20 red checks here. There are three distinct root causes, not one. I've pushed a fix for the first; the other two I'm deliberately leaving to you, because repairing them means finishing a design decision rather than fixing a defect, and that's your call. Short version: I'd suggest not merging 1. Fixed and pushed —
|
|
CI has finished on Red checks went from 20 to 10, and the remaining failures are exactly the three known ones, with nothing new introduced: So the pushed fix cleanly resolves the Nothing further from me here — the decision on whether to revert to |
…ation cannot hold apply_loop_storage_contracts fell through to set_local_type when the contract and the local disagreed on container kind, leaving a slot that holds a hash while every later read is typed array<mixed>. The string-key write then promoted at the write site, releasing the slot value before array_to_hash read it and storing the result without a retain (issue illegalstudio#408 leak). Restores the heap-kind guard the pre-fixed-point widening applied before promoting. Claude-Session: https://claude.ai/code/session_01124pGFSEbzYQcGbNykWN7V
…intrinsic on Mixed receivers lower_mixed_method_candidate_call emitted a class-vtable dispatch for every candidate, but builtin Throwables implement getMessage/getCode/... as compact intrinsics and leave their vtable slots empty, so the call branched to a null slot. Route those candidates to lower_throwable_standard_method_from_reg, which the nullable-receiver path already uses. Pre-existing: reachable without any loop via $a[] = 1; $a[] = new LogicException(...); $a[1]->getMessage(). Claude-Session: https://claude.ai/code/session_01124pGFSEbzYQcGbNykWN7V
|
Correction to my earlier comment, plus three more fixes pushed ( Retraction: the element-write paths are fineI wrote that the storage contract "isn't honoured by the write side", that Codegen already boxes on push: To be precise about the scope of the retraction: I verified the What the two failures actually were408 leak — fixed, The escaped-exception segfault was NOT a regression from your commit — fixed, It reproduces on the green $keep = []; $keep[] = 1; $keep[] = new LogicException("boom");
$m = $keep[1]; echo $m->getMessage(); // SIGSEGV on both headsYour commit only made it reachable from that test, by widening That test is still red, for a different and legitimate reasonIt no longer crashes — correct output The two shapes now disagree: with try/catch ( Fix B, and a trade-off you should weigh —
|
| six target tests | 4 passed, 2 failed (was 3/3, originally 0/6) |
runtime_gc + regressions + arrays + exceptions |
810 passed, 2 failed, 1 ignored |
error_tests |
1109 passed, 0 failed |
The only failures anywhere are the two discussed above. Unverified and stated as such: macOS aarch64 only, and I did not run the full 6771-test binary — CI covers both better than I can.
The recommendation from my previous comment stands unchanged, and so does the fact that it's yours to make: revert to the green 16e4caa0e and let the fixed-point generalization return as its own PR, or complete the contract. What's changed is that two of the three causes are now closed, one of them being a pre-existing crash that had nothing to do with this PR.
|
One addendum, because my correction above was too generous in one place and I'd rather over-correct than leave you with a wrong impression twice. I said I was "withdrawing the claim that a gap exists". That's too broad. A narrower defect does exist, and it is not repaired by anything I pushed:
Also, to be exact about coverage rather than implying more than I checked: |
07e98b9 to
38418f8
Compare
|
I dropped the provisional-signature commit — it broke a test that was passing. Branch is now CI on Bisected: green at My mistake in process, worth stating: I verified those commits on four surfaces I'd picked because that's where the failing tests were, when the surface I should have measured was everything the change could touch — and a change to signature resolution can touch any call site. State on
Dropping the commit costs nothing: the six target tests are 4/2 either way, so it was pure cost with no benefit. What remains is exactly the two failures I described before, both design calls for you, and The two commits still on the branch are the ones that stand on their own: |
Summary
Reassigning a raw-scalar array local to a literal rebuilt from its own elements
inside a loop read freed/misrepresented storage.
$r = [$r[0] - 1, 0]printedheap addresses from the second iteration, and the same rebind in a
whilecondition never terminated (the corrupted read is always truthy). A pure swap
$r = [$r[1], $r[0]]was corrupted the same way.Fixes #594.
Root cause
A constant initializer
[3, 0]builds anarray<int>whose elements are rawscalars. The self-referential rebind produces a wider element representation —
array<mixed>(the$r[0] - 1overflow-checked subtraction boxes) orarray<int|null>(each$r[i]array read is the inline tagged scalar). But aread of
$r[0]at the top of the single-pass loop body is still typed againstthe entry
array<int>, soarray_getis lowered as a raw-int element read.From iteration 2 it reads the promoted (boxed/tagged) cell as a raw scalar, so a
heap pointer surfaces as an int.
Seeding the initializer with a runtime value (
[$argc + 2, 0]) was alreadycorrect because the entry array is
array<mixed>from the start, so the entrytype and the rebind agree — which is why the bug looked specific to a
compile-time-constant initializer.
The loop-entry array widening added for issue #452
(
loop_grown_mixed_array_pushes) only covered in-place growth sites(
$a[] =,$a[$i] =,array_push), so a full reassignment$r = [...]wasnever widened.
Fix
Add
loop_reassigned_mixed_arraysto the shared loop-widening prescan. Itreports a local that holds a raw-scalar indexed array at loop entry and is
reassigned in the loop body to a literal whose element lowers to a boxed
mixedor tagged
int|nullcell. Both the type checker (TypeEnv) and EIR lowering(
local_typesplus the existing up-frontArrayToMixedpromotion) consume it,so every read of the local uses the boxed representation the back edge produces,
and the entry array is converted in place before the body runs.
A self-referential rebind reads
$r's own elements, so once$ris widened itsreads return
mixedand the rebuilt literal is itselfarray<mixed>— noper-element coercion is needed. A same-representation rebind
(
$r = [count($r), 0], stillarray<int>) is deliberately left untouched, andthe promotion converts the previous array in place without leaking or
double-freeing it.
Tests
Added 7 regression tests in
tests/codegen/array_basics.rsnext to the #452tests: the two issue repros (for-loop garbage sequence and terminating while),
a self-referential swap, a float flavour, the runtime-seeded control, a
same-representation guard that must not widen, and a heap-clean (allocs == frees)
guard. Verified red without the fix and green with it. The sibling #452 tests
and the
array_basics/runtime_gc/foreach/control_flow/optimizerslices remain green.
Not fixed (out of scope, separate mechanisms — filed as follow-ups)
mixedinside theloop (
$v = $v - 1; $r = [$v, 0]) needs a real fixpoint, not a single-passprescan.
(
array<string>→array<int>) would additionally need the rebind coerced tomixed storage.
[]) is a distinct element-typing gap(
[]→array<never>/Void) with no loop or self-reference; it is notaddressed here.
https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L