Skip to content

fix(codegen): box a tagged nullable int for a mixed parameter - #1122

Open
Guikingone wants to merge 1 commit into
illegalstudio:mainfrom
Guikingone:fix/1040-tagged-scalar-mixed
Open

Guikingone wants to merge 1 commit into
illegalstudio:mainfrom
Guikingone:fix/1040-tagged-scalar-mixed

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

Fixes #1040.

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, so it reached the PhpType::Mixed | PhpType::Union(_) arm before
PhpType::TaggedScalar was considered and 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, sitting unreachable below the arm that swallowed its only
input.

Instrumentation found it, not reading: a temporary eprintln! in
materialize_direct_call_arg_for_param printed
source=Union([Int, Void]) repr=TaggedScalar param=Mixed repr=Mixed, proving the boxing arm was
reached and the helper emitted nothing.

The issue's perimeter, corrected on three points

Measured at 89a923f4b4:

  • not properties — a nullable-int local and a method's ?int return crash identically;
    var_export(nint(1), true) needs no class at all;
  • not nullable in general?string, ?float, ?bool and ?array are already boxed Mixed
    and were always correct;
  • 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.

This fixes every consumer reached through a call's mixed parameter — free function, method,
closure, first-class callable, call_user_func, 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 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_export and broke abs($n)4330504864. A
builtin's result type comes from its own check hook, which reads the ARGUMENT's type: the checker
typed the call int because it saw int|null, and a boxed argument made the runtime return a boxed
Mixed the caller read as a raw integer. Narrowing to a DECLARED mixed parameter did not help —
abs's registry parameter is declared mixed too.

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_signature decides from the SOURCE type, and
call_arg_gets_independent_mixed_box returns false once the source is already Mixed, so nothing
released the fresh box. Fixing the ABI leaves that bookkeeping intact — allocs=1001 frees=1001,
leak summary: clean, against live_blocks=200 over 200 iterations for the EIR cut.

Tests

tests/codegen/null_sentinel/tagged.rs, seven added. Three fail with the line reverted (verified by
mutation). 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 mixed call 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, reading ctx.value_php_type(value) as the declared Union([Int, Void]) — it is the
EIR value type, already TaggedScalar, so __rt_mixed_free_deep fires and the heap is
leak summary: clean with and without the guard they proposed changing (DeepSeek reached the same
conclusion by reading). GLM also reported generators with a ?int parameter broken end-to-end;
measured, gen(?int $n) yields 5|7| and gen2(?int $n, string $tail) yields 5|'x'|, both
correct. DeepSeek's two minor findings were right and are fixed: the Union(_) half of the first
arm 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

`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
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Sep 20, 2026
@greptile-apps

greptile-apps Bot commented Sep 20, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR should not merge until tagged nullable-integer argument boxes are released when a mixed-parameter callee throws into a caller-side catch.

Fix All in Claude CodeFindings

  1. P1 Caught exceptions leak boxes
Fix with agent prompt
### Issue 1
src/codegen_support/value_boxing.rs:77
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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR changes Mixed boxing to dispatch on the runtime representation, allowing tagged int|null values to be boxed correctly at Mixed ABI boundaries, and adds documentation and broad end-to-end regression coverage.

  • Fixes normal-return behavior for free functions, methods, callables, variadics, and Mixed property writes.
  • Preserves unboxed argument behavior for builtins whose result typing depends on the source type.
  • Leaves the new caller-owned box unreleased when a mixed-parameter call throws into a catch in the caller.

Reviews (1) · Last reviewed commit: "fix(codegen): box a tagged nullable int ..."

/// 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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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:

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.

Fix in Claude Code Fix in Codex Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

var_export() on a nullable declared property segfaults

1 participant