test(codegen): ignored RED pin for match-arm consume-split leak (#63 leg 3)
The third and final identified leg of #63: an owned heap param consumed on one match arm but live on a sibling fall-through arm is never dropped, because both codegen drop sites (the fn-return Own-param dec in lib.rs and the pre-tail-call dec in match_lower.rs) gate on the per-fn AGGREGATE consume_count. When the param is consumed on one arm (aggregate >= 1) but live on another, the gate skips the drop on the live arm too. This is the residual that keeps examples/series_sma.ail at live=2 after legs 1 and 2. Series-free minimal repro (a `Box` consumed on the `On` arm, live on the `Off` fall-through arm; main takes the `Off` path): `live=1` today. Committed #[ignore]: unlike legs 1 and 2, this is NOT a contained fix. The per-arm/per-path consume information is destroyed by the worst-case `max` merge in uniqueness.rs (`merge_states`) before MIR — MArm carries only {pat, body}, MirDef.consume is the per-fn aggregate — so codegen has no per-arm liveness to gate a fall-through drop on. Closing it needs a memory-model change (retain per-arm consume, surface it to codegen, emit the drop only on the live arm, never at the post-match join or the consuming arm — a live double-free hazard) and a fresh brainstorm cycle. The pin is committed ignored so the precise symptom and minimal repro are durable; un-ignore when the fix ships. The assertion is a correct symptom pin (live == 0); only the fix mechanism is undecided. refs #63
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
//! RED-pin for leg 3 of bug #63 — the per-arm-vs-per-fn consume-
|
||||||
|
//! accounting leak at a match fall-through (the last open leg of the
|
||||||
|
//! Series milestone #61 leak tail).
|
||||||
|
//!
|
||||||
|
//! Property protected: under `--alloc=rc`, when an `(own ...)` heap
|
||||||
|
//! param is consumed on ONE match arm but is LIVE (un-consumed) on a
|
||||||
|
//! SIBLING fall-through arm, it MUST be dropped on that sibling arm
|
||||||
|
//! before the fn returns. The runtime RC stats line at exit must
|
||||||
|
//! report `live == 0` (equivalently `allocs == frees`) even though
|
||||||
|
//! stdout is already correct.
|
||||||
|
//!
|
||||||
|
//! This is deliberately Series/RawBuf-FREE: a plain user ADT (`Box`)
|
||||||
|
//! leaks identically, so the pin guards the GENERAL memory-model
|
||||||
|
//! consume-accounting path, not a Series-specific surface. The shape
|
||||||
|
//! is the SMALLEST that reproduces — a plain (non-recursive) `match`
|
||||||
|
//! is enough; tail recursion is not required. `run` takes an
|
||||||
|
//! `(own Box) b` and an `(own Flag) f`, matches on `f`: the `On` arm
|
||||||
|
//! consumes `b` (forwards it into the consuming call `sink`), the
|
||||||
|
//! `Off` arm returns without touching `b`. `main` calls `run b Off`,
|
||||||
|
//! so the `Off` (fall-through) arm runs and `b` stays live.
|
||||||
|
//!
|
||||||
|
//! Root cause: the fn-return Own-param dec in
|
||||||
|
//! `crates/ailang-codegen/src/lib.rs` (~line 1484) and the
|
||||||
|
//! pre-tail-call Own-param dec in
|
||||||
|
//! `crates/ailang-codegen/src/match_lower.rs` (~line 823) both gate
|
||||||
|
//! the drop on `consume_count != 0 -> skip`, reading the PER-FN
|
||||||
|
//! AGGREGATE consume map (`self.consume`, keyed `(def, binder)`). For
|
||||||
|
//! `b` that aggregate is `1` — the `On` arm consumed it — so every
|
||||||
|
//! drop site skips `b` entirely, including the `Off` arm where `b` is
|
||||||
|
//! genuinely live. The aggregate is the worst-case-path `max` of the
|
||||||
|
//! arm consume counts, computed by `merge_states` in
|
||||||
|
//! `crates/ailang-check/src/uniqueness.rs` (~line 480); the per-arm /
|
||||||
|
//! per-path consume information is destroyed there before it ever
|
||||||
|
//! reaches MIR (`MArm` carries only `{ pat, body }`; `MirDef.consume`
|
||||||
|
//! is the per-fn aggregate). So codegen has no per-arm liveness to
|
||||||
|
//! gate a fall-through drop on.
|
||||||
|
//!
|
||||||
|
//! DOUBLE-FREE HAZARD (central): the aggregate gate is conservative
|
||||||
|
//! precisely to avoid double-dropping a param that WAS consumed. The
|
||||||
|
//! emitted IR for `run` drops only the scrutinee `f` at the fn-return
|
||||||
|
//! join `mjoin`, never `b`; the `On` arm already consumed `b` into
|
||||||
|
//! `sink`. Any fix MUST drop `b` ONLY on the arm where it stays live
|
||||||
|
//! (the `Off` arm block), never at the post-`match` join (a merge
|
||||||
|
//! point that cannot tell the consumed path from the live path) and
|
||||||
|
//! never on the `On` arm — or it introduces a double-free.
|
||||||
|
//!
|
||||||
|
//! As of HEAD this test fails: the binary reports
|
||||||
|
//! `ailang_rc_stats: allocs=2 frees=1 live=1` — the `Box` param `b`
|
||||||
|
//! leaks once because the `Off` arm never drops it.
|
||||||
|
//!
|
||||||
|
//! The leg-1 (`tail_recur_owned_param_no_leak_pin.rs`) and leg-2
|
||||||
|
//! (`tail_recur_let_wrapped_no_leak_pin.rs`) pins guard params whose
|
||||||
|
//! AGGREGATE `consume_count == 0` (consumed on no arm). This pin is
|
||||||
|
//! intentionally distinct: the param here is consumed on one arm and
|
||||||
|
//! live on another, so the aggregate is `>= 1` and the existing gates
|
||||||
|
//! cannot express it. Do not collapse the three.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn ail_bin() -> &'static str {
|
||||||
|
env!("CARGO_BIN_EXE_ail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IGNORED: leg 3 of #63 is a design fork, not a contained fix. Per-arm
|
||||||
|
// consume liveness is destroyed by the worst-case `max` merge in
|
||||||
|
// uniqueness.rs (`merge_states`) before MIR, so codegen has no per-arm
|
||||||
|
// information to gate a fall-through drop on. Closing this leg needs a
|
||||||
|
// memory-model change (retain per-arm consume, thread it to codegen,
|
||||||
|
// emit the drop only on the live arm — never at the join or the
|
||||||
|
// consuming arm) and a fresh brainstorm cycle. Un-ignore when that
|
||||||
|
// ships. The assertion below is a correct symptom pin (live == 0); only
|
||||||
|
// the fix mechanism is undecided.
|
||||||
|
#[ignore = "leg 3 of #63 — design fork (per-arm consume dataflow); un-ignore when fixed"]
|
||||||
|
#[test]
|
||||||
|
fn alloc_rc_owned_param_consumed_on_one_arm_live_on_sibling_does_not_leak() {
|
||||||
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||||
|
let src = workspace
|
||||||
|
.join("examples")
|
||||||
|
.join("match_arm_consume_split_no_leak_pin.ail");
|
||||||
|
let tmp = std::env::temp_dir().join(format!(
|
||||||
|
"ailang_match_arm_consume_split_no_leak_pin_{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
let out = tmp.join("bin");
|
||||||
|
let status = Command::new(ail_bin())
|
||||||
|
.args(["build", src.to_str().unwrap(), "--alloc=rc", "-o"])
|
||||||
|
.arg(&out)
|
||||||
|
.status()
|
||||||
|
.expect("ail build failed to run");
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"ail build --alloc=rc failed for match_arm_consume_split_no_leak_pin.ail"
|
||||||
|
);
|
||||||
|
let output = Command::new(&out)
|
||||||
|
.env("AILANG_RC_STATS", "1")
|
||||||
|
.output()
|
||||||
|
.expect("execute binary");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"binary exited non-zero: status {:?}",
|
||||||
|
output.status
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||||
|
let stats_line = stderr
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("ailang_rc_stats:"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
|
||||||
|
});
|
||||||
|
let mut allocs: Option<u64> = None;
|
||||||
|
let mut frees: Option<u64> = None;
|
||||||
|
let mut live: Option<i64> = None;
|
||||||
|
for tok in stats_line.split_whitespace() {
|
||||||
|
if let Some(v) = tok.strip_prefix("allocs=") {
|
||||||
|
allocs = v.parse().ok();
|
||||||
|
} else if let Some(v) = tok.strip_prefix("frees=") {
|
||||||
|
frees = v.parse().ok();
|
||||||
|
} else if let Some(v) = tok.strip_prefix("live=") {
|
||||||
|
live = v.parse().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let allocs = allocs.expect("missing allocs= field");
|
||||||
|
let frees = frees.expect("missing frees= field");
|
||||||
|
let live = live.expect("missing live= field");
|
||||||
|
assert_eq!(
|
||||||
|
live, 0,
|
||||||
|
"an (own) heap param consumed on one match arm but live on a \
|
||||||
|
sibling fall-through arm leaks {live} slab(s) \
|
||||||
|
(allocs={allocs} frees={frees}): the fn-return Own-param dec \
|
||||||
|
in crates/ailang-codegen/src/lib.rs (~line 1484) and the \
|
||||||
|
pre-tail-call dec in match_lower.rs (~line 823) both gate on \
|
||||||
|
the PER-FN AGGREGATE consume_count (>= 1 here, from the \
|
||||||
|
consuming arm) instead of per-arm liveness, so the param is \
|
||||||
|
never dropped on the arm where it stays live."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
allocs, frees,
|
||||||
|
"alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
(module match_arm_consume_split_no_leak_pin
|
||||||
|
(data Box (ctor B (con Int)))
|
||||||
|
(data Flag (ctor On) (ctor Off))
|
||||||
|
(fn sink
|
||||||
|
(type (fn-type (params (own (con Box))) (ret (own (con Unit))) (effects IO)))
|
||||||
|
(params b)
|
||||||
|
(body (match b (case (pat-ctor B n) (do io/print_str "x")))))
|
||||||
|
(fn run
|
||||||
|
(type (fn-type (params (own (con Box)) (own (con Flag))) (ret (own (con Unit))) (effects IO)))
|
||||||
|
(params b f)
|
||||||
|
(body
|
||||||
|
(match f
|
||||||
|
(case (pat-ctor Off) (do io/print_str ""))
|
||||||
|
(case (pat-ctor On) (app sink b)))))
|
||||||
|
(fn main
|
||||||
|
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
|
||||||
|
(params)
|
||||||
|
(body
|
||||||
|
(let b (term-ctor Box B 7)
|
||||||
|
(seq
|
||||||
|
(app run b (term-ctor Flag Off))
|
||||||
|
(do io/print_str "done\n"))))))
|
||||||
Reference in New Issue
Block a user