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
+1 -1
View File
@@ -55,7 +55,7 @@
(param a)
(doc "Producer of a human-readable Str representation. Ships in milestone 24 with primitive instances for Int/Bool/Str/Float; user types declare their own instance.")
(method show
(type (fn-type (params (borrow a)) (ret (con Str))))))
(type (fn-type (params (borrow a)) (ret (own (con Str)))))))
(instance
(class Show)
(type (con Int))
+1
View File
@@ -232,6 +232,7 @@
"params": [{ "k": "var", "name": "a" }],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Str" },
"ret_mode": "own",
"effects": []
}
}
+6
View File
@@ -0,0 +1,6 @@
(module print_int_no_leak_pin
(fn main
(doc "RED-pin fixture for the 2026-05-14 rpe.1 Cat-A heap-Str leak. Under --alloc=rc, the prelude `print` function's body `let s = (app show x) in (do io/print_str s)` allocates a heap-Str via show, then passes it (borrow) to io/print_str. The let-binder `s` is `ret_mode: Own` for `show __Int`, so codegen should emit ailang_rc_dec(s) at let-scope-close. As of commit 301cbc3 the slab leaks: AILANG_RC_STATS=1 reports `allocs=1 frees=0 live=1` for the trivial `(body (app print 42))` program. Expected post-fix: `allocs == frees && live == 0`.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print 42))))