Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ All notable changes to elephc, a PHP-to-native compiler written in Rust.
Releases are listed newest first.

## [Unreleased]
- Fixed by-reference `foreach` over indexed and associative array elements silently discarding mutations or stopping early when the parent was replaced (issue #580).

## [0.26.3] - 2026-08-05
- Added tagless `.lfc` source files with per-file PHP/LFC classification across entry points, includes, and autoload; LFC always enables elephc extensions, while `--strict-php` remains PHP-only and now composes with `--define`, callable dispatch, and `eval()`.
Expand Down
46 changes: 45 additions & 1 deletion docs/internals/the-ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ Ownership operations:
| Op | Operand | Result | Effects | Lowering |
|---|---|---|---|---|
| `Acquire` | refcounted/string/callable value | `Void` or retained value alias | `REFCOUNT_OP`, maybe `WRITES_HEAP` | `__rt_incref`, string persist/retain, callable descriptor retain if added |
| `Acquire` + `Bool` immediate | same | same | same | same; the immediate marks a *lifetime pin* and only opts the pair out of acquire/release cancellation |
| `Release` | owned value | `Void` | `REFCOUNT_OP`, maybe `WRITES_HEAP`, debug may fatal | `__rt_decref_any`, `__rt_heap_free_safe`, callable descriptor release |
| `Move` | any value | same type | pure validator operation | no machine instruction |
| `Borrow` | value with live owner | same type | pure validator operation | no machine instruction |
Expand Down Expand Up @@ -540,7 +541,9 @@ across a reset point.
| `HashNew(key_type, value_type, capacity)` | none | `Heap(Hash)` | `alloc_heap` |
| `ArrayLen`, `HashLen` | container | `I64` | `reads_heap` |
| `ArrayGet` | array, index | element type | `reads_heap`, `may_warn`, maybe `may_fatal` |
| `ArrayGetForWrite` | array, index (`I64`) | element type, **borrowed** | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn` |
| `HashGet` | hash, key | value type | `reads_heap`, `may_warn`, maybe `may_fatal` |
| `HashGetForWrite` | hash, key | value type, **borrowed** | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn` |
| `ArraySet` | array, index, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` |
| `HashSet` | hash, key, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` |
| `ArrayPush`, `HashAppend` | container, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` |
Expand All @@ -556,6 +559,43 @@ All mutating operations must preserve copy-on-write. The builder emits
`ArrayEnsureUnique`/`HashEnsureUnique` before mutation unless prior ownership
proofs make it unnecessary.

`ArrayGetForWrite` and `HashGetForWrite` are the read side of that rule for a
container element that is about to be mutated through an alias — today, the
source of a by-reference `foreach` (issue #580). Unlike `ArrayGet`/`HashGet` they
take no reference for the caller; they separate the receiver, then split the
element from any co-owner and store the separated container back into the
receiver's element slot, so the result is owned by the parent and unique. That is
what lets `foreach ($a[0] as &$v)` and `foreach ($h['a'] as &$v)` write through
to their sources: the plain retaining read left the element shared, and
`IterStart`'s own copy-on-write split then gave the loop a private copy to mutate
and discard.

The two differ only in how they address the element slot. `ArrayGetForWrite`
scales an integer key into the indexed payload, so it requires an `I64` key.
`HashGetForWrite` cannot compute an address, so it takes the matching entry's
address from `__rt_hash_get` (returned in `x4` on AArch64, `r8` on x86_64, null
on a miss) and splits the container that entry holds; string and integer keys are
both fine, since the lookup normalizes them. Both require an array or hash
element, each split with its own runtime helper, and both keep the plain read's
missing-key warning and null-container sentinel. Every other shape — a `Mixed`
element in particular, whose read can materialize a fresh box instead of the
slot's own storage — keeps the retaining read. The receiver split only happens
for a receiver that came from a local slot, since the new container has to be
published somewhere.

Because the result is borrowed, the parent's element slot is its only owner, and
the loop body can drop that parent (`$a = []`, `unset($a)`) while the iterator is
still running. The by-reference `foreach` therefore takes a **lifetime pin** on
the source — an `Acquire` marked with a `Bool(true)` immediate — emitted *after*
`IterStart`, and releases it on every exit: the loop's own exit block for normal
termination and `break`, and `emit_innermost_loop_cleanups` for `break N`,
`return`, and `throw`. The ordering is load-bearing in both directions. Earlier
than `IterStart` and the extra reference makes that instruction's
`__rt_array_ensure_unique` split, handing the loop the private copy this whole
mechanism exists to avoid; later than the last exit and the element outlives the
program's need for it. PHP gets the same effect for free: its by-reference
`foreach` holds a reference to the iterated array itself.

### Iterables, SPL, and Foreach

| Op | Operands | Result | Effects |
Expand Down Expand Up @@ -979,7 +1019,11 @@ phase commits them, sharing `replace_all_uses`, `resolve_chains`, and
scalar slots are not aliased.
- **Paired acquire/release cancellation** — an `acquire` whose result is used
exactly once, by its `release`, drops both. The single-use guard makes this
refcount-neutral on every path regardless of distance between the two ops.
refcount-neutral on every path regardless of distance between the two ops. An
`acquire` carrying an immediate is a **lifetime pin** and is exempt: its result
is deliberately never read, because the reference exists so the value survives
an interval in which another owner may release it, and that raised refcount is
exactly what the program observes.
- **String-literal concat folding** — `str_concat(const_str a, const_str b)`
interns `a ++ b` into the data pool and becomes a single `const_str` marked
`persistent` so cleanup never frees the literal. Nested concats converge across
Expand Down
2 changes: 2 additions & 0 deletions src/codegen/lower_inst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId)
Op::ArrayLen => arrays::lower_array_len(ctx, &inst),
Op::ArrayGet => arrays::lower_array_get(ctx, &inst, true),
Op::ArrayGetSilent => arrays::lower_array_get(ctx, &inst, false),
Op::ArrayGetForWrite => arrays::lower_array_get_for_write(ctx, &inst),
Op::ArrayIsset => builtins::lower_array_isset(ctx, &inst),
Op::ArrayElemAddr => arrays::lower_array_elem_addr(ctx, &inst),
Op::ArraySet => arrays::lower_array_set(ctx, &inst),
Expand All @@ -236,6 +237,7 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId)
Op::HashNew => hashes::lower_hash_new(ctx, &inst),
Op::HashLen => hashes::lower_hash_len(ctx, &inst),
Op::HashGet => hashes::lower_hash_get(ctx, &inst, true),
Op::HashGetForWrite => hashes::lower_hash_get_for_write(ctx, &inst),
Op::HashGetSilent => hashes::lower_hash_get(ctx, &inst, false),
Op::HashIsset => builtins::lower_hash_isset(ctx, &inst),
Op::HashSet => hashes::lower_hash_set(ctx, &inst),
Expand Down
Loading
Loading