fix(ir,codegen): fetch by-ref foreach element sources for writing (#580) - #648
Conversation
Greptile SummaryThis PR fixes a silent mutation-loss bug in by-reference
Confidence Score: 5/5
|
| Filename | Overview |
|---|---|
| src/ir_lower/stmt/typed_foreach.rs | Core fix site: splits lower_foreach_source from the old plain lower_expr call; emits ArrayGetForWrite/HashGetForWrite for by-ref element sources; adds lifetime pin after IterStart and releases it on all exit paths. Logic is correct and well-documented. |
| src/ir_lower/expr/array_access.rs | Adds lower_by_ref_foreach_element_source and helpers. The element_fetch_for_write_op guard correctly rejects nullable receivers, non-integer keys for indexed arrays, Mixed elements, and non-variable-rooted chains before opting into the fetch-for-write path. Recursive receiver walk handles nested chains correctly. |
| src/codegen/lower_inst/arrays.rs | Adds lower_array_get_for_write, separate_get_for_write_receiver, array_get_for_write_cow_helper, and target-specific in-bounds emitters. Both AArch64 and x86_64 paths correctly spill the element slot address across the COW helper call, then store the result back unconditionally. |
| src/codegen/lower_inst/hashes.rs | Adds lower_hash_get_for_write and per-target helpers. Uses __rt_hash_get's new entry-address output (x4/r8) to locate the value slot, then separates it in place. Miss and null-receiver paths correctly reuse the existing sentinel/warning infrastructure. |
| src/codegen_support/runtime/arrays/hash_get.rs | Adds entry-address output (x4 on AArch64, r8 on x86_64, 0 on miss) to __rt_hash_get. Both targets are handled; the miss path explicitly zeroes the register, and the found path exposes an address the probe already computed at no extra cost. |
| src/ir_lower/ownership.rs | Adds acquire_lifetime_pin_if_refcounted — same as acquire_if_refcounted but passes Immediate::Bool(true) to mark the pair as exempt from the peephole cancellation pass. Returns source value unchanged for non-refcounted types so callers can detect "nothing to pin" by comparing value IDs. |
| src/ir_passes/peephole/acquire_release.rs | Correctly skips any Acquire with a non-None immediate, preventing cancellation of lifetime pins. The guard is narrow enough not to affect normal acquires (which always have None immediate). |
| src/ir/instr.rs | Adds ArrayGetForWrite and HashGetForWrite opcodes with correct broad effect flags (`READS_HEAP |
| src/ir/validator.rs | ArrayGetForWrite validates both operands (array + I64 key). HashGetForWrite validates operand count and the hash operand only — intentionally consistent with the existing HashGet pattern, since hash keys are polymorphic (string or int) and the hash lookup normalizes them. |
| src/ir_lower/context.rs | Adds source_pin: Option<LoopCleanup> to LoopFrame. All non-foreach loop constructors correctly initialize it to None. |
| tests/codegen/types/iterable/foreach.rs | 13 new regression tests with good coverage: basic indexed/hash element mutation, mid-loop parent visibility, nested access, keyed form, hash-valued element, shared-owner scenario, missing-index warning+skip, by-value guard, plain-local guard, and heap-debug leak guards. |
| src/ir_passes/tests/peephole_test.rs | Adds lifetime_pin_acquire_release_pair_is_not_cancelled test that directly constructs a pinned acquire/release pair and asserts the peephole pass leaves it intact. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["lower_foreach_source(array, value_by_ref)"] --> B{value_by_ref AND\narray is ArrayAccess?}
B -- No --> C["lower_expr(array)\nsource_is_borrowed = false"]
B -- Yes --> D["lower_by_ref_foreach_element_source\n(receiver, index, expr)"]
D --> E["lower_by_ref_foreach_source_receiver(receiver)"]
E --> F{receiver is\nArrayAccess?}
F -- Yes --> D
F -- No --> G["lower_expr(receiver)\n(base local)"]
G --> H["element_fetch_for_write_op(array_value, index)"]
H --> I{Nullable receiver?\nOr non-container element?\nOr not variable-rooted?}
I -- Yes --> J["fallback: lower_array_access_from_value\n(retaining read)"]
I -- No --> K{Indexed array\nor Hash?}
K -- "Array (int key)" --> L["emit Op::ArrayGetForWrite\nresult = Borrowed"]
K -- Hash --> M["emit Op::HashGetForWrite\nresult = Borrowed"]
L --> N["source_is_borrowed = true"]
M --> N
N --> O["emit Op::IterStart\n(__rt_array_ensure_unique — no-op: element already unique)"]
O --> P["pin_by_ref_foreach_element_source\nemit Acquire+Bool(true) pin\nset pin = Owned"]
P --> Q["Loop body runs"]
Q --> R{Loop exit path}
R -- "normal termination\n(exit block)" --> S["release_if_owned(pin)"]
R -- "break / break N\nreturn / throw" --> T["emit_innermost_loop_cleanups\nrelease_if_owned(source_pin)"]
Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
nahime0
left a comment
There was a problem hiding this comment.
Requesting changes for one blocking lifetime regression plus two scope/documentation corrections.
The COW split fixes the covered cases, and the focused regression suites are green, but the borrowed element is not kept alive when the loop body replaces its parent. I reproduced this against the exact head: PHP and base 9f74f5300 print 1,2,done, while this PR prints 1,done with IR optimization both on and off. The inline comment includes the reproducer and the required lifetime invariant.
Please also avoid auto-closing #580 while its hash-receiver case remains silently wrong, and align the IR effects table with the implementation.
Operationally, the branch currently conflicts with live main in CHANGELOG.md; after the code changes and base refresh, CI needs to be terminal on the new exact head.
|
Thanks for the review — taking all three points. A short status so this does not sit silent while I work on them. The branch is rebased onto current
I will come back with the reproducer output before and after, and the test numbers. |
f72d0e3 to
57e2672
Compare
A by-reference foreach must mutate its source in place. For a plain local that already happens: loading a local yields the local's own storage with no extra reference, so the element writes land in it. For an array element it did not: Op::ArrayGet returns the parent's container WITH a retain, which leaves it at refcount 2, so the by-ref iteration copy-on-writes a private container, mutates that, and drops it. Every write was lost, with no crash and no diagnostic. Adds Op::ArrayGetForWrite, which performs the copy-on-write split up front and republishes the unique container into the parent's element slot, so the loop iterates storage the parent actually shares. Its effects say so: it writes heap and local state and may warn, which keeps it from being reordered or folded against the plain reads around it, and preserves the undefined-key warning and null-container sentinel that a missing index must still produce (issue illegalstudio#556). By-value foreach keeps the retaining read: the extra reference is exactly what makes the runtime copy, and copying is that loop's semantics. Indexed receivers are covered, including elements that are themselves hashes. A hash receiver is not: reference-binding a hash element is unimplemented (the checker rejects an explicit `$r = &$h['k'];`), so those keep their previous behaviour.
Op::ArrayGetForWrite hands the loop the parent's element BORROWED: the parent's element slot is the only owner. That is what makes the writes land in the parent, and it is also what leaves the iterator holding a dangling pointer the moment the body drops that parent. `$a = []` or `unset($a)` inside the loop released the outer container, which released the element with it, so iteration stopped after the first element and the element write ran on freed storage — a silent use-after-free, not just a wrong iteration count. The loop now takes a lifetime reference of its own on the source, and drops it on every way out: its exit block covers normal termination and a plain `break`, and emit_innermost_loop_cleanups covers `break N`, `return`, and `throw`, exactly where the pre-existing source cleanup is already released. The acquire has to come AFTER IterStart. That instruction splits a by-reference source through __rt_array_ensure_unique, so a reference held across it would make the split fire and hand the loop the private copy this whole path exists to avoid. The pin also has to survive the optimizer. The paired acquire/release peephole cancels an Acquire whose only use is its Release, on the premise that the raised refcount in between is unobservable — the one premise a pin breaks by construction, which is why ELEPHC_IR_OPT=on kept miscompiling after the pin was added. Acquire now carries a Bool immediate marking it as a lifetime pin, and the peephole skips those pairs. PHP needs none of this: its by-reference foreach references the iterated array itself, so the array outlives the variable it came from.
…ects() The row listed only reads_heap and may_warn, while the implementation carries writes_heap, writes_local, alloc_heap and refcount_op as well. Those four are the reason the instruction is not reorderable against the plain reads around it, so omitting them contradicted the very ordering contract the surrounding prose relies on.
The hash half of illegalstudio#580 was left out of the indexed fix on the grounds that reference-binding a hash element is unimplemented. That reasoning does not apply: the fetch-for-write path never binds an alias, it separates the element and iterates the parent's own storage, so the checker's "requires an indexed array" restriction on `$r = &$h['k'];` was never a blocker. The defect is the same one, measured the same way. Allocation counts on `$h = ['a' => [1, 2]];` isolate it: baseline 3, by-value foreach 3, by-ref foreach with an EMPTY body 4 — one copy, no user write involved. Op::HashGet returns the parent's container with a retain, refcount 2, so iter_start copy-on-writes it and the loop mutates a private container it then drops. Adds Op::HashGetForWrite, the hash counterpart of Op::ArrayGetForWrite. The two differ only in how the element slot is addressed: an indexed element is reached by scaling the key into the payload, whereas a hash entry has to be probed for. __rt_hash_get already builds that entry address while probing, so it now returns it (x4 on AArch64, r8 on x86_64, null on a miss) instead of discarding it. Both registers are caller-saved and were already clobbered by the helper's own probing and key comparisons, and no caller reads them after the call, so no existing contract changes. Everything downstream is shared with the indexed path: separate the receiver, split the container the slot holds, publish the unique pointer back into that slot, hand the result out borrowed. String and integer keys both work, since the lookup normalizes them. A hash whose value type is Mixed keeps the retaining read, exactly as an indexed Mixed element does: that slot can hold an invoker ref-cell marker whose read materializes a fresh box rather than the slot's own storage. Claude-Session: https://claude.ai/code/session_01Nhxpk3SozBtVbqwh8grAAd
57e2672 to
39db03f
Compare
|
All three points are addressed. The branch is rebased onto current Blocking lifetimeYour reproducer, measured on the exact head,
The loop now takes a lifetime reference of its own on the source and drops it on every way out: its exit block covers normal termination and a plain Two details worth flagging, because both changed the shape of the fix: The acquire has to come after The pin does not survive the optimizer unmarked. The paired acquire/release peephole cancels an Hash receiverFixed here rather than deferred, so the closing keyword stays honest. The reason the hash half was excluded does not hold up: the checker's restriction is on Measured the same way as the indexed case, on
End-to-end, The test that pinned Scope left out: a hash whose value type is IR effects table
Tests
|
…-borrowed-source # Conflicts: # CHANGELOG.md
…-borrowed-source # Conflicts: # src/ir_lower/expr/mod.rs # src/ir_lower/stmt/mod.rs
Brings in parse_url (illegalstudio#667) and the by-ref foreach borrowed-source fixes (illegalstudio#648), 15 upstream commits in all. Every conflict was a GENERATED builtin page — 152 files under `docs/php/builtins` and `docs/internals/builtins`, and no source file at all. They were resolved the only way generated files can be: by regenerating them from the two registries with `cargo build --example gen_builtins` + `scripts/docs/extract_builtins.py --render --force`, not by picking a side. 494 builtins, 962 pages; both CI audits (`audit_builtins.py`, `validate_site_compat.py`) report 0 errors.
Fixes #580 for indexed receivers. Based on current
main9f74f5300.Root cause
Measured rather than guessed. Allocation counts under
--heap-debugisolate which step copies:$a = [[1,2]];(baseline)$b = $a[0];foreach ($a[0] as $v) {}by valueforeach ($a[0] as &$v) {}by ref, empty bodyOp::ArrayGetdoes not copy: it returns the parent's own container with a retain. That retain leaves the element at refcount 2 (parent + temporary), so the by-ref iteration copy-on-writes it, mutates the private copy, and releases it at the end of the loop. Every write is lost — no crash, no diagnostic.The working plain-local form differs by exactly one instruction, and the loop bodies are byte-identical:
The report guessed a missing write-back. It is upstream of that: the loop never shares the parent's storage in the first place.
Fix
Op::ArrayGetForWriteperforms the copy-on-write split up front and republishes the unique container into the parent's element slot, so the loop iterates storage the parent genuinely shares.Its effects are deliberately broad —
READS_HEAP | WRITES_HEAP | WRITES_LOCAL | ALLOC_HEAP | REFCOUNT_OP | MAY_WARN. Despite the name it is not a pure read: the split rewrites the receiver's element slot and the receiver's own local slot, so it must never be reordered or folded against the plain reads around it.MAY_WARNmatters too — a missing index must still emitUndefined array keyand yield the null-container sentinel the loop knows how to skip, which is what keeps #556 intact.By-value
foreachkeeps the ordinary retaining read: that extra reference is exactly what makes the runtime copy, and copying is precisely its semantics.PHP's mid-loop visibility is satisfied, which is the discriminator a write-back-only fix would fail:
Scope
Indexed receivers, including elements that are themselves hashes (
$a[0]holding['x' => 1]), and nested receivers ($a[0][0]).A hash receiver is not covered (
$h['a']). Reference-binding a hash element is unimplemented in elephc — an explicit$r = &$h['k'];is rejected by the checker with "Reference assignment to an array element requires an indexed array" — so there is no alias to iterate. Those keep their previous behaviour rather than starting to fail differently.Tests
13 regressions in
tests/codegen/types/iterable/foreach.rs, covering the indexed element source, mid-loop parent visibility, a nested element source, the keyed form, a hash-valued element of an indexed receiver, a source shared with another owner, an aliased parent, a missing index (warns and skips), plus guards that by-value must NOT mutate and that the plain-local form still does, and a heap-debug leak guard.Verification (macOS ARM64,
f72d0e3c7)ELEPHC_IR_OPT=offiterable::foreachbyref_foreach(the #556 guards)--heap-debugleak summary: cleancargo build/git diff --checkNarrow filters only, per the project's test policy; CI owns the full matrix. Linux targets not run locally (no Docker on this machine).
The
byref_foreachrow is the one worth checking first: an earlier attempt of mine, aliasing the element through a hidden ref-cell, made the indexed cases pass while regressing exactly those three #556 tests —LoadArrayElemRefCellyields a null pointer for a missing index and skips the warning, soforeach ($a[7] as &$v)crashed instead of warning and skipping. That approach was discarded for this one.Related, not fixed here
Two strictly worse defects found while diagnosing this, both pre-existing and filed separately: #642 (by-ref
foreachover an object property corrupts the property —count()returns a heap address) and #643 (by-refforeachinside a by-refarrayparameter fails to compile withEIR PhpTypeMismatch).