bugfix: print leak — propagate ret_mode through rigid substitution

`(app print x)` under --alloc=rc leaked the heap-Str allocated by
`show x` in print's body: the let-binder `s` in
`(let s (app show x) (do io/print_str s))` was never flagged
trackable, so the drop site at scope-close emitted no
`ailang_rc_dec(s)`. Minimal repro `(body (app print 42))` produced
`allocs=1 frees=0 live=1`.

Root cause is two-layer:

1. examples/prelude.ail.json declared the Show class method `show`
   without an explicit `ret_mode` on the return type, so serde
   defaulted to ParamMode::Implicit. Fix: add `"ret_mode": "own"`
   on the Show.show method, parallel to how the heap-Str-producing
   builtins (int_to_str, bool_to_str, float_to_str, str_clone,
   str_concat) declare it. examples/prelude.ail regenerated via
   `ail render` to stay parse-isomorphic with the JSON.

2. crates/ailang-check/src/lib.rs `substitute_rigids` was silently
   stripping `param_modes` and `ret_mode` whenever it rebuilt a
   `Type::Fn`, so even after the prelude fix, the mono-synthesised
   `show__Int / Bool / Str / Float` lost the `Own` annotation
   during rigid substitution. Fix: preserve both fields through
   the substitution.

RED pin: crates/ail/tests/print_no_leak_pin.rs (test
`alloc_rc_print_int_does_not_leak_show_result_str`) + fixture
examples/print_int_no_leak_pin.ail asserts allocs == frees and
live == 0 for `(body (app print 42))` under --alloc=rc.

Ten mono-body hash pins re-recorded (eq__Int/Bool/Str,
compare__Int/Bool/Str, show__Int/Bool/Str/Float, eq__IntBox) —
the bodies are semantically identical; the canonical JSON now
carries the previously-stripped mode metadata, so the body hash
moves. Re-pinning is bookkeeping for the intentional drift, not
a workaround.

Surfaced 2026-05-14 during the rpe.1 BLOCKED orchestrator run
(Cat A). Existed in latent form since iter 24.3 (when Show + print
shipped); only became user-observable when `(app print ...)` joined
the corpus. cargo test --workspace: 565 / 0 / 3.

Open follow-up flagged for next /audit: grep for `Type::Fn { ..., .. }`
field-spread sites — any other shape that drops modes during
rebuild is a latent instance of the same bug class. Out of scope
for this minimal fix.
This commit is contained in:
2026-05-14 01:51:50 +02:00
parent 301cbc33a0
commit feb941363a
9 changed files with 271 additions and 17 deletions
@@ -0,0 +1,85 @@
# iter bugfix-print-leak-show-ret-mode — print() leaks heap-Str under --alloc=rc; Show class method's ret_mode stripped twice
**Date:** 2026-05-14
**Started from:** 301cbc33a0b2fed18f465c6c17041211ff7da35e
**Status:** DONE
**Tasks completed:** 1 of 1
## Summary
Two-layer bug: (i) the `Show` class method declaration in
`examples/prelude.ail.json` omitted the `ret_mode` key, so serde
defaulted it to `ParamMode::Implicit`; (ii) even with `ret_mode: own`
declared, `substitute_rigids` in `crates/ailang-check/src/lib.rs`
was hard-resetting `param_modes` to `vec![]` and `ret_mode` to
`Implicit` in its `Type::Fn` arm — silently stripping the mode
metadata during class-method mono synthesis (`show__Int`, `eq__T`,
`compare__T`, user-instance `eq__<UserType>`, etc.). Codegen's
`is_rc_heap_allocated` App-arm requires the callee's `ret_mode == Own`
to mark the let-binder trackable, so print's `let s = show x` binder
was never `ailang_rc_dec`'d at let-scope-close. The fix declares
`ret_mode: "own"` on the Show class method (parallel to the
`int_to_str`/`bool_to_str`/`float_to_str`/`str_clone`/`str_concat`
builtins which already carry `ret_mode: Own`) and repairs
`substitute_rigids`'s `Type::Fn` arm to clone `param_modes` and
preserve `ret_mode` through rigid substitution. The Form-A sibling
`examples/prelude.ail` is regenerated via `ail render` to stay in
lock-step per spec §C4 (b). Ten pinned mono-body hashes
(`eq__Int/Bool/Str`, `compare__Int/Bool/Str`, `show__Int/Bool/Str/Float`,
plus `eq__IntBox`) are re-pinned to the new GREEN baselines because
the mono bodies now carry the previously-stripped `param_modes` /
`ret_mode` metadata; bodies are semantically identical.
## Per-task notes
- bugfix.1: applied the fix.
- `examples/prelude.ail.json`: Show class method now has
`"ret_mode": "own"`.
- `examples/prelude.ail`: regenerated via
`cargo run -p ail -- render examples/prelude.ail.json
> examples/prelude.ail`; round-trips parse-isomorphic.
- `crates/ailang-check/src/lib.rs` `substitute_rigids` Type::Fn
arm: pattern-match all five fields explicitly; clone
`param_modes` and pass `*ret_mode` through (`ParamMode: Copy`).
Comment cites the bugfix iter id and the cause.
- `crates/ail/tests/mono_hash_stability.rs`: re-pinned 10 hashes
across the Eq/Ord and Show test fns; both bodies now report
`captured: {h}` on assertion failure (the Eq/Ord arm was
missing the captured-suffix; small in-place tidy folded into
the re-pin since the assertion-message string was being
rewritten anyway).
- `crates/ail/tests/eq_ord_e2e.rs`: re-pinned `eq__IntBox`
body hash to `3c4cf040cb4e8bb2`.
## Concerns
The debugger's cause analysis (carrier) pointed only at the JSON
omission. The JSON-only fix leaves the test RED — `substitute_rigids`
was silently stripping modes too. Suggest: at the next /audit pass,
verify there are no *other* call sites that build a `Type::Fn` with
explicit `..` field-spread and end up dropping modes. The pattern
`Type::Fn { ..., .. }` is the smell.
## Known debt
None — the fix is local. The wider question of "should `Type::Fn`'s
mode fields be field-init-by-name everywhere to prevent this class
of bug at the compiler level" is out of scope for this minimal fix.
## Files touched
- `examples/prelude.ail.json` — Show class: added `ret_mode: own`.
- `examples/prelude.ail` — regenerated Form-A sibling.
- `crates/ailang-check/src/lib.rs``substitute_rigids` Type::Fn
arm: preserve `param_modes` + `ret_mode`.
- `crates/ail/tests/mono_hash_stability.rs` — re-pinned 10 hashes
(6 Eq/Ord + 4 Show); added cause comments.
- `crates/ail/tests/eq_ord_e2e.rs` — re-pinned `eq__IntBox` hash.
- `crates/ail/tests/print_no_leak_pin.rs` (from /debug) — RED-pin
for the leak; now GREEN.
- `examples/print_int_no_leak_pin.ail` (from /debug) — fixture for
the RED-pin; now GREEN.
## Stats
bench/orchestrator-stats/2026-05-14-iter-bugfix-print-leak-show-ret-mode.json