From 064ed7393c78a31df2646568da7b6da8e13c627a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 11 Jul 2026 18:18:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(298):=20SR-50=20=E2=80=94=20drop=20ves?= =?UTF-8?q?tigial=20cabi=5Frealloc=20once=20boundary=20internalised?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan for #298 (v0.41.0): when cabi_realloc_drop_provably_safe (core output, no adapters, all lifts scalar — already computed, INERT), wire the drop: remove the cabi_realloc* exports (allocator DCEs downstream) + defer the now-dead memory.grow under rebasing (IndexMaps::defer_grow_under_rebase), unblocking the lean --memory shared --address-rebase MCU fuse. Conservatism is load-bearing (over-drop = silent marshalling corruption). derives-from SYS-8, mitigates LS-D-3. rivet validate PASS. Refs #298, #299, SR-50. Co-Authored-By: Claude Opus 4.8 (1M context) --- safety/requirements/safety-requirements.yaml | 53 ++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/safety/requirements/safety-requirements.yaml b/safety/requirements/safety-requirements.yaml index 2d714ef..53cd362 100644 --- a/safety/requirements/safety-requirements.yaml +++ b/safety/requirements/safety-requirements.yaml @@ -1765,3 +1765,56 @@ artifacts: module lowers under `synth ... --native-pointer-abi --shadow-stack-size` with zero closed-world/shadow-stack warnings and fits the 8 KiB budget. + - id: SR-50 + type: sw-req + title: Drop the vestigial cabi_realloc once the component boundary is internalised + description: > + After `meld fuse` fully internalises a component boundary (no remaining + cross-component imports and no adapter sites), each input's canonical-ABI + `cabi_realloc` — the allocator wit-bindgen emits to marshal + pointer-carrying values ACROSS that boundary — is vestigial: fusion has + removed the marshalling it served. Because it is an *export*, downstream + DCE (loom/synth) cannot remove it, so the allocator and its + `memory.grow` survive, bloating the MCU output and BLOCKING + `--memory shared --address-rebase` (which hard-errors on `memory.grow`, + #299) purely because of dead allocator code (#298). + When — and ONLY when — `cabi_realloc` is PROVABLY vestigial + (`cabi_realloc_drop_provably_safe`: core-module output, no adapter sites, + every component lift's type provably scalar — no string/list params or + results — fail-safe `false` on any uncertainty), meld shall: (a) drop the + `cabi_realloc*` exports so the allocator becomes DCE-eligible, and (b) + defer the now-dead `memory.grow` under address rebasing (emit + `unreachable` via `IndexMaps::defer_grow_under_rebase` rather than + hard-failing), so the lean single-address-space fuse succeeds. Over-drop + is silent marshalling corruption, so the verdict's conservatism is + load-bearing and must not be weakened. + status: proposed + tags: [memory-strategy, shared-memory, mcu, dce, realloc, v0.41.0] + links: + - type: derives-from + target: SYS-8 + - type: mitigates + target: LS-D-3 + cited-source: + - uri: "https://github.com/pulseengine/meld/issues/298" + kind: github + last-checked: 2026-07-11 + release: v0.41.0 + fields: + implementation: + - meld-core/src/lib.rs + - meld-core/src/merger.rs + - meld-core/src/rewriter.rs + verification-method: test + verification-description: > + PLANNED. Positive: a fused fully-internalised core (scalar lifts, no + adapters) exports no `cabi_realloc*` and, under `--memory shared + --address-rebase`, fuses successfully (the vestigial `memory.grow` is + deferred, not a hard error) — verified by re-parsing the output exports + and asserting the fuse returns Ok. Negative controls (the verdict's + conservatism): a component with a NON-scalar lift (string/list param or + result), a P2 component-wrapped output, or a fuse with adapter sites all + KEEP `cabi_realloc` and do not defer grow. A live-allocator fuse still + rejects `memory.grow` under rebasing (no behaviour change off the + provably-safe path). + From f01f266bf74f7f87dd9b09ddbc4b168cc1fee54d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 14 Jul 2026 20:41:50 +0200 Subject: [PATCH 2/2] feat(298): drop vestigial cabi_realloc + defer dead memory.grow (Tier-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the (previously INERT) cabi_realloc_drop_provably_safe verdict: when a component boundary is fully internalised (core output, no adapters, all lifts scalar) AND address rebasing is on AND the allocator is provably dead, meld drops the cabi_realloc* exports (allocator DCEs downstream) and defers the dead memory.grow to `unreachable`, unblocking the lean --memory shared --address-rebase MCU fuse (SR-50). All other paths byte-identical. Mythos discover pass (this branch) found + fixed two findings: - FINDING 1 (HIGH): module-wide grow-defer was unsound — the boundary verdict proves no *marshalling* realloc is needed, NOT that the allocator is dead. A scalar-interface component that allocates internally (Vec/String -> dlmalloc -> sbrk -> memory.grow, reachable from a live export) would fuse-Ok then TRAP at runtime (a compile error silently downgraded to a trap). FIX: new memory_probe::module_has_reachable_memory_grow — a call-graph reachability probe (roots = exports except the dropped cabi_realloc*, start, ref.func/elem targets; edges = call/return_call; fail-safe true on parse error) ANDed into the gate via allocator_grow_is_dead(). Now only fires when NO memory.grow is reachable from a live non-cabi_realloc root; a live internal grow keeps cabi_realloc and preserves the clean hard-error. - FINDING 2 (LOW): tightened the export match to cabi_realloc or cabi_realloc$ + non-empty ASCII digits (was starts_with, which could drop a lookalike). Verdict conservatism preserved (fail-safe false/keep on any uncertainty; the new gate only ever PREVENTS a drop). Detection: exports named cabi_realloc / cabi_realloc$. Export removal only (loom DCEs the dead code). 467 lib + 7 drop_realloc integration tests green (incl. live_internal_grow_keeps_realloc_and_hard_errors, tight_match_preserves_realloc_lookalike_export, and 11 memory_probe unit tests); clippy + fmt clean. Refs #298, #299, SR-50, SYS-8, LS-D-3. Co-Authored-By: Claude Opus 4.8 (1M context) --- meld-core/src/lib.rs | 69 ++- meld-core/src/memory_probe.rs | 333 ++++++++++++ meld-core/src/merger.rs | 25 +- meld-core/tests/drop_realloc.rs | 932 ++++++++++++++++++++++++++++++++ 4 files changed, 1350 insertions(+), 9 deletions(-) create mode 100644 meld-core/tests/drop_realloc.rs diff --git a/meld-core/src/lib.rs b/meld-core/src/lib.rs index af0f071..1d6346e 100644 --- a/meld-core/src/lib.rs +++ b/meld-core/src/lib.rs @@ -590,6 +590,29 @@ impl Fuser { true } + /// #298 (FINDING 1) — is every input's allocator genuinely dead once the + /// vestigial `cabi_realloc*` exports are dropped? + /// + /// [`Self::cabi_realloc_drop_provably_safe`] proves the *interface boundary* + /// needs no realloc; it does NOT prove the allocator unreachable. A + /// scalar-interface component may still allocate internally (`Vec`/`String`/ + /// `Box` → `dlmalloc`/`sbrk` → `memory.grow`) from a live export, and the + /// grow-defer is module-wide — so without this gate a live grow would be + /// rewritten to `unreachable` and trap at runtime instead of hard-failing + /// at fuse time. This ANDs an extra, strictly-stricter condition into the + /// drop/defer wiring: it returns `true` only when NO `memory.grow` is + /// reachable from any live root (all exports except the dropped + /// `cabi_realloc*`, plus `start` and every indirectly-referenced function) + /// in ANY core module of ANY input. Fail-safe: an unparseable module counts + /// as having a live grow (via [`memory_probe::module_has_reachable_memory_grow`]). + fn allocator_grow_is_dead(&self) -> bool { + self.components.iter().all(|comp| { + comp.core_modules + .iter() + .all(|module| !memory_probe::module_has_reachable_memory_grow(&module.bytes)) + }) + } + /// The fusion pipeline proper. `self.config.memory_strategy` is a /// concrete strategy here — `Auto` has been resolved by the caller. fn fuse_with_stats_resolved(&self) -> Result<(Vec, FusionStats)> { @@ -634,22 +657,52 @@ impl Fuser { let graph = resolver.resolve_with_hints(&self.components, &self.wiring_hints)?; stats.imports_resolved = graph.resolved_imports.len(); - // #298 (INERT): compute the vestigial-cabi_realloc verdict for - // visibility. Not yet wired to drop anything — the actual drop is the - // corruption-critical step gated behind this and is implemented - // separately (tolerant rewriter + dead-function stubbing). - if self.cabi_realloc_drop_provably_safe(&graph) { + // #298: compute the vestigial-`cabi_realloc` verdict. When it holds + // AND we are on the address-rebasing (single-address-space MCU) path — + // the only path a surviving allocator `memory.grow` hard-blocks — the + // allocator is provably dead: drop its exports (below, so loom can DCE + // it) and defer its now-unreachable `memory.grow` to `unreachable` + // (via the merger flag) instead of hard-failing. On a false verdict, or + // any non-rebasing fuse, behavior is byte-identical to before. + // #298 FINDING 1: the interface-boundary verdict proves only that the + // boundary needs no realloc — NOT that the allocator is dead. A + // scalar-interface component can still allocate internally (`Vec` → + // `dlmalloc` → `memory.grow`) reachable from a live export. Deferring + // every `memory.grow` there would fuse-`Ok` then trap at runtime. + // `allocator_grow_is_dead` closes the gap: it fires the drop/defer only + // when NO `memory.grow` is reachable from any live root once the + // `cabi_realloc*` exports are dropped. `&&` short-circuits, so this + // call-graph scan runs only on the boundary-clean rebasing path. + let drop_vestigial_realloc = self.cabi_realloc_drop_provably_safe(&graph) + && self.config.address_rebasing + && self.allocator_grow_is_dead(); + if drop_vestigial_realloc { log::debug!( - "#298: cabi_realloc is provably vestigial for this fuse \ - (scalar lift surface, core output, no adapters) — drop not yet wired" + "#298: cabi_realloc is provably vestigial for this rebasing fuse \ + (scalar lift surface, core output, no adapters) — dropping its \ + exports and deferring the dead allocator's memory.grow" ); } // Step 2: Merge modules log::info!("Merging {} core modules", stats.modules_merged); let merger = Merger::new(self.config.memory_strategy, self.config.address_rebasing) - .with_opaque_resources(self.config.opaque_resources.clone()); + .with_opaque_resources(self.config.opaque_resources.clone()) + .with_defer_grow_under_rebase(drop_vestigial_realloc); let mut merged = merger.merge(&self.components, &graph)?; + + // #298: drop the now-vestigial `cabi_realloc*` exports. Removing the + // export (not the function body — out of scope per #298) makes the + // allocator + its `dlmalloc`/`sbrk`/`memory.grow` DCE-eligible for + // loom downstream. The merger emits these under exactly two names: + // the bare `cabi_realloc`, and the suffixed `cabi_realloc$` forms + // (per-memory-index in multi-memory mode); match both. Gated on the + // verdict AND the rebasing path, so every other fuse is unchanged. + if drop_vestigial_realloc { + merged + .exports + .retain(|e| !memory_probe::is_vestigial_realloc_export_name(&e.name)); + } stats.total_functions = merged.functions.len(); stats.total_exports = merged.exports.len(); diff --git a/meld-core/src/memory_probe.rs b/meld-core/src/memory_probe.rs index 8895d3a..17b1e9a 100644 --- a/meld-core/src/memory_probe.rs +++ b/meld-core/src/memory_probe.rs @@ -42,6 +42,196 @@ pub fn module_uses_memory_grow(module_bytes: &[u8]) -> bool { false } +/// #298 — is `name` an export the vestigial-`cabi_realloc` drop may remove? +/// +/// The merger emits the allocator export under exactly two shapes: the bare +/// `cabi_realloc`, and the per-memory-index suffixed `cabi_realloc$` +/// form. Match those two ONLY — a legitimately-authored export such as +/// `cabi_realloc$foo` (non-digit suffix) is NOT one of ours and must never be +/// dropped. So the suffix, when present, must be non-empty and all ASCII +/// digits. +pub fn is_vestigial_realloc_export_name(name: &str) -> bool { + if name == "cabi_realloc" { + return true; + } + match name.strip_prefix("cabi_realloc$") { + Some(suffix) => !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()), + None => false, + } +} + +/// #298 (FINDING 1) — is a `memory.grow` reachable from any LIVE root once the +/// vestigial `cabi_realloc*` exports are dropped? +/// +/// The vestigial-`cabi_realloc` verdict ([`crate::Fuser:: +/// cabi_realloc_drop_provably_safe`]) proves only that the *interface boundary* +/// needs no realloc (scalar lifts, core output, no adapters). It does NOT prove +/// the allocator is dead: a scalar-interface component can still allocate +/// internally (`Vec`/`String`/`Box` → `dlmalloc`/`sbrk` → `memory.grow`) +/// reachable from a live export. Deferring EVERY `memory.grow` to `unreachable` +/// there would fuse-`Ok` then trap at runtime — a compile error silently +/// downgraded to a trap. +/// +/// This probe closes that gap: it builds the module call graph and asks whether +/// any `memory.grow` survives once the dropped `cabi_realloc*` exports are no +/// longer roots. If it returns `true`, the allocator is LIVE and the drop/defer +/// must NOT fire (keep `cabi_realloc`, keep the hard error). It returns `true` +/// (fail-safe: assume live) on any parse failure. +/// +/// **Roots** (a function is reachable if reached from any of these): +/// * every exported function EXCEPT the dropped `cabi_realloc*` exports +/// (see [`is_vestigial_realloc_export_name`]); +/// * the `start` function; +/// * every function named by a `ref.func` (in a body, a global initializer, +/// or an element-segment expression) or listed in any element segment — +/// these may be reached via `call_indirect`/`call_ref`, whose targets are +/// not statically known, so they are treated as roots conservatively. +/// +/// **Edges**: a function → its direct callees via `call` / `return_call` +/// (`call_indirect`/`call_ref` are covered by the ref.func/elem roots above). +pub fn module_has_reachable_memory_grow(module_bytes: &[u8]) -> bool { + let mut num_imported_funcs: u32 = 0; + let mut func_exports: Vec<(String, u32)> = Vec::new(); + let mut start: Option = None; + // Functions reachable indirectly (ref.func / element segments) — roots. + let mut ref_roots: Vec = Vec::new(); + // Per defined function: (direct callees, contains memory.grow). + // Indexed by absolute function index (>= num_imported_funcs). + let mut bodies: std::collections::HashMap, bool)> = + std::collections::HashMap::new(); + let mut next_defined_idx: u32 = 0; + + for payload in Parser::new(0).parse_all(module_bytes) { + let Ok(payload) = payload else { + return true; // fail-safe: unparseable ⇒ assume a live grow + }; + match payload { + Payload::ImportSection(reader) => { + for imp in reader.into_imports() { + let Ok(imp) = imp else { return true }; + if matches!( + imp.ty, + wasmparser::TypeRef::Func(_) | wasmparser::TypeRef::FuncExact(_) + ) { + num_imported_funcs += 1; + } + } + } + Payload::ExportSection(reader) => { + for exp in reader { + let Ok(exp) = exp else { return true }; + if matches!( + exp.kind, + wasmparser::ExternalKind::Func | wasmparser::ExternalKind::FuncExact + ) { + func_exports.push((exp.name.to_string(), exp.index)); + } + } + } + Payload::StartSection { func, .. } => start = Some(func), + Payload::GlobalSection(reader) => { + for g in reader { + let Ok(g) = g else { return true }; + let Ok(ops) = g + .init_expr + .get_operators_reader() + .into_iter() + .collect::, _>>() + else { + return true; + }; + for op in ops { + if let Operator::RefFunc { function_index } = op { + ref_roots.push(function_index); + } + } + } + } + Payload::ElementSection(reader) => { + for elem in reader { + let Ok(elem) = elem else { return true }; + match elem.items { + wasmparser::ElementItems::Functions(fr) => { + for f in fr { + let Ok(f) = f else { return true }; + ref_roots.push(f); + } + } + wasmparser::ElementItems::Expressions(_, er) => { + for expr in er { + let Ok(expr) = expr else { return true }; + let Ok(ops) = expr + .get_operators_reader() + .into_iter() + .collect::, _>>() + else { + return true; + }; + for op in ops { + if let Operator::RefFunc { function_index } = op { + ref_roots.push(function_index); + } + } + } + } + } + } + } + Payload::CodeSectionEntry(body) => { + let func_idx = num_imported_funcs + next_defined_idx; + next_defined_idx += 1; + let Ok(ops) = body.get_operators_reader() else { + return true; + }; + let mut callees: Vec = Vec::new(); + let mut has_grow = false; + for op in ops { + match op { + Ok(Operator::MemoryGrow { .. }) => has_grow = true, + Ok(Operator::Call { function_index }) + | Ok(Operator::ReturnCall { function_index }) => { + callees.push(function_index) + } + Ok(Operator::RefFunc { function_index }) => ref_roots.push(function_index), + Ok(_) => {} + Err(_) => return true, + } + } + bodies.insert(func_idx, (callees, has_grow)); + } + _ => {} + } + } + + // Assemble roots: live func exports (drop the vestigial realloc ones) + + // start + all indirectly-referenced functions. + let mut worklist: Vec = Vec::new(); + for (name, idx) in &func_exports { + if !is_vestigial_realloc_export_name(name) { + worklist.push(*idx); + } + } + if let Some(s) = start { + worklist.push(s); + } + worklist.extend(ref_roots); + + // BFS over call edges; any reachable body with a grow ⇒ live grow. + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + while let Some(idx) = worklist.pop() { + if !visited.insert(idx) { + continue; + } + if let Some((callees, has_grow)) = bodies.get(&idx) { + if *has_grow { + return true; + } + worklist.extend(callees.iter().copied()); + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -118,4 +308,147 @@ mod tests { let bytes = module(r#"(module (func (export "f") (result i32) i32.const 7))"#); assert!(!module_uses_memory_grow(&bytes)); } + + // --- #298 FINDING 2: droppable-name predicate ----------------------- + + #[test] + fn vestigial_realloc_name_match_is_tight() { + assert!(is_vestigial_realloc_export_name("cabi_realloc")); + assert!(is_vestigial_realloc_export_name("cabi_realloc$0")); + assert!(is_vestigial_realloc_export_name("cabi_realloc$42")); + // Non-digit / mixed / empty suffixes are NOT ours — keep them. + assert!(!is_vestigial_realloc_export_name("cabi_realloc$")); + assert!(!is_vestigial_realloc_export_name("cabi_realloc$foo")); + assert!(!is_vestigial_realloc_export_name("cabi_realloc$1a")); + assert!(!is_vestigial_realloc_export_name("cabi_realloc$notdigits")); + assert!(!is_vestigial_realloc_export_name("cabi_realloc_extra")); + assert!(!is_vestigial_realloc_export_name("my_cabi_realloc")); + } + + // --- #298 FINDING 1: memory.grow reachability ----------------------- + + /// A grow reachable ONLY via the (to-be-dropped) `cabi_realloc` export is + /// dead: no live root reaches it. + #[test] + fn grow_only_under_vestigial_realloc_is_dead() { + let bytes = module( + r#"(module + (memory 1) + (func $realloc (export "cabi_realloc") + (param i32 i32 i32 i32) (result i32) + i32.const 1 memory.grow drop i32.const 0) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(!module_has_reachable_memory_grow(&bytes)); + } + + /// A grow in a LIVE export's own body is reachable ⇒ allocator is live. + #[test] + fn grow_in_live_export_is_reachable() { + let bytes = module( + r#"(module + (memory 1) + (func $realloc (export "cabi_realloc") + (param i32 i32 i32 i32) (result i32) i32.const 0) + (func (export "compute") (result i32) + i32.const 1 memory.grow drop i32.const 7))"#, + ); + assert!(module_has_reachable_memory_grow(&bytes)); + } + + /// A grow buried in a helper CALLED (transitively) by a live export is + /// reachable — the naive "grow not in the export body" check would miss it. + #[test] + fn grow_transitively_reachable_from_live_export() { + let bytes = module( + r#"(module + (memory 1) + (func $sbrk (result i32) i32.const 1 memory.grow) + (func $dlmalloc (result i32) call $sbrk) + (func (export "compute") (result i32) call $dlmalloc))"#, + ); + assert!(module_has_reachable_memory_grow(&bytes)); + } + + /// A grow inside a helper called ONLY by `cabi_realloc` (the dlmalloc/sbrk + /// shape) is dead once that export is dropped. + #[test] + fn grow_transitively_under_only_realloc_is_dead() { + let bytes = module( + r#"(module + (memory 1) + (func $sbrk (result i32) i32.const 1 memory.grow) + (func $dlmalloc (result i32) call $sbrk) + (func $realloc (export "cabi_realloc") + (param i32 i32 i32 i32) (result i32) call $dlmalloc) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(!module_has_reachable_memory_grow(&bytes)); + } + + /// A grow in a function reachable only via `call_indirect` (an element + /// segment) is treated as live — the elem entry is a conservative root. + #[test] + fn grow_via_elem_segment_is_reachable() { + let bytes = module( + r#"(module + (memory 1) + (table 1 funcref) + (elem (i32.const 0) $grower) + (func $grower (result i32) i32.const 1 memory.grow) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(module_has_reachable_memory_grow(&bytes)); + } + + /// The `start` function is a root: a grow reachable from it is live. + #[test] + fn grow_reachable_from_start_is_reachable() { + let bytes = module( + r#"(module + (memory 1) + (start $init) + (func $init i32.const 1 memory.grow drop) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(module_has_reachable_memory_grow(&bytes)); + } + + /// A grow reachable only via the `cabi_realloc$` suffixed export is + /// still dead (that name is dropped too). + #[test] + fn grow_under_suffixed_realloc_is_dead() { + let bytes = module( + r#"(module + (memory 1) + (func $realloc (export "cabi_realloc$0") + (param i32 i32 i32 i32) (result i32) + i32.const 1 memory.grow drop i32.const 0) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(!module_has_reachable_memory_grow(&bytes)); + } + + /// But a grow under a LOOK-ALIKE `cabi_realloc$notdigits` export (which is + /// NOT dropped, so stays a live root) is reachable. + #[test] + fn grow_under_lookalike_realloc_is_reachable() { + let bytes = module( + r#"(module + (memory 1) + (func $realloc (export "cabi_realloc$notdigits") + (param i32 i32 i32 i32) (result i32) + i32.const 1 memory.grow drop i32.const 0) + (func (export "compute") (result i32) i32.const 7))"#, + ); + assert!(module_has_reachable_memory_grow(&bytes)); + } + + /// Fail-safe: unparseable bytes count as having a live grow. + #[test] + fn malformed_input_counts_as_reachable_grow() { + assert!(module_has_reachable_memory_grow(&[ + 0x00, 0x61, 0x73, 0x6d, 0xff + ])); + } } diff --git a/meld-core/src/merger.rs b/meld-core/src/merger.rs index 6bb37e6..860afa5 100644 --- a/meld-core/src/merger.rs +++ b/meld-core/src/merger.rs @@ -524,6 +524,13 @@ fn find_exact_resource_import_idx( pub struct Merger { memory_strategy: MemoryStrategy, address_rebasing: bool, + /// #298: the upstream `cabi_realloc`-is-vestigial verdict + /// (`Fuser::cabi_realloc_drop_provably_safe`). When set, module bodies are + /// rewritten with `IndexMaps::defer_grow_under_rebase`, so a `memory.grow` + /// in the (now provably dead) vestigial allocator emits `unreachable` + /// under address rebasing instead of hard-failing. Defaults `false` + /// (current behavior preserved everywhere the gated wiring is absent). + defer_grow_under_rebase: bool, /// (interface, resource_name) tuples marked opaque-rep — skip handle /// table allocation for these resources because their reps are already /// valid integer handles (no Box dereferencing in user code). @@ -556,6 +563,7 @@ impl Merger { Self { memory_strategy, address_rebasing, + defer_grow_under_rebase: false, opaque_resources: Vec::new(), } } @@ -566,6 +574,16 @@ impl Merger { self } + /// #298: thread the upstream vestigial-`cabi_realloc` verdict in. When + /// `true`, the vestigial allocator's `memory.grow` is deferred to + /// `unreachable` under address rebasing (see + /// [`IndexMaps::defer_grow_under_rebase`]) rather than hard-failing — + /// sound only because the caller has proved that allocator dead. + pub fn with_defer_grow_under_rebase(mut self, defer: bool) -> Self { + self.defer_grow_under_rebase = defer; + self + } + fn compute_shared_memory_plan( &self, components: &[ParsedComponent], @@ -1956,7 +1974,7 @@ impl Merger { .segment_bases .insert((comp_idx, mod_idx), (data_segment_base, elem_segment_base)); - let index_maps = build_index_maps_for_module( + let mut index_maps = build_index_maps_for_module( comp_idx, mod_idx, module, @@ -1970,6 +1988,11 @@ impl Merger { elem_segment_base, code_addr_relocs, ); + // #298: only under the upstream vestigial-allocator verdict does a + // `memory.grow` reached during rebasing become `unreachable` (the + // allocator is provably dead) instead of a hard error. Inert when + // `address_rebasing` is off (the rewriter checks it only under rebase). + index_maps.defer_grow_under_rebase = self.defer_grow_under_rebase; // Second pass: extract and rewrite function bodies for (old_idx, old_func_idx, new_type_idx, type_idx) in func_type_indices { diff --git a/meld-core/tests/drop_realloc.rs b/meld-core/tests/drop_realloc.rs new file mode 100644 index 0000000..4e2672e --- /dev/null +++ b/meld-core/tests/drop_realloc.rs @@ -0,0 +1,932 @@ +//! #298 (SR-50) — dropping the vestigial `cabi_realloc` allocator. +//! +//! After `meld fuse` fully internalises a component boundary (no cross-component +//! imports, no adapter sites) and every component lift is provably scalar, each +//! input's canonical-ABI `cabi_realloc` allocator is *vestigial*: it existed +//! only to marshal pointer-carrying values across the boundary fusion removed. +//! Because it is an **export**, downstream DCE cannot remove it, so the +//! allocator + its `memory.grow` survive — and that surviving `memory.grow` +//! hard-blocks the single-address-space MCU path (`--memory shared +//! --address-rebase`, which rejects `memory.grow`). +//! +//! When `Fuser::cabi_realloc_drop_provably_safe` holds AND we are on the +//! rebasing path, meld now: +//! 1. drops the `cabi_realloc*` exports (so loom can DCE the allocator), and +//! 2. defers the now-dead allocator's `memory.grow` to `unreachable` instead +//! of hard-failing. +//! +//! The verdict is fail-safe: on a non-scalar lift, a non-core output, or any +//! adapter site it returns `false` and behaviour is byte-identical to before +//! (the export survives and the `memory.grow` still hard-errors under rebasing). +//! Over-dropping would silently corrupt marshalling, so every uncertainty keeps +//! the allocator. + +use meld_core::{Fuser, FuserConfig, MemoryStrategy, OutputFormat}; +use wasm_encoder::{ + Alias, CanonicalFunctionSection, CanonicalOption, CodeSection, Component, + ComponentAliasSection, ComponentExportKind, ComponentExportSection, ComponentImportSection, + ComponentTypeRef, ComponentTypeSection, ConstExpr, ExportKind, ExportSection, Function, + FunctionSection, GlobalSection, GlobalType, ImportSection, InstanceSection, Instruction, + MemorySection, MemoryType, Module, ModuleArg, ModuleSection, TypeSection, ValType, +}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// The kind of high-level lift the exported component function carries. +#[derive(Clone, Copy)] +enum Lift { + /// `func() -> u32` — no pointers, so the allocator is provably vestigial. + Scalar, + /// `func(s: string) -> u32` — a pointer-carrying param, so the allocator is + /// live (the canonical ABI needs it to lower the string) and the verdict + /// must keep it. + String, +} + +/// A `cabi_realloc(orig, size, align, new_size) -> ptr` whose body contains a +/// `memory.grow` — the shape of a real wit-bindgen `dlmalloc → $sbrk → +/// memory.grow` allocator, and exactly what hard-blocks the rebasing path. +fn emit_growing_realloc(func: &mut Function) { + func.instruction(&Instruction::I32Const(1)); + func.instruction(&Instruction::MemoryGrow(0)); + func.instruction(&Instruction::Drop); + func.instruction(&Instruction::I32Const(0)); + func.instruction(&Instruction::End); +} + +/// A single, self-contained component that exports `test:api/api` (a lift of +/// `compute`) and carries a growing `cabi_realloc`. Fusing it internalises +/// nothing and creates no adapter sites, so the allocator is vestigial exactly +/// when the lift is [`Lift::Scalar`]. +fn build_component(lift: Lift) -> Vec { + let core_module = { + let mut types = TypeSection::new(); + // type 0: cabi_realloc (i32,i32,i32,i32) -> i32 + types.ty().function( + [ValType::I32, ValType::I32, ValType::I32, ValType::I32], + [ValType::I32], + ); + // type 1: compute — core signature of the lift. + match lift { + // scalar `func() -> u32` flattens to core `() -> i32` + Lift::Scalar => { + types.ty().function([], [ValType::I32]); + } + // `func(s: string) -> u32` flattens to core `(ptr, len) -> i32` + Lift::String => { + types + .ty() + .function([ValType::I32, ValType::I32], [ValType::I32]); + } + } + + let mut functions = FunctionSection::new(); + functions.function(0); // func 0: cabi_realloc + functions.function(1); // func 1: compute + + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + + let mut exports = ExportSection::new(); + exports.export("cabi_realloc", ExportKind::Func, 0); + exports.export("compute", ExportKind::Func, 1); + exports.export("memory", ExportKind::Memory, 0); + + let mut code = CodeSection::new(); + { + let mut f = Function::new([]); + emit_growing_realloc(&mut f); + code.function(&f); + } + { + // compute: ignore any args, return a constant. No direct memory + // access, so the #326 path-F reloc gate never fires — the only + // rebasing obstacle is the allocator's `memory.grow`. + let mut f = Function::new([]); + f.instruction(&Instruction::I32Const(7)); + f.instruction(&Instruction::End); + code.function(&f); + } + + let mut module = Module::new(); + module + .section(&types) + .section(&functions) + .section(&memory) + .section(&exports) + .section(&code); + module + }; + + let mut component = Component::new(); + component.section(&ModuleSection(&core_module)); + + { + let mut types = ComponentTypeSection::new(); + match lift { + Lift::Scalar => { + let no_params: [(&str, wasm_encoder::ComponentValType); 0] = []; + types.function().params(no_params).result(Some( + wasm_encoder::ComponentValType::Primitive(wasm_encoder::PrimitiveValType::U32), + )); + } + Lift::String => { + types + .function() + .params([( + "s", + wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::String, + ), + )]) + .result(Some(wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::U32, + ))); + } + } + component.section(&types); + } + + { + let mut inst = InstanceSection::new(); + let no_args: Vec<(&str, ModuleArg)> = vec![]; + inst.instantiate(0, no_args); + component.section(&inst); + } + + for (kind, name) in [ + (ExportKind::Func, "cabi_realloc"), + (ExportKind::Func, "compute"), + (ExportKind::Memory, "memory"), + ] { + let mut aliases = ComponentAliasSection::new(); + aliases.alias(Alias::CoreInstanceExport { + instance: 0, + kind, + name, + }); + component.section(&aliases); + } + + { + let mut canon = CanonicalFunctionSection::new(); + // Aliased core func 1 == compute; lift it with the component type 0. + canon.lift( + 1, + 0, + [CanonicalOption::Memory(0), CanonicalOption::Realloc(0)], + ); + component.section(&canon); + } + { + let mut exp = ComponentExportSection::new(); + exp.export("test:api/api", ComponentExportKind::Func, 0, None); + component.section(&exp); + } + + component.finish() +} + +/// A single scalar-lift component whose LIVE `compute` body itself contains a +/// `memory.grow` — modelling a scalar-interface component that allocates +/// internally (`Vec`/`String`/`Box` → `dlmalloc` → `memory.grow`) reachable +/// from a live export. The interface boundary is still scalar (so the #298 +/// interface verdict holds), but the allocator is NOT dead: the grow is +/// reachable from `compute`, a live root. Under shared+rebase this must KEEP +/// `cabi_realloc` and hard-error on the grow — NOT fuse-`Ok`-then-trap. +fn build_scalar_component_with_live_grow() -> Vec { + let core_module = { + let mut types = TypeSection::new(); + // type 0: cabi_realloc (i32,i32,i32,i32) -> i32 + types.ty().function( + [ValType::I32, ValType::I32, ValType::I32, ValType::I32], + [ValType::I32], + ); + // type 1: compute — scalar `func() -> u32` flattens to core `() -> i32` + types.ty().function([], [ValType::I32]); + + let mut functions = FunctionSection::new(); + functions.function(0); // func 0: cabi_realloc + functions.function(1); // func 1: compute + + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + + let mut exports = ExportSection::new(); + exports.export("cabi_realloc", ExportKind::Func, 0); + exports.export("compute", ExportKind::Func, 1); + exports.export("memory", ExportKind::Memory, 0); + + let mut code = CodeSection::new(); + { + let mut f = Function::new([]); + emit_growing_realloc(&mut f); + code.function(&f); + } + { + // compute: allocates internally — `i32.const 1; memory.grow 0; + // drop; i32.const 7`. This grow is reachable from the LIVE + // `compute` export, so the allocator is not dead. + let mut f = Function::new([]); + f.instruction(&Instruction::I32Const(1)); + f.instruction(&Instruction::MemoryGrow(0)); + f.instruction(&Instruction::Drop); + f.instruction(&Instruction::I32Const(7)); + f.instruction(&Instruction::End); + code.function(&f); + } + + let mut module = Module::new(); + module + .section(&types) + .section(&functions) + .section(&memory) + .section(&exports) + .section(&code); + module + }; + + let mut component = Component::new(); + component.section(&ModuleSection(&core_module)); + + { + let mut types = ComponentTypeSection::new(); + let no_params: [(&str, wasm_encoder::ComponentValType); 0] = []; + types + .function() + .params(no_params) + .result(Some(wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::U32, + ))); + component.section(&types); + } + { + let mut inst = InstanceSection::new(); + let no_args: Vec<(&str, ModuleArg)> = vec![]; + inst.instantiate(0, no_args); + component.section(&inst); + } + for (kind, name) in [ + (ExportKind::Func, "cabi_realloc"), + (ExportKind::Func, "compute"), + (ExportKind::Memory, "memory"), + ] { + let mut aliases = ComponentAliasSection::new(); + aliases.alias(Alias::CoreInstanceExport { + instance: 0, + kind, + name, + }); + component.section(&aliases); + } + { + let mut canon = CanonicalFunctionSection::new(); + canon.lift( + 1, + 0, + [CanonicalOption::Memory(0), CanonicalOption::Realloc(0)], + ); + component.section(&canon); + } + { + let mut exp = ComponentExportSection::new(); + exp.export("test:api/api", ComponentExportKind::Func, 0, None); + component.section(&exp); + } + + component.finish() +} + +/// A scalar-lift component identical in shape to [`build_component`]`(Scalar)` +/// but carrying an EXTRA core export `extra_name` (a legitimately-authored +/// export that merely looks realloc-adjacent). Used to prove the #298 drop's +/// name match is tight: only the merger's own `cabi_realloc` / `cabi_realloc$ +/// ` shapes are dropped; e.g. `cabi_realloc$notdigits` is preserved. +fn build_scalar_component_with_extra_export(extra_name: &str) -> Vec { + let core_module = { + let mut types = TypeSection::new(); + types.ty().function( + [ValType::I32, ValType::I32, ValType::I32, ValType::I32], + [ValType::I32], + ); + types.ty().function([], [ValType::I32]); + + let mut functions = FunctionSection::new(); + functions.function(0); // func 0: cabi_realloc + functions.function(1); // func 1: compute (also the extra export target) + + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + + let mut exports = ExportSection::new(); + exports.export("cabi_realloc", ExportKind::Func, 0); + exports.export("compute", ExportKind::Func, 1); + // The extra, legitimately-named export → func 1 (a grow-free body). + exports.export(extra_name, ExportKind::Func, 1); + exports.export("memory", ExportKind::Memory, 0); + + let mut code = CodeSection::new(); + { + let mut f = Function::new([]); + emit_growing_realloc(&mut f); + code.function(&f); + } + { + let mut f = Function::new([]); + f.instruction(&Instruction::I32Const(7)); + f.instruction(&Instruction::End); + code.function(&f); + } + + let mut module = Module::new(); + module + .section(&types) + .section(&functions) + .section(&memory) + .section(&exports) + .section(&code); + module + }; + + let mut component = Component::new(); + component.section(&ModuleSection(&core_module)); + + { + let mut types = ComponentTypeSection::new(); + let no_params: [(&str, wasm_encoder::ComponentValType); 0] = []; + types + .function() + .params(no_params) + .result(Some(wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::U32, + ))); + component.section(&types); + } + { + let mut inst = InstanceSection::new(); + let no_args: Vec<(&str, ModuleArg)> = vec![]; + inst.instantiate(0, no_args); + component.section(&inst); + } + for (kind, name) in [ + (ExportKind::Func, "cabi_realloc"), + (ExportKind::Func, "compute"), + (ExportKind::Memory, "memory"), + ] { + let mut aliases = ComponentAliasSection::new(); + aliases.alias(Alias::CoreInstanceExport { + instance: 0, + kind, + name, + }); + component.section(&aliases); + } + { + let mut canon = CanonicalFunctionSection::new(); + canon.lift( + 1, + 0, + [CanonicalOption::Memory(0), CanonicalOption::Realloc(0)], + ); + component.section(&canon); + } + { + let mut exp = ComponentExportSection::new(); + exp.export("test:api/api", ComponentExportKind::Func, 0, None); + component.section(&exp); + } + + component.finish() +} + +// --- Cross-component (adapter-site) fixtures -------------------------------- +// +// A string-passing caller→callee pair (same shape as +// `tests/realloc_safety.rs`): fusing them internalises a string boundary and so +// forces meld to emit a real adapter — the resolver populates +// `graph.adapter_sites`, which flips the #298 verdict to `false`. Each carries +// a **growing** `cabi_realloc` so that, under rebasing, the kept (non-vestigial) +// allocator's `memory.grow` hard-errors — proving no grow-defer happened. + +/// Callee P2 component: exports `process-string(s: string) -> u32`, with a +/// growing `cabi_realloc`. +fn build_callee_string_component() -> Vec { + let core_module = { + let mut types = TypeSection::new(); + types.ty().function( + [ValType::I32, ValType::I32, ValType::I32, ValType::I32], + [ValType::I32], + ); + types + .ty() + .function([ValType::I32, ValType::I32], [ValType::I32]); + + let mut functions = FunctionSection::new(); + functions.function(0); + functions.function(1); + + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + + let mut exports = ExportSection::new(); + exports.export("cabi_realloc", ExportKind::Func, 0); + exports.export("test:api/api#process-string", ExportKind::Func, 1); + exports.export("memory", ExportKind::Memory, 0); + + let mut code = CodeSection::new(); + { + let mut f = Function::new([]); + emit_growing_realloc(&mut f); + code.function(&f); + } + { + // process-string(ptr, len) -> 0. Body does no direct memory access, + // so the only rebasing obstacle is the growing allocator above. + let mut f = Function::new([]); + f.instruction(&Instruction::I32Const(0)); + f.instruction(&Instruction::End); + code.function(&f); + } + + let mut module = Module::new(); + module + .section(&types) + .section(&functions) + .section(&memory) + .section(&exports) + .section(&code); + module + }; + + let mut component = Component::new(); + component.section(&ModuleSection(&core_module)); + + { + let mut types = ComponentTypeSection::new(); + types + .function() + .params([( + "s", + wasm_encoder::ComponentValType::Primitive(wasm_encoder::PrimitiveValType::String), + )]) + .result(Some(wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::U32, + ))); + component.section(&types); + } + + { + let mut inst = InstanceSection::new(); + let no_args: Vec<(&str, ModuleArg)> = vec![]; + inst.instantiate(0, no_args); + component.section(&inst); + } + + for (kind, name) in [ + (ExportKind::Func, "cabi_realloc"), + (ExportKind::Func, "test:api/api#process-string"), + (ExportKind::Memory, "memory"), + ] { + let mut aliases = ComponentAliasSection::new(); + aliases.alias(Alias::CoreInstanceExport { + instance: 0, + kind, + name, + }); + component.section(&aliases); + } + + { + let mut canon = CanonicalFunctionSection::new(); + canon.lift( + 1, + 0, + [ + CanonicalOption::UTF8, + CanonicalOption::Memory(0), + CanonicalOption::Realloc(0), + ], + ); + component.section(&canon); + } + { + let mut exp = ComponentExportSection::new(); + exp.export("test:api/api", ComponentExportKind::Func, 0, None); + component.section(&exp); + } + + component.finish() +} + +/// Caller P2 component: imports `process-string`, calls it with "Hello", with a +/// growing `cabi_realloc`. +fn build_caller_string_component() -> Vec { + let core_module = { + let mut types = TypeSection::new(); + types + .ty() + .function([ValType::I32, ValType::I32], [ValType::I32]); + types.ty().function([], [ValType::I32]); + types.ty().function( + [ValType::I32, ValType::I32, ValType::I32, ValType::I32], + [ValType::I32], + ); + + let mut imports = ImportSection::new(); + imports.import( + "test:api/api", + "process-string", + wasm_encoder::EntityType::Function(0), + ); + + let mut functions = FunctionSection::new(); + functions.function(1); + functions.function(2); + + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + + let mut globals = GlobalSection::new(); + globals.global( + GlobalType { + val_type: ValType::I32, + mutable: true, + shared: false, + }, + &ConstExpr::i32_const(1024), + ); + + let mut exports = ExportSection::new(); + exports.export("run", ExportKind::Func, 1); + exports.export("cabi_realloc", ExportKind::Func, 2); + exports.export("memory", ExportKind::Memory, 0); + + let mut code = CodeSection::new(); + { + let mut f = Function::new([]); + f.instruction(&Instruction::I32Const(0)); + f.instruction(&Instruction::I32Const(5)); + f.instruction(&Instruction::Call(0)); + f.instruction(&Instruction::End); + code.function(&f); + } + { + let mut f = Function::new([]); + emit_growing_realloc(&mut f); + code.function(&f); + } + + let mut module = Module::new(); + module + .section(&types) + .section(&imports) + .section(&functions) + .section(&memory) + .section(&globals) + .section(&exports) + .section(&code); + module + }; + + let mut component = Component::new(); + { + let mut types = ComponentTypeSection::new(); + types + .function() + .params([( + "s", + wasm_encoder::ComponentValType::Primitive(wasm_encoder::PrimitiveValType::String), + )]) + .result(Some(wasm_encoder::ComponentValType::Primitive( + wasm_encoder::PrimitiveValType::U32, + ))); + component.section(&types); + } + { + let mut imports = ComponentImportSection::new(); + imports.import("test:api/api", ComponentTypeRef::Func(0)); + component.section(&imports); + } + component.section(&ModuleSection(&core_module)); + component.finish() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Names of every exported **function** in a core-wasm module. +fn export_func_names(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + if let Ok(wasmparser::Payload::ExportSection(reader)) = payload { + for exp in reader.into_iter().flatten() { + if matches!( + exp.kind, + wasmparser::ExternalKind::Func | wasmparser::ExternalKind::FuncExact + ) { + out.push(exp.name.to_string()); + } + } + } + } + out +} + +fn has_realloc_export(bytes: &[u8]) -> bool { + export_func_names(bytes) + .iter() + .any(|n| n == "cabi_realloc" || n.starts_with("cabi_realloc$")) +} + +fn shared_rebase_config() -> FuserConfig { + FuserConfig { + memory_strategy: MemoryStrategy::SharedMemory, + address_rebasing: true, + attestation: false, + reproducible: false, + component_provenance: false, + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// 1. Positive: scalar-only boundary → drop + defer +// --------------------------------------------------------------------------- + +/// A single scalar-lift component with a growing `cabi_realloc`, fused under +/// `SharedMemory` + `address_rebasing`: +/// * the fuse returns `Ok` — the vestigial `memory.grow` was deferred to +/// `unreachable`, not a hard error; and +/// * the fused core exports NO `cabi_realloc*` — the allocator export was +/// dropped so loom can DCE it. +#[test] +fn scalar_boundary_drops_realloc_and_defers_grow() { + let component = build_component(Lift::Scalar); + + let mut fuser = Fuser::new(shared_rebase_config()); + fuser + .add_component_named(&component, Some("scalar-app")) + .expect("component parses"); + + let fused = fuser + .fuse() + .expect("scalar boundary must fuse under shared+rebase (grow deferred)"); + + // Output must validate — a malformed module would mask the real behaviour. + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&fused) + .expect("fused output must validate"); + + assert!( + !has_realloc_export(&fused), + "vestigial cabi_realloc export must be dropped; exports = {:?}", + export_func_names(&fused) + ); +} + +// --------------------------------------------------------------------------- +// 2. Negative controls — the verdict's conservatism must hold +// --------------------------------------------------------------------------- + +/// (a) A non-scalar (string) lift keeps the allocator live, so the verdict is +/// `false`: the `memory.grow` still hard-errors under rebasing (no defer). +#[test] +fn string_lift_keeps_realloc_and_hard_errors_on_grow() { + let component = build_component(Lift::String); + + let mut fuser = Fuser::new(shared_rebase_config()); + fuser + .add_component_named(&component, Some("string-app")) + .expect("component parses"); + + let err = fuser + .fuse() + .expect_err("a string lift must NOT allow the vestigial drop / grow-defer"); + assert!( + err.to_string().contains("memory.grow"), + "expected the memory.grow rebase rejection (verdict must stay false for a \ + non-scalar lift), got: {err}" + ); +} + +/// (b) A P2-component output (`OutputFormat::Component`) aliases `cabi_realloc` +/// for `canon lower`, so the verdict is `false`: the grow still hard-errors. +#[test] +fn component_output_keeps_realloc_and_hard_errors_on_grow() { + let component = build_component(Lift::Scalar); + + let mut config = shared_rebase_config(); + config.output_format = OutputFormat::Component; + + let mut fuser = Fuser::new(config); + fuser + .add_component_named(&component, Some("scalar-app")) + .expect("component parses"); + + let err = fuser + .fuse() + .expect_err("a P2 wrap output must NOT allow the vestigial drop / grow-defer"); + assert!( + err.to_string().contains("memory.grow"), + "expected the memory.grow rebase rejection (verdict must stay false for a \ + non-core output), got: {err}" + ); +} + +/// (c) A fuse WITH a cross-component adapter site keeps the allocator (an +/// adapter may marshal pointers), so the verdict is `false`: the grow still +/// hard-errors under rebasing. +#[test] +fn adapter_site_keeps_realloc_and_hard_errors_on_grow() { + // First, confirm the fixture genuinely produces an adapter — otherwise the + // shared+rebase assertion below would pass for the wrong reason. A + // multi-memory fuse succeeds today; assert an adapter was emitted AND the + // allocator export survives (verdict false ⟹ no drop). + { + let mut fuser = Fuser::new(FuserConfig { + memory_strategy: MemoryStrategy::MultiMemory, + address_rebasing: false, + attestation: false, + reproducible: false, + component_provenance: false, + ..Default::default() + }); + fuser + .add_component_named(&build_callee_string_component(), Some("callee")) + .expect("callee parses"); + fuser + .add_component_named(&build_caller_string_component(), Some("caller")) + .expect("caller parses"); + let (fused, stats) = fuser.fuse_with_stats().expect("multi-memory fuse succeeds"); + assert!( + stats.adapter_functions >= 1, + "fixture must emit at least one adapter (got {}) so the adapter-site \ + branch of the verdict is actually exercised", + stats.adapter_functions + ); + assert!( + has_realloc_export(&fused), + "with an adapter site present the allocator export must survive; \ + exports = {:?}", + export_func_names(&fused) + ); + } + + // Now the real control: under shared+rebase the kept allocator's + // `memory.grow` must still hard-error (no defer engaged). + let mut fuser = Fuser::new(shared_rebase_config()); + fuser + .add_component_named(&build_callee_string_component(), Some("callee")) + .expect("callee parses"); + fuser + .add_component_named(&build_caller_string_component(), Some("caller")) + .expect("caller parses"); + + let err = fuser + .fuse() + .expect_err("an adapter-site fuse must NOT allow the vestigial drop / grow-defer"); + assert!( + err.to_string().contains("memory.grow"), + "expected the memory.grow rebase rejection (verdict must stay false when an \ + adapter site is present), got: {err}" + ); +} + +/// (d) FINDING 1 — a scalar interface whose LIVE `compute` body allocates +/// internally (`memory.grow` reachable from a live export). The interface +/// verdict holds (scalar boundary), but the allocator is NOT dead, so the +/// drop/defer must NOT fire: under shared+rebase this must KEEP `cabi_realloc` +/// and hard-error on the grow — a clean compile error, never a fuse-`Ok` that +/// traps at runtime. +#[test] +fn live_internal_grow_keeps_realloc_and_hard_errors() { + let component = build_scalar_component_with_live_grow(); + + let mut fuser = Fuser::new(shared_rebase_config()); + fuser + .add_component_named(&component, Some("scalar-live-grow")) + .expect("component parses"); + + let err = fuser.fuse().expect_err( + "a scalar interface with a LIVE internal memory.grow must NOT drop/defer \ + (allocator is not dead) — it must hard-error, not fuse-Ok-then-trap", + ); + assert!( + err.to_string().contains("memory.grow"), + "expected the memory.grow rebase rejection (allocator not dead ⟹ no defer), \ + got: {err}" + ); +} + +// --------------------------------------------------------------------------- +// 3. Guard: a normal non-shared fuse is unchanged +// --------------------------------------------------------------------------- + +/// A non-rebasing (multi-memory) fuse of the very same scalar component must be +/// byte-identical to today: the drop is gated on the rebasing path, so +/// `cabi_realloc` is preserved. +#[test] +fn non_shared_fuse_preserves_realloc() { + let component = build_component(Lift::Scalar); + + let config = FuserConfig { + memory_strategy: MemoryStrategy::MultiMemory, + address_rebasing: false, + attestation: false, + reproducible: false, + component_provenance: false, + ..Default::default() + }; + + let mut fuser = Fuser::new(config); + fuser + .add_component_named(&component, Some("scalar-app")) + .expect("component parses"); + + let fused = fuser.fuse().expect("multi-memory fuse succeeds"); + assert!( + has_realloc_export(&fused), + "a non-shared fuse must preserve cabi_realloc; exports = {:?}", + export_func_names(&fused) + ); +} + +// --------------------------------------------------------------------------- +// 4. FINDING 2: the export-name match is tight +// --------------------------------------------------------------------------- + +/// A legitimately-authored export `cabi_realloc$notdigits` (non-digit suffix) +/// is NOT one the merger mints, so the #298 drop must PRESERVE it even on the +/// drop path — while still dropping the real `cabi_realloc`. Fused under +/// shared+rebase (the drop path): `cabi_realloc` gone, `cabi_realloc$notdigits` +/// kept. +#[test] +fn tight_match_preserves_realloc_lookalike_export() { + // The predicate itself: only the merger's own shapes are droppable. + use meld_core::memory_probe::is_vestigial_realloc_export_name as droppable; + assert!(droppable("cabi_realloc")); + assert!(droppable("cabi_realloc$0")); + assert!(droppable("cabi_realloc$12")); + assert!(!droppable("cabi_realloc$notdigits")); + assert!(!droppable("cabi_realloc$")); // empty suffix + assert!(!droppable("cabi_realloc$1a")); // mixed suffix + assert!(!droppable("cabi_realloc_extra")); + + let component = build_scalar_component_with_extra_export("cabi_realloc$notdigits"); + + let mut fuser = Fuser::new(shared_rebase_config()); + fuser + .add_component_named(&component, Some("lookalike-app")) + .expect("component parses"); + + let fused = fuser + .fuse() + .expect("scalar boundary must fuse under shared+rebase (grow deferred)"); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&fused) + .expect("fused output must validate"); + + let names = export_func_names(&fused); + assert!( + !names.iter().any(|n| n == "cabi_realloc"), + "the real cabi_realloc must be dropped; exports = {names:?}" + ); + assert!( + names.iter().any(|n| n == "cabi_realloc$notdigits"), + "the legitimately-named cabi_realloc$notdigits must be preserved; \ + exports = {names:?}" + ); +}