Skip to content

fix(ir_lower): balance refcounts for a directly-passed owned mixed argument returned by the callee - #618

Merged
nahime0 merged 2 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/604-owned-mixed-call-arg
Jul 28, 2026
Merged

fix(ir_lower): balance refcounts for a directly-passed owned mixed argument returned by the callee#618
nahime0 merged 2 commits into
illegalstudio:mainfrom
mirchaemanuel:fix/604-owned-mixed-call-arg

Conversation

@mirchaemanuel

@mirchaemanuel mirchaemanuel commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Passing a freshly boxed owned mixed value directly as a call argument to a
function that returns that parameter, and consuming the result, corrupted refcounts:
heap debug aborted with bad refcount.

<?php
function idv($x) { return $x; }
$c = 0;
for ($i = 0; $i < 20; $i++) { $r = idv($i + 1); $c = $c + $r; }
echo $c, "\n"; // PHP: 210

Root cause

User functions return refcounted parameter storage without acquiring it for the
caller
, so the argument box ($i + 1) and the returned box are the same
allocation. The caller released it as an argument temporary after the call and then
re-acquired the same box to store the result — the single reference was dropped once
too often, and the acquire ran on an already-freed block (incref-on-freed → bad
refcount).

The existing argument-release suppression for this aliasing only fired on the
conservative may_alias path, and call_result_may_alias_arg excludes fresh
checked-arithmetic boxes there on purpose (an unproven/builtin callee must still
release them, #486). So the proven case (idv returns its parameter) fell through
and double-released.

Fix

Add a proven-return path to the suppression: when the callee is proven to return a
parameter, suppress the argument release even for a fresh boxed $i + 1, letting
ownership flow through the result (released once after the retaining store). The pure
type-compatibility check is split into arg_and_result_types_can_alias, so the proven
path admits those boxes without weakening the may_alias path (which keeps the
checked-arithmetic exclusion, so #486 stays fixed).

Known trade-off

ReturnArgAlias::Parameters is a MAY summary (a union over branches), so a callee
that returns the parameter only conditionally (if ($c) return $x; return 7;) is
still reported as possibly returning it, and the fix suppresses the argument release
on every call. When the runtime takes the aliasing branch the box flows through the
result and is freed exactly once (clean); when it takes the non-aliasing branch the
suppressed owned box leaks. This is the same deliberate leak-over-crash trade-off the
existing may_alias suppression already makes for array/hash arguments — a leak is
preferred over the previous refcount corruption/crash. #619 tracks
runtime alias disambiguation to recover those leaked boxes.

Tests

  • tests/codegen/runtime_gc/regressions.rs: loop repro (210), single-call (6),
    method dispatch (210), discarded result (20), conditional-return alias path
    (210), and controls for the via-local and callee-returns-a-constant paths (must
    stay clean and still release the argument).
  • src/ir_lower/tests/ownership.rs: IR-level assertion that the argument release is
    suppressed when the callee is proven to return it.

Fixes #604

https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L

@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Review note: ReturnArgAlias::Parameters is a MAY summary, so conditional-return callees also get the suppression — verified empirically that the aliasing branch is clean and the non-aliasing branch leaks the suppressed box, the same trade-off the long-standing may_alias suppression makes for container arguments (reverified with a fresh-array arg on the same shape). The residual leak and the runtime-disambiguation fix are tracked in #619.

… aliases an owned box

A freshly boxed owned `mixed` value passed directly as a call argument to a
function that returns that parameter, whose result is then consumed, corrupted
refcounts: heap debug aborted with `bad refcount` (issue illegalstudio#604).

The argument box and the returned box are the same allocation (`return $x` hands
the parameter straight back). The caller released the box as an argument
temporary after the call and then re-acquired the same box to store the result,
so the single reference was dropped once too often — the acquire ran on an
already-freed block.

The argument-release suppression that exists for this aliasing only fired on the
conservative `may_alias` path, and `call_result_may_alias_arg` deliberately
excludes fresh checked-arithmetic boxes there (an unproven callee must still
release them, issue illegalstudio#486). Add a proven-return path: when the callee is proven
to return this parameter, suppress the argument release even for a fresh boxed
`$i + 1`, letting ownership flow through the result (released once after the
retaining store). Split the pure type-compatibility check into
`arg_and_result_types_can_alias` so the proven path admits those boxes without
weakening the `may_alias` path.

Regression tests cover the loop repro, the single-call and method shapes, the
discarded-result shape, and controls for the via-local and callee-returns-a-
constant paths (which must stay clean and must still release the argument).

Claude-Session: https://claude.ai/code/session_01HRvbcjPHa3kAojdCjNZL5L
@mirchaemanuel
mirchaemanuel force-pushed the fix/604-owned-mixed-call-arg branch from 796a130 to e452449 Compare July 26, 2026 22:57
@mirchaemanuel

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (ba836179c) — e452449ba. Before doing it I checked whether the fix was still warranted at all, since #603 and #606 reworked the ownership machinery underneath it. It is: on clean main, unpatched, under --heap-debug:

function idv($x) { return $x; }
$c = 0;
for ($i = 0; $i < 20; $i++) { $r = idv($i + 1); $c = $c + $r; }
echo $c, "\n";
Fatal error: heap debug detected bad refcount

Four of this PR's seven cases still fail on main. One of them is worth calling out separately: the single-call shape doesn't crash, it silently prints an empty line instead of 6, with a clean heap. That one is a quiet wrong answer rather than an abort.

Why the fix still belongs where it is. I expected to have to re-express it against the Ownership::Owned contract, and that turns out to be impossible rather than merely unnecessary: finalize_value_ownership_metadata() is called at src/ir_lower/function.rs:958, after the whole body is lowered and after both prune passes. release_owned_call_arg_temporaries_with_signature runs during expression lowering, when every value is still MaybeOwned. The new metadata simply doesn't exist yet at the suppression site, so it can't host this.

If anything the reverse holds — main's own doc on value_is_borrowed_user_call_result (src/ir_lower/context.rs:1731-1736) already states that the result "remains an owning temporary when an owning argument temporary transfers through the call". This patch is the missing application of a contract already published. And main already labels the call result own=owned while still emitting the release of the argument that aliases it, so metadata and emission contradict each other today.

The rebase itself was mechanical. Despite the churn, release_owned_call_arg_temporaries_with_signature is byte-identical between the merge base and main (moved one line, 1323913238), and none of the ~20 churn hunks in expr/mod.rs fall anywhere near it. context.rs picked up #606's loop-storage fields and #603's finalize_value_ownership_metadata 37 lines above our hunk, no overlap. Only tests/codegen/runtime_gc/regressions.rs actually conflicted, and only because both sides append at the end and diff3 factored out their identical three-line assertion tail — the same shape #613 had. Resolved by restoring the tail to main's last test rather than letting the two blocks share one copy.

Added the missing CHANGELOG bullet. The original commit had none, which AGENTS.md requires in the same PR — that would have failed CI independently of the rebase.

The ReturnArgAlias::Parameters trade-off, now measured rather than argued. With a conditional callee maybe($x, $c) { if ($c) return $x; return 7; }:

main this branch
aliasing branch ($c true) bad refcount clean, 210
non-aliasing branch ($c false) clean leaks 20 blocks / 800 bytes

Same with --ir-opt=off. That's one block per iteration, and it's the documented consequence of the summary being a MAY union — a leak in place of a crash, tracked in #619. Worth knowing quantified.

For #619, one thing I hadn't realised: a precise mechanism already exists in-tree. emit_call_arg_temp_cleanups / call_result_can_alias_mixed_temp / emit_branch_if_cleanup_temp_aliases_result (lower_inst.rs:5839-5909) do exactly this runtime pointer comparison — but only for cleanup_slots, which are populated for ABI-boxed arguments with a non-Mixed source. The #604 case is Mixed→Mixed, never gets a slot, and its release comes from EIR. So closing #619 means either a "release-unless-aliases-result" EIR form or extending cleanup slots to EIR-owned Mixed arg temps. Bigger than this PR.

Verification on e452449ba, macOS aarch64:

the 7 PR cases, by name 7 passed, 0 failed
repro by hand, heap debug 210, allocs=82 frees=82 live_blocks=0, clean
codegen::runtime_gc 247 passed, 0 failed
codegen::callables 400 passed, 0 failed
error_tests 1137 passed, 0 failed
cargo test -p elephc --lib 761 passed, 0 failed
test target compile clean

Not run locally: the Linux targets and the full 6800-test codegen binary — CI covers both better than I can.

…all-arg

# Conflicts:
#	CHANGELOG.md
#	tests/codegen/runtime_gc/regressions.rs
@nahime0

nahime0 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Maintainer refresh completed on the contributor branch.

Focused local validation:

Published head: 294eae29788683e11594c7d39ba96c82ca36af5e.

Exact-head CI run 30275882787 is terminal green across macOS ARM64, Linux x86_64, and Linux ARM64: 111 jobs succeeded, the benchmark job was expectedly skipped, and there were no failures. GitHub reports the PR CLEAN / MERGEABLE.

@nahime0
nahime0 merged commit 3a5336d into illegalstudio:main Jul 28, 2026
113 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:eir Touches EIR definitions, lowering, validation, or passes. 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.

Direct owned boxed-mixed call argument with a consumed return value corrupts refcounts (heap debug: bad refcount)

2 participants