feat(arrays): array_push() takes PHP's value list, unset() reaches a by-ref table - #1038
Conversation
|
3359987 to
6ff87b6
Compare
…by-ref table
Two compile-time capability gaps in the array-mutation path. Both refused rather
than miscompiled, and both rejected ordinary PHP.
## array_push() accepted only one value
$a = [1, 2];
array_push($a, 3, 4); // error: array_push() takes exactly 2 arguments
PHP's signature is `array_push(array &$array, mixed ...$values): int`. The contract
pinned `min_args: 2, max_args: 2` to reproduce a legacy CHECK arm, so the variadic
shape the golden signature already declared was unreachable, and `array_push($a)`
with no values -- legal since PHP 7.3 -- was rejected with it.
The arity is now `min_args: 1` with no maximum, exactly as the `array_unshift`
sibling has always been. Values are appended in source order, one at a time: each
append can reach `__rt_array_grow` and relocate the array, so `lower_array_push_value`
republishes the receiver BETWEEN values rather than once at the end. The `ir_lower`
fast path for a plain local re-reads the slot per value for the same reason --
appending into a stale pointer would write to freed storage.
`array_push` also returned `Void`, so `$n = array_push($a, 1)` read `NULL` where PHP
gives the new element count. That is fixed with the arity, because it lives in the
same descriptor and the same `check` hook: the count is read back off the array after
the appends, which also gives the value-less form its answer without a helper call.
`array_unshift` already did exactly this.
The eval interpreter was NOT the gap. `eval_array_push_unshift_declared_call` already
accepted the full value list and returned the count, so this brings the AOT backend up
to parity with eval rather than changing both.
## unset() on an element of a by-reference array parameter
function u(array &$a) { unset($a["b"]); }
// EIR backend error: unsupported EIR backend feature:
// unset target shape with 1 lowered operands
Every other write through a by-reference array already worked -- `$a["c"] = 3`,
`array_unshift()`, `sort()` -- because each routes its receiver through
`ReceiverPlace`. `lower_hash_unset` does too, and has all along. The refusal was in
`ir_lower`, which turned away a ref-bound receiver before the backend ever saw it.
That gate was written for the INDEXED case, and it is right there: `unset()` removes a
key without renumbering, so the local has to become a hash, and a callee cannot retype
the caller's slot, which still reads `array<T>`. An ASSOCIATIVE receiver has no such
problem -- the removal is in place and the copy-on-write split publishes through the
ref cell -- so the gate now applies only where its reasoning does.
The indexed case keeps its refusal, but `lower_unset_builtin`'s message no longer
describes only the untyped-property shape. It now names this one too, and says the
associative form is supported, so "array/hash elements" in the supported list stops
reading as a blanket promise.
## Verified
Twelve `array_push` shapes and eight `unset` shapes against host PHP 8.5.10, all
byte-identical: value counts from zero to five, the growth relocation a thirteen-value
push forces, a bool payload, all five receiver places, left-to-right argument
evaluation, the returned count; and for `unset`, multiple keys, a missing key, an
integer key, a `&$x` binding, a nested callee, copy-on-write protection of a snapshot
taken before the call, and unsetting every key.
Both halves were confirmed to be load-bearing by reverting each in turn: the push
tests fail with `array_push() takes exactly 2 arguments`, the unset tests with the
backend's unsupported-shape error.
`--gc-stats` reports `allocs=1000 frees=1000` over 200 iterations of both together,
and each half carries a `leak summary: clean` heap-debug test of its own -- the push
one because growth frees the previous buffer after republishing, the unset one because
the split has to account for the released payloads and the replaced table exactly once.
`cargo test --test codegen_tests -- codegen::arrays` passes in full (573), as do the
1680 lib tests and 1534 error tests.
## Docs and examples
`docs/php/arrays.md` gets the real `array_push()` signature and a by-reference `unset()`
section that also states the indexed limitation and its workaround. The generated
builtin pages are regenerated from the contract.
`examples/arrays/main.php` gains a multi-value push that shows the returned count;
`examples/assoc-arrays/main.php` gains a `drop_field(array &$record, string $field)`
that also shows the caller's earlier copy surviving. Both examples are byte-identical
to host PHP 8.5.10.
One pinned expectation moved: `test_error_array_push_wrong_args` asserted
`takes exactly 2 arguments` and now asserts `takes at least 1 argument`, which is what
host PHP reports for `array_push()` with no arguments.
Fixes #677
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
CI's `Builtins docs in sync` runs the generator with `--render --force`, which rewrites every page; the first commit ran it without `--force`, so the two hand-written internals pages it would have rewritten stayed stale and the drift gate failed. Both changes are the generator's own reading of this PR's source: - `array_push` internals: the signature summary follows the contract to `): int`. - `unset` internals: the lowering line follows `lower_unset_builtin` to its new line number, and the notes pick up the by-reference-indexed-array paragraph added to its docblock. The user-facing pages were already byte-identical, and `gen_module_sections.py` reports no module page changed. `audit_builtins.py` passes with 0 errors and `validate_site_compat.py` validates all 2071 generated pages. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
…f them
Review follow-up on three threads. The P1 was a real bug this PR introduced.
## P1 -- arguments observed earlier appends
PHP evaluates a call's arguments and only then enters the function, so nothing an
argument reads may observe an append the same call performs. The `ir_lower` fast
path for a plain local interleaved the two -- lower a value, append it, lower the
next -- which was invisible while the arity was pinned at one value and wrong as
soon as it was not:
$a = [10]; array_push($a, 1, count($a));
PHP: 10,1,1
elephc before this commit: 10,1,2
Three values compounded it: `array_push($b, count($b), count($b), count($b))` on
`[10]` gave `10,1,2,3` against PHP's `10,1,1,1`. A by-reference receiver took the
same path and was wrong the same way.
Every value is now lowered before the first append. The re-read of the local stays
INSIDE the append loop, for the reason it was there originally: an earlier append
may have relocated the array, and appending into the stale pointer would write to
freed storage.
The general runtime-call path never had the bug -- `lower_builtin_call_args` lowers
every operand first -- so a property receiver was already correct.
`test_array_push_evaluates_every_value_before_appending` covers both receiver kinds
so they cannot drift apart again, and its five rows are verbatim host PHP 8.5.10.
## P2 -- the note the change obsoleted
`docs/php/arrays.md` still carried "Removing an element from an array passed by
reference is not yet supported and reports a compile error" directly beneath the new
section that documents exactly that support. Removed; the paragraph above it already
states the one shape that remains refused (an indexed receiver) and why.
## P2 -- assembly comment convention
`load_array_push_length_to_result` gained the `// -- description --` group headings
on both architecture branches, and the inline comments now say what the value IS
(the post-append element count) rather than just where it is read from.
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
CI caught one more pinned expectation. `ir_backend_smoke_test`'s `array_push_builtin_return_is_legacy_null` asserted `$n = array_push($a, 20)` reads `null` -- the name says what it was pinning -- and this PR makes it read the element count, which is what PHP returns. Renamed to `array_push_builtin_returns_the_new_count` with `2:20`, and a second row added beside it for the variadic form: `array_push($a, 20, count($a))` on `[10]` gives `3:201`, so the smoke suite also covers the arity change and the argument-evaluation order the previous commit fixed. Both expectations are host PHP 8.5.10 output. The three `Non-Codegen Tests` jobs and the `Build & Test` gate that aggregates them were failing on this one assertion. Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
6ff87b6 to
a6e3a45
Compare
Grok review (READ-ONLY)Verdict: sound with nits — merge yes, with follow-ups. Both halves of #677 look load-bearing and correct. Greptile’s open P1 ( What this PR changes
Property / static / element receivers go through the existing
What looks fine
Follow-ups opened
|
|
I tried to reproduce this and could not. The scenario is unreachable, because the checker refuses exactly the reassignments that would make the receiver's representation stale — before lowering ever runs. Your own example: $a = [1, 2];
array_push($a, ($a = 5));Both reject it. The diagnostics differ in kind — a compile-time refusal against PHP's runtime I then went looking for a rebind the checker allows that would still reach the fast path:
So the only rebind that survives the checker is the one that leaves the receiver's representation intact, which is precisely the case the fast path is still correct for. The local-retyping rule that does the refusing is pre-existing and not introduced by this PR. Leaving this open rather than resolving it myself, in case you have a shape I did not think of — a by-reference parameter aliasing the receiver, or a property/static receiver, would be the places I would look next. If you have one that compiles and misbehaves, post it and I will fix it. |
Fixes #677 — both halves.
1.
array_push()accepted only one valuePHP's signature is
array_push(array &$array, mixed ...$values): int. The contract pinnedmin_args: 2, max_args: 2to reproduce a legacy CHECK arm, so the variadic shape the golden signature already declared was unreachable — andarray_push($a)with no values, legal since PHP 7.3, went with it.The arity is now
min_args: 1with no maximum, exactly as thearray_unshiftsibling has always been. Values are appended in source order, one at a time: each append can reach__rt_array_growand relocate the array, solower_array_push_valuerepublishes the receiver between values rather than once at the end. Their_lowerfast path for a plain local re-reads the slot per value for the same reason — appending into a stale pointer would write to freed storage.array_pushalso returnedVoid, so$n = array_push($a, 1)readNULLwhere PHP gives the new element count. That is fixed here rather than separately, because it lives in the same descriptor and the samecheckhook the arity does: the count is read back off the array after the appends, which also gives the value-less form its answer without a helper call.array_unshiftalready did exactly this.The eval interpreter was not the gap.
eval_array_push_unshift_declared_callalready accepted the full value list and returned the count, so this brings the AOT backend up to parity with eval rather than changing both.2.
unset()on an element of a by-reference array parameterEvery other write through a by-reference array already worked —
$a["c"] = 3,array_unshift(),sort()— because each routes its receiver throughReceiverPlace.lower_hash_unsetdoes too, and has all along. The refusal was inir_lower, which turned away a ref-bound receiver before the backend ever saw it.That gate was written for the indexed case, and it is right there:
unset()removes a key without renumbering, so the local has to become a hash, and a callee cannot retype the caller's slot, which still readsarray<T>. An associative receiver has no such problem — the removal is in place and the copy-on-write split publishes through the ref cell — so the gate now applies only where its reasoning does.The indexed case keeps its refusal, but
lower_unset_builtin's message no longer describes only the untyped-property shape. It names this one too and says the associative form is supported, so "array/hash elements" in the supported list stops reading as a blanket promise.Verified
Twelve
array_pushshapes and eightunsetshapes against host PHP 8.5.10, all byte-identical:__rt_array_growarray_push($s, f(1), f(2), f(3))evaluates left to right&$xbinding, a nested callee, copy-on-write protection of a snapshot taken before the call, unsetting every keyBoth halves were confirmed load-bearing by reverting each in turn: the push tests fail with
array_push() takes exactly 2 arguments, the unset tests with the backend's unsupported-shape error.--gc-statsreportsallocs=1000 frees=1000over 200 iterations of both together, and each half carries aleak summary: cleanheap-debug test of its own — the push one because growth frees the previous buffer after republishing, the unset one because the split has to account for the released payloads and the replaced table exactly once.cargo test --test codegen_tests -- codegen::arrayspasses in full (573), as do the 1680 lib tests and 1534 error tests.Docs and examples
docs/php/arrays.mdgets the realarray_push()signature and a by-referenceunset()section that also states the indexed limitation and its workaround. The generated builtin pages are regenerated from the contract.examples/arrays/main.phpgains a multi-value push showing the returned count;examples/assoc-arrays/main.phpgains adrop_field(array &$record, string $field)that also shows the caller's earlier copy surviving. Both examples are byte-identical to host PHP 8.5.10.One pinned expectation moved.
test_error_array_push_wrong_argsassertedtakes exactly 2 argumentsand now assertstakes at least 1 argument, which is what host PHP reports forarray_push()with no arguments (ArgumentCountError: array_push() expects at least 1 argument, 0 given).🤖 Generated with Claude Code
https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr