fix(ir,codegen): fetch by-ref foreach property sources for writing (#642) — stacked on #648 - #651
Conversation
|
The two pre-existing leaks mentioned above are now filed, both re-measured by me on unpatched
Neither is masked by an assertion in this PR: the string-property test added here asserts behaviour only, and does not claim a clean heap where there is not one. |
Greptile SummaryThis PR fixes a memory-safety bug (#642) where by-reference The fix introduces
Confidence Score: 5/5
|
| Filename | Overview |
|---|---|
| src/ir/instr.rs | Adds PropGetForWrite Op variant with effects `READS_HEAP |
| src/ir/validator.rs | Single-line addition registering PropGetForWrite alongside PropGet in the opcode-rules validator. Straightforward and correct. |
| src/codegen/lower_inst/objects/property_fetch_for_write.rs | New codegen backend for PropGetForWrite. Loads the container pointer from the property slot (indirecting through the ref cell when present), calls the correct COW helper (__rt_array_ensure_unique or __rt_hash_ensure_unique), reloads the receiver (scratch registers are clobbered by the helper), and publishes the unique container back to the same slot. Returns no reference (result is borrowed). Hard-errors on unsupported slot shapes to enforce frontend/backend coherence. |
| src/ir_lower/expr/property_fetch_for_write.rs | New IR-lowering frontend for the PropGetForWrite path. property_fetch_for_write_applies guards emission with three independent checks: the receiver is a concrete non-nullable object class, the property slot is an array/hash container the backend can split (mirroring the backend classifier), and every step in the receiver chain has stable backing storage. Falls back to ordinary PropGet when any check fails. Correctly handles $this as a stable root and recursively validates nested property chains. |
| src/ir_lower/stmt/typed_foreach.rs | Extends lower_foreach_source with a PropertyAccess branch for by-ref loops, setting source_is_borrowed_fetch from the returned ownership annotation. Renames the element-source pin function to pin_by_ref_foreach_borrowed_source so it covers both element and property sources. Logic and ordering constraints (pin after IterStart) are unchanged and correctly documented. |
| tests/codegen/types/iterable/foreach.rs | Adds 12 regression and guard tests covering all documented shapes: typed/untyped array/hash properties, $this, nested plain chains, static (guard), by-value (guard), shared-owner proofs, heap-debug leak checks, mid-loop visibility, receiver replacement, reference-cell slot, hooked/magic-get chains (must decline), and two structural assembly tests validating the exact load → split → republish sequence on both architectures. |
| docs/internals/the-ir.md | Documents PropGetForWrite in the IR reference table and adds a detailed prose explanation of why both refcount defects are closed by a single op. Accurately describes the stable-receiver constraint and the fail-loud backend policy. |
Sequence Diagram
sequenceDiagram
participant FE as IR Lowering (frontend)
participant IR as EIR (PropGetForWrite)
participant CG as Codegen (backend)
participant RT as Runtime
FE->>FE: lower_foreach_source detects PropertyAccess + by-ref
FE->>FE: "property_fetch_for_write_applies:<br/>non-null object? array/hash slot? stable chain?"
alt applies
FE->>IR: "emit PropGetForWrite(object, property)<br/>ownership = Borrowed"
FE->>FE: release owning temporary receiver (if any)
IR->>CG: lower_prop_get_for_write
CG->>CG: resolve_property_slot → offset, is_reference, is_packed
CG->>CG: property_container_split → helper + through_reference_cell
CG->>RT: load container from slot (via ref cell if needed)
CG->>RT: __rt_array_ensure_unique / __rt_hash_ensure_unique
RT-->>CG: unique container (refcount 1, old shared ref consumed)
CG->>CG: reload receiver (scratch clobbered by helper)
CG->>RT: store unique container back to property slot
CG-->>FE: result register (borrowed, no acquire)
else does not apply
FE->>IR: "emit PropGet(object, property)<br/>ordinary retaining read"
end
FE->>IR: "emit IterStart (splits borrowed source in place — no-op at rc=1)"
FE->>IR: "pin_by_ref_foreach_borrowed_source (after IterStart)<br/>keeps container alive if body drops receiver"
Reviews (5): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
nahime0
left a comment
There was a problem hiding this comment.
Blocking on two ownership/lifetime correctness gaps that are not covered by the current regression suite. Both reproduce on this exact head (7595113f) and diverge from PHP; one also triggers heap-debug refcount corruption and the other leaks. I left minimal repros and corrective directions inline. The changelog claim should be revised after those cases are fixed.
| // The receiver object is dropped here rather than held for the whole loop. This is safe | ||
| // because `property_fetch_for_write_applies` demanded a variable-rooted receiver: the base | ||
| // local keeps the object — and therefore its property slot — alive until the loop ends. | ||
| if ctx.value_is_owning_temporary(object_value) { |
There was a problem hiding this comment.
P1 — the receiver lifetime is not actually guaranteed here. property_chain_is_variable_rooted() only proves the syntactic root is a variable; an intermediate property can still be a get hook (or __get) that returns an owning temporary. For example:
class Inner { public array $x = [1, 2]; }
class Outer { public Inner $inner { get { return new Inner(); } } }
$o = new Outer();
foreach ($o->inner->x as &$v) { $v *= 2; echo $v; }On this head, releasing object_value here frees the only owner of x; the loop warns that its source is null and does not run, whereas PHP outputs 24.
Please either reject fetch-for-write unless every intermediate access is a stable backing slot (no property hook, magic getter, dynamic step, or temporary), or retain the receiver through loop teardown without adding a reference to the source container. Add a heap-debug regression for this hooked-intermediate chain.
| /// cell, or a dynamic slot is not a plain container pointer at a fixed offset, and a scalar | ||
| /// property has nothing to separate; all of them keep the ordinary read. | ||
| fn property_cow_helper(slot: &PropertySlot) -> Option<&'static str> { | ||
| if slot.is_packed || slot.is_reference || !slot.is_declared { |
There was a problem hiding this comment.
P1 — falling back for untyped/reference slots is unsafe because the frontend still emits PropGetForWrite for them when the inferred value is Array/AssocArray. Returning None skips the split and write-back, but the EIR result is still marked Borrowed; with a second owner, IterStart consumes the property's own reference and clones without republishing it.
Repros on this exact head:
- untyped property plus
$keep = $o->x: output is1,2|1,2, followed by heap-debugbad refcount; PHP gives2,4|1,2; - property promoted to a reference cell plus a second owner: writes are lost and heap-debug reports 4 live blocks / 184 bytes; PHP gives
2,4|2,4|1,2.
Please split/write back fixed untyped slots using the actual container layout and handle reference slots through the ref-cell, or make the frontend selection truly exclude these shapes. Add shared-owner heap-debug regressions for both.
| Releases are listed newest first. | ||
|
|
||
| ## [Unreleased] | ||
| - Fixed by-reference `foreach` over an object-PROPERTY source CORRUPTING the property (issue #642): after `foreach ($o->x as &$v) { ... }` the property was unusable — `implode()` printed nothing and `count()` returned a heap address — and an empty loop body destroyed it just as thoroughly, because the damage was in the loop's source handling rather than in the writes. `prop_get` retains, so the container reached the by-reference `iter_start` at refcount 2; the copy-on-write split consumes one reference from a shared source, and the loop-exit release then consumed a second, taking the property's own container to refcount 0 while the property still pointed at it. A `mixed`-valued property was hit twice over, since its dynamic iterator path republishes the converted container only to a local origin. A new EIR `PropGetForWrite` instruction splits the property's container up front, publishes the separated container back into the property slot, and hands the loop a borrowed pointer, so no second reference is ever taken: the writes land in the property, during the loop, as in PHP, on every supported target. Typed, untyped, hash-valued, string-valued and `$this`-accessed properties are covered, a property shared with another owner is separated so the loop mutates the property while the co-owner keeps PHP's value-copy semantics, and static properties, by-value `foreach`, dynamic and hooked properties are unchanged. |
There was a problem hiding this comment.
This entry is not accurate yet: it claims untyped properties are covered and hooked properties are unchanged, but the two blocking repros above contradict those statements. It is also far longer and more implementation-focused than the repository's terse, user-facing changelog entries. Please rewrite it after the supported scope is fixed and verified, and avoid claiming every-target coverage until compiler CI has completed on the exact head.
|
Thanks — both P1s taken, and the changelog point with them. Status: the branch is rebased onto current
|
6f7b262 to
df32c1e
Compare
|
Both P1s and the changelog point are addressed. Rebased onto current P1 1 — hooked and magic intermediate stepsConfirmed on the previous head: your I took the restrict-the-gate direction rather than holding the receiver across the loop. Chain stability is now checked per step: a variable or The reasoning for preferring it: PHP itself mutates a temporary in that shape, so nothing observable is lost by falling back, and the fallback path already gets the receiver's lifetime right. Retaining across every loop exit would be a new lifetime mechanism for no user-visible gain. Regressions cover both the get-hook and the P1 2 — untyped and reference slotsBoth of your repros confirmed, including the The defect is the disagreement itself: the frontend emitted the op, the backend silently declined it, and the result stayed marked Frontend and backend now classify slots from the same Output now matches I checked separately that turning the fallback into a hard error does not reject programs that used to compile. Seven shapes outside the new tests: One addition beyond the reviewIntegrating this branch on top of the updated #648 surfaced a gap neither side showed alone. Measured rather than assumed: with the flag forced to ChangelogRewritten short and user-facing. It now describes only what is actually covered — typed and untyped array and hash properties, Tests
|
A by-reference foreach over an object property did not merely lose its writes -- it freed the property's container while the property still pointed at it, so a later count() read the allocator's freelist pointer out of the dead block's header. The reference was consumed twice. Reading $o->x lowers to prop_get plus acquire, leaving the container at refcount 2, where a plain local is borrowed at refcount 1. At refcount 2 __rt_array_to_mixed's leading ensure_unique clones and drops the caller's reference, and the clone reaches only the iterator's private slot, because the existing write-back recognizes LoadLocal producers alone. The loop then mutated the clone (writes lost) while the exit path released the original (container freed). Op::PropGetForWrite splits the container up front, republishes the unique container into the property's slot, and returns it Borrowed. One change closes both defects: with refcount already 1, ensure_unique converts in place and never changes the pointer, so the local-only write-back becomes irrelevant rather than insufficient, and the Borrowed result removes the release that was freeing the property's storage.
Two shapes the first PropGetForWrite got wrong, both ending the same way: the loop borrows a container nobody keeps alive for it. property_chain_is_variable_rooted only proved the SYNTACTIC root was a variable, so $o->inner->x passed the gate even when inner is a get hook or a __get. Both return a fresh object, and the read drops its receiver as soon as it takes the borrow -- freeing the container the loop was about to iterate. Chain stability is now checked per step: a variable or $this, optionally followed by declared, non-hooked, object-typed property slots. Restricting the gate rather than holding the receiver across the loop is what the case actually needs. PHP mutates a temporary there, so nothing observable is lost by falling back, and the fallback path already gets the receiver's lifetime right; retaining across every loop exit would be a new lifetime mechanism for no user-visible gain. The backend declined to split untyped and reference slots, but the frontend had already marked the result Borrowed. With a second owner the container is shared, IterStart's split consumes the reference the PROPERTY holds, and the loop mutates a copy nobody republishes -- lost writes plus a bad refcount at scope exit. Untyped slots hold the same plain container pointer as typed ones and are split identically; a reference slot is split one indirection deeper, reading and republishing through the cell so the alias observes the mutation. Frontend and backend now classify slots from the same ClassInfo metadata, and the backend has no fallback left: a slot it cannot split is a hard unsupported error. A plain read there would be unsound, and failing loudly is what keeps the two sides from drifting apart silently. PropGetForWrite was also missing from the op table in the-ir.md entirely; its row now lists exactly the effects Op::default_effects() assigns it. Claude-Session: https://claude.ai/code/session_01Nhxpk3SozBtVbqwh8grAAd
PropGetForWrite hands the loop the property's own container BORROWED, so the property slot is its only owner -- the same situation ArrayGetForWrite creates for an element. The element path already takes a lifetime pin for exactly that reason; the property path was returning its source without the flag that requests one, because it was written against the signature lower_foreach_source had before the pin existed. Measured, not assumed: with the flag hardcoded to false the new test prints "1," instead of "1,2,done" -- the loop stops after the first element because the body replaced the receiver and the container went away under the iterator.
df32c1e to
8c1b29d
Compare
…-property-source # Conflicts: # CHANGELOG.md # src/codegen/lower_inst/objects.rs # src/ir_lower/expr/mod.rs # src/ir_lower/stmt/mod.rs
|
Thanks for taking the rebase onto While re-checking this PR's scope against current
Those are outside this PR by design, and they are not in #642's reproducer either, so Verified alongside it that #580's element shape is fixed on current |
…-property-source # Conflicts: # src/ir_lower/stmt/typed_foreach.rs
Fixes #642.
Root cause: one reference consumed twice
Reading
$o->xlowers toprop_getplusacquire, leaving the container at refcount 2. A plain local is different:load_localis borrowed, refcount 1. That asymmetry is the whole bug.At refcount 2,
__rt_array_to_mixed's leadingensure_uniqueclones the container and drops the caller's reference. The clone reaches only the iterator's private slot, because the existing write-back (store_iter_source_to_origin_if_local→source_load_local_slot) returnsNonefor any producer that is notOp::LoadLocal— and here it isOp::Acquire. So:releasestill targets the original → refcount 0 → the property's container is freed while the property still points at itcount($o->x)returning a heap address is the direct consequence: it reads the first word of the freed block's header, where the allocator has written its freelist pointer.The measurements match the mechanism exactly:
$keep = $o->x;)The empty body corrupting already proves the damage is in setup and exit, not in the writes. The extra live reference masking it is the refcount arithmetic: 3 →
ensure_uniquedrops to 2 → the final release leaves 1, so it survives.Fix
Op::PropGetForWrite— the property-side twin of #648'sArrayGetForWrite. It splits the container up front, republishes the unique container into the property's slot, and returns it Borrowed.The two-part fix I initially expected collapsed into one, and the reason is worth recording: with refcount already 1,
ensure_uniquedoes not clone — it converts in place and returns the same pointer. So the local-only write-back becomes irrelevant rather than insufficient. And because the result is Borrowed, the exit-pathreleasedisappears too — that was the second consumption. One change closes both defects.Emitted AArch64:
No
acquire, norelease.Tests
12 regressions. Under TDD, 9 failed before the change and 2 passed (the
staticand by-value guards, which already worked).Shapes covered, all matching PHP: the report's repro, an empty body, a hash-valued property, an untyped property, a
staticproperty,$this->xinside a method, a property of strings, by-value on the same property (must NOT mutate), mid-loop visibility, and the extra-live-reference case.That last one is the one that proves both defects are closed rather than just the refcount:
The property is split into its own unique container and receives the writes;
$keepkeeps PHP's by-value copy semantics.The assembly test asserts the structural invariant — load the slot → split → republish into the same slot — with per-target mnemonics, and was validated with a negative control: reverting the six source files makes it fail on the host and under
ELEPHC_TEST_TARGET=linux-x86_64andlinux-aarch64withmissing prop_get_for_write lowering.Verification (macOS ARM64,
7595113fa)regression_580(#648 intact)foreach::iterabletyped_propertyproperty_arraybyreflinux-x86_64/linux-aarch64--heap-debugleak summary: cleancargo build/git diff --checkNarrow filters only, per the project's test policy. Linux not executed locally (no Docker), but both lowerings are covered by the assembly test.
An independent confirmation of the diagnosis
An untyped property (
public $x) lost its writes pre-fix but did not corrupt. Reason, measured:array<int>takes the staticIndexedpath, whereensure_unique_static_iter_sourcealways callsstore_result_value, while the dynamic path returns before the rebind. That asymmetry is precisely what madearray<mixed>lethal andarray<int>merely lossy.Not fixed here
Two pre-existing leaks surfaced during the measurements and are filed separately — neither is on this fix's path, and both were measured identically before and after it. Also:
subscript_chain_is_variable_rootedin #648 acceptsExprKind::Variable(_)but notExprKind::This; harmless today, but worth a look if$this->x[0]ever routes through it.