fix(codegen): box a tagged nullable int for a mixed parameter - #1122
Guikingone wants to merge 1 commit into
Conversation
`var_export()` on a `?int` segfaulted. `emit_box_current_value_as_mixed` matched
on the DECLARED type, where `PhpType::Union(_)` means "already a boxed Mixed" —
true of every nullable union except this one. With the default tagged null
representation `int|null` is an unboxed two-word `{payload, tag}` pair, and it
reached the `PhpType::Mixed | PhpType::Union(_)` arm before `PhpType::TaggedScalar`
was considered, so NOTHING was emitted. The callee read the raw payload word as a
Mixed pointer, and the caller's own `__rt_decref_mixed` ran on the integer.
The match is now on the representation. One line; the `TaggedScalar` arm that
boxes the payload with its dynamic tag was already there and unreachable.
The issue's perimeter is narrower than the defect in one direction and wider in
two others, measured at `89a923f4b4`:
- it is NOT about properties. A nullable-int LOCAL and a method's `?int` return
crash identically — `var_export(nint(1), true)` with `function nint(int $i): ?int`
needs no class at all.
- it is NOT nullable in general, it is `int|null`. `?string`, `?float`, `?bool`
and `?array` are already boxed Mixed and were always correct, in a property as
well as in a local.
- it is not only `var_export`. Of twenty consumers probed against a `?int`
holding `5`, thirteen were correct, `var_export` segfaulted, `json_encode`
answered `null`, and `is_numeric`, `in_array`, `array_sum`, `implode`,
`serialize` and a hash key were refused by the backend outright.
This fixes every consumer reached through a call's `mixed` parameter — a free
function, a method, a closure, a first-class callable, `call_user_func`, a
variadic, and a `mixed` property write, all measured. A by-reference `mixed`
parameter still refuses a tagged scalar at the backend, which is the honest
outcome: the writeback would have to unbox back into two words. The
runtime-call builtins and the array-element form are a separate mechanism, filed
with the full table.
Two earlier cuts are worth recording, because both were wrong in instructive ways
and both were caught by measurement rather than by reading.
Boxing at the EIR argument boundary fixed `var_export` and broke `abs($n)`, which
answered `4330504864`: a builtin's result type is computed by its own check hook
from the ARGUMENT's type, so converting the argument behind the hook's back
invalidates the answer — the checker had typed the call `int` because it saw
`int|null`, and the runtime returned a boxed Mixed the caller read as a raw
integer. Narrowing that to a DECLARED `mixed` parameter did not help, because
`abs`'s registry parameter is declared `mixed` too.
Moving it to the user-call path only then leaked one heap block per call: the
release machinery decides from the SOURCE type, and a value that is already Mixed
is not treated as a fresh box the caller must release. Fixing the ABI instead
leaves that bookkeeping intact — `allocs=1001 frees=1001`, `leak summary: clean`,
against 200 leaked blocks over 200 iterations for the EIR cut.
Fixes illegalstudio#1040
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
|
| /// reader infer a live branch, which is how the original bug hid. | ||
| pub(crate) fn emit_box_current_value_as_mixed(emitter: &mut Emitter, ty: &PhpType) { | ||
| match ty { | ||
| match &ty.codegen_repr() { |
There was a problem hiding this comment.
When a tagged nullable integer is passed to a mixed parameter, this path allocates a fresh Mixed cell and saves it in a temporary cleanup slot. That slot is decref'd only after the callee returns normally. If the callee throws and the caller catches the exception, unwinding preserves the caller frame but skips the post-call cleanup, while exceptional frame cleanup covers locals rather than this temporary. Each caught throwing call therefore leaks one Mixed cell, causing unbounded heap growth when repeated.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codegen_support/value_boxing.rs
Line: 77
Comment:
**Caught exceptions leak boxes**
When a tagged nullable integer is passed to a `mixed` parameter, this path allocates a fresh Mixed cell and saves it in a temporary cleanup slot. That slot is decref'd only after the callee returns normally. If the callee throws and the caller catches the exception, unwinding preserves the caller frame but skips the post-call cleanup, while exceptional frame cleanup covers locals rather than this temporary. Each caught throwing call therefore leaks one Mixed cell, causing unbounded heap growth when repeated.
**Knowledge Base Used:**
- [Native code generation and linking](https://app.greptile.com/illegal-studio/-/custom-context/knowledge-base/illegalstudio/elephc/-/docs/code-generation-and-linking.md)
- [Tests, fixtures, and compatiblity coverage](https://app.greptile.com/illegal-studio/-/custom-context/knowledge-base/illegalstudio/elephc/-/docs/tests-fixtures-and-compatibility.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Fixes #1040.
var_export()on a?intsegfaulted.emit_box_current_value_as_mixedmatched on the declaredtype, where
PhpType::Union(_)means "already a boxed Mixed" — true of every nullable union exceptthis one. With the default tagged null representation
int|nullis an unboxed two-word{payload, tag}pair, so it reached thePhpType::Mixed | PhpType::Union(_)arm beforePhpType::TaggedScalarwas considered and nothing was emitted. The callee read the raw payloadword as a Mixed pointer, and the caller's own
__rt_decref_mixedran on the integer.The match is now on the representation. One line — the
TaggedScalararm that boxes the payloadwith its dynamic tag was already there, sitting unreachable below the arm that swallowed its only
input.
Instrumentation found it, not reading: a temporary
eprintln!inmaterialize_direct_call_arg_for_paramprintedsource=Union([Int, Void]) repr=TaggedScalar param=Mixed repr=Mixed, proving the boxing arm wasreached and the helper emitted nothing.
The issue's perimeter, corrected on three points
Measured at
89a923f4b4:?intreturn crash identically;var_export(nint(1), true)needs no class at all;?string,?float,?booland?arrayare already boxed Mixedand were always correct;
var_export— of twenty consumers probed against a?intholding5, thirteen werecorrect,
var_exportsegfaulted,json_encodeanswerednull, andis_numeric,in_array,array_sum,implode,serializeand a hash key were refused by the backend.This fixes every consumer reached through a call's
mixedparameter — free function, method,closure, first-class callable,
call_user_func, variadic, and amixedproperty write, allmeasured. A by-reference
mixedparameter still refuses a tagged scalar at the backend, which isthe honest outcome: the writeback would have to unbox back into two words. The remaining eight
consumers are three separate mechanisms, filed as #1121 with the full table.
Two earlier cuts, both wrong, both caught by measurement
Boxing at the EIR argument boundary fixed
var_exportand brokeabs($n)→4330504864. Abuiltin's result type comes from its own check hook, which reads the ARGUMENT's type: the checker
typed the call
intbecause it sawint|null, and a boxed argument made the runtime return a boxedMixed the caller read as a raw integer. Narrowing to a DECLARED
mixedparameter did not help —abs's registry parameter is declaredmixedtoo.The same box on the user-call path only produced correct output and leaked one heap block per
call:
release_owned_call_arg_temporaries_with_signaturedecides from the SOURCE type, andcall_arg_gets_independent_mixed_boxreturns false once the source is already Mixed, so nothingreleased the fresh box. Fixing the ABI leaves that bookkeeping intact —
allocs=1001 frees=1001,leak summary: clean, againstlive_blocks=200over 200 iterations for the EIR cut.Tests
tests/codegen/null_sentinel/tagged.rs, seven added. Three fail with the line reverted (verified bymutation). The rest are anti-regression pins that pass either way by design: the other nullable
scalars, the builtins that must keep receiving the value unboxed, every other
mixedcall shape,and a generator parameter.
Full codegen suite: 9017 passed, 0 failed.
Reviewed
Kimi K3, GLM 5.3 and DeepSeek. Three major findings did not reproduce and are recorded here
because the measurements are worth having: two reviewers independently reported a leak in
zval_pack, readingctx.value_php_type(value)as the declaredUnion([Int, Void])— it is theEIR value type, already
TaggedScalar, so__rt_mixed_free_deepfires and the heap isleak summary: cleanwith and without the guard they proposed changing (DeepSeek reached the sameconclusion by reading). GLM also reported generators with a
?intparameter broken end-to-end;measured,
gen(?int $n)yields5|7|andgen2(?int $n, string $tail)yields5|'x'|, bothcorrect. DeepSeek's two minor findings were right and are fixed: the
Union(_)half of the firstarm is now unreachable and the comment said otherwise, and the call shapes it listed as untested are
now pinned.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr