Skip to main content

shape_jit/ffi/
string.rs

1//! `Arc<String>` strict-typed carrier FFI for JIT-emitted code
2//! (W12-jit-string-carrier-unification, Phase 3 cluster-0 Round 12 T2/T3,
3//! 2026-05-13).
4//!
5//! ADR-006 §2.7.5 (producing-site classification) names `NativeKind::String`
6//! as the §2.7.5 String carrier with shape `Arc::into_raw(Arc<String>) as
7//! u64` — the standard Rust Arc layout with refcount at offset -16 of the
8//! data pointer. The VM-side consumer (`crates/shape-vm/src/executor/objects/
9//! set_methods.rs:136-155::result_slot_to_string_arc`, mirrors in
10//! `hashmap_methods.rs`) and `KindedSlot::Drop` for `NativeKind::String`
11//! (`crates/shape-value/src/kinded_slot.rs:500-502`) both decode this exact
12//! shape via `Arc::increment_strong_count::<String>` / `Arc::from_raw(bits
13//! as *const String)`.
14//!
15//! ## Carrier-shape rule (binding)
16//!
17//! - **`NativeKind::String` slot**: `Arc::into_raw(Arc<String>) as u64`,
18//!   refcount at offset -16. Retain/release dispatches through this
19//!   module's `jit_arc_string_retain` / `jit_arc_string_release` — bumps
20//!   the Rust Arc control-block refcount.
21//!
22//! - **JIT-internal NaN-box string carrier**: `Box::into_raw(Box::new(
23//!   UnifiedValue<Arc<String>>)) as u64`, refcount at offset +4 inside
24//!   the UnifiedValue allocation. Retained/released via the legacy
25//!   `jit_arc_retain` / `jit_arc_release` in `ffi/arc.rs`. Stays for
26//!   JIT-internal pathways (the dispatch shell's method-name push at
27//!   `terminators.rs:235`, `call_string_method` returns, etc.) that
28//!   pair the bits with their own JIT-internal decode contract.
29//!
30//! Mixing the two segfaults at every retain/release reclaim:
31//! - `jit_arc_release` on an `Arc::into_raw(Arc<String>) as u64` slot
32//!   reads `*(bits + 4) as *const AtomicU32` — offset 4 inside the
33//!   `String` payload (`String`'s `ptr/cap/len` words), corrupting the
34//!   data on `fetch_sub`.
35//! - `Arc::decrement_strong_count::<String>(bits)` on a `Box::into_raw(
36//!   Box::new(UnifiedValue<Arc<String>>))` slot decrements `*(bits - 16)`
37//!   as if it were the Arc control block — but offset -16 from the
38//!   UnifiedValue start is whatever the allocator placed there. UB.
39//!
40//! ## Round 7A precedent
41//!
42//! The Result/Option Arc carriers in `ffi/result.rs::jit_arc_result_retain`
43//! / `_release` / `jit_arc_option_retain` / `_release` (Round 7A close
44//! commit `d01d83b7` + `9f27edcd`) and the Round 9 typed-Arc collection
45//! retain/release pairs in `ffi/v2/collection_arc.rs` are the bound
46//! precedent shape for every body in this module.
47//!
48//! ## Round 12 T2/T3 surface closures
49//!
50//! - Smoke 4 JIT: `let mut s = Set(); s.add("a"); s.add("b"); print(
51//!   s.size())` → `2` VM == JIT. The `"a"` / `"b"` constants flow as
52//!   `MirConstant::Str` operands stamped `NativeKind::String`; the VM
53//!   trampoline's `KindedSlot::Drop` decodes via `Arc::from_raw(bits as
54//!   *const String)`. Pre-Round-12 `box_string` returned NaN-box bits →
55//!   UB at the VM consumer's `Arc::from_raw`.
56//! - `print("hello")` JIT: was clean SURFACE at the print Call-terminator's
57//!   `NativeKind::String` arm in `terminators.rs::466` (Round 8A reopen
58//!   surfaced). Post-Round-12 the §2.7.5 producer emits the matching
59//!   carrier shape and `jit_print_str` reads `&String` directly.
60
61use std::collections::HashMap;
62use std::sync::{Arc, Mutex, OnceLock};
63
64// ============================================================================
65// Per-NativeKind::String kinded retain / release
66// ============================================================================
67
68/// Retain (clone) an `Arc<String>` strong-count share. Bumps the standard
69/// Rust Arc refcount at offset -16 of the `Arc::into_raw` pointer via
70/// `Arc::increment_strong_count::<String>` — NOT the W-series
71/// `UnifiedValue<T>` refcount at offset 4 (`jit_arc_retain`'s shape).
72///
73/// SAFETY: `bits` must be `Arc::into_raw(Arc<String>) as u64` produced by
74/// the `MirConstant::Str` / `MirConstant::StringId` lowering in
75/// `mir_compiler/ownership.rs::compile_constant`, or by the VM-side
76/// `KindedSlot::from_string_arc` producer. Null is silently no-op'd
77/// (mirror of Round 7A's `jit_arc_result_retain` null-bits safety).
78#[unsafe(no_mangle)]
79pub extern "C" fn jit_arc_string_retain(bits: u64) {
80    if bits == 0 {
81        return;
82    }
83    // cluster-2-cw-E measurement (cluster-2-inventory §F): track active-
84    // share retain count for the §2.7.5 `Arc<String>` carrier. Independent
85    // counter from `JIT_ARC_RETAIN_CALLS` (the UnifiedValue<T> path) per
86    // the carrier-shape distinction at this module's docstring.
87    super::arc::STRING_RETAIN_CALLS
88        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
89    // SAFETY: see fn docs. The §2.7.5 String carrier contract names the
90    // bits as `Arc::into_raw(Arc<String>) as u64`; `Arc::increment_strong_
91    // count` operates on the Arc control block at offset -16.
92    unsafe {
93        Arc::increment_strong_count(bits as *const String);
94    }
95}
96
97/// Release an `Arc<String>` strong-count share. Mirrors
98/// `jit_arc_string_retain`'s increment — uses
99/// `Arc::decrement_strong_count::<String>` per Rust Arc contract.
100/// Reaching refcount zero runs `String::Drop` (drops the inner buffer).
101///
102/// SAFETY: same as `jit_arc_string_retain`. Null is silently no-op'd.
103#[unsafe(no_mangle)]
104pub extern "C" fn jit_arc_string_release(bits: u64) {
105    if bits == 0 {
106        return;
107    }
108    // cluster-2-cw-E measurement (cluster-2-inventory §F): track active-
109    // share release count + drop-to-zero count for the §2.7.5 `Arc<String>`
110    // carrier. Independent counters from `JIT_ARC_RELEASE_CALLS` /
111    // `JIT_ARC_RELEASE_FREES` (the UnifiedValue<T> path) per the carrier-
112    // shape distinction at this module's docstring.
113    super::arc::STRING_RELEASE_CALLS
114        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
115    // SAFETY: see fn docs. Read strong-count BEFORE decrement to detect
116    // the drop-to-zero transition (Arc's decrement returns void; we cannot
117    // observe the post-decrement count atomically without racing). The
118    // `strong_count == 1` read identifies the slot that will reach zero
119    // on this decrement — `Acquire` ordering pairs with the matching
120    // `Release` decrement to synchronize with the eventual drop.
121    unsafe {
122        // Construct a temporary Arc to inspect strong count without
123        // perturbing it. SAFETY: bits is a live Arc::into_raw payload per
124        // the function-level contract; `from_raw` adopts one share, the
125        // following `into_raw` returns it, so strong_count is unperturbed
126        // across this block.
127        let arc = Arc::from_raw(bits as *const String);
128        let pre_release_count = Arc::strong_count(&arc);
129        let _ = Arc::into_raw(arc); // restore the share we adopted
130        if pre_release_count == 1 {
131            super::arc::STRING_RELEASE_FREES
132                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
133        }
134        Arc::decrement_strong_count(bits as *const String);
135    }
136}
137
138// ============================================================================
139// §2.7.5 String carrier compile-time-emitted-constant helper
140// ============================================================================
141
142/// Content-keyed intern pool for §2.7.5 `Arc<String>` JIT compile-time
143/// constants. Per cluster-2-closure-wave-E-fix refined-Option-A disposition
144/// (`docs/cluster-audits/cluster-2-cw-E-string-leak-measurement.md` §5.1 +
145/// §6): deduplicate by content so repeat occurrences of the same constant
146/// share one `Arc<String>` allocation instead of allocating N copies.
147///
148/// The pool itself IS the "permanent share" of the §2.7.5 carrier — the
149/// `Arc<String>` lives for the program's lifetime (process-wide static),
150/// matching the prior `Arc::increment_strong_count`-based permanent-share
151/// discipline's lifetime. Carrier-shape is unchanged: iconst payload stays
152/// `Arc::as_ptr(&pool_arc) as u64`, identical to the prior
153/// `Arc::into_raw` pointer shape that consumers
154/// (`KindedSlot::Drop` for `NativeKind::String` at
155/// `crates/shape-value/src/kinded_slot.rs`, `set_methods.rs::
156/// result_slot_to_string_arc`, hashmap_methods.rs mirrors,
157/// `jit_arc_string_retain` / `jit_arc_string_release` above) already
158/// decode via `Arc::increment_strong_count::<String>` /
159/// `Arc::from_raw(bits as *const String)`.
160///
161/// **Deduplication-only fix.** Per §5.1 quantification: prog3 (5x
162/// "hello") drops from `leaked_total=9` (4 baseline + 5 distinct allocs)
163/// to `leaked_total=5` (4 baseline + 1 dedup'd). Worst-case fixtures
164/// where all constants are distinct (prog5 = 20 distinct) see zero
165/// savings. Full elimination requires a JIT-module deallocation hook
166/// (measurement §5.2 Option B, cluster-1.5+ territory).
167///
168/// **Forbidden under refined Option A** (per measurement §5.1 +
169/// CLAUDE.md cluster-2 canonical refusal set):
170/// - Changing iconst payload to an intern-pool *index* (breaks §2.7.5
171///   carrier-shape contract; cascades through 257+ `NativeKind::String`
172///   sites + 48 `Arc::from_raw`-shape consumers — exceeds ceiling-c).
173/// - Renaming the pool to a defection-attractor framing
174///   ("intern-pool bridge" / "string-constant probe" / "dedup helper" —
175///   refused per CLAUDE.md broader-family regex `(decode|tag|kind|
176///   dispatch|value.call|closure.callback|frame.setup|callee|capture)
177///   (bridge|probe|helper|hop|translator|adapter|shim)`).
178fn intern_pool() -> &'static Mutex<HashMap<String, Arc<String>>> {
179    static POOL: OnceLock<Mutex<HashMap<String, Arc<String>>>> = OnceLock::new();
180    POOL.get_or_init(|| Mutex::new(HashMap::new()))
181}
182
183/// Compile-time helper: produce a §2.7.5 `Arc::into_raw`-shape carrier
184/// pointer for a `MirConstant::Str` / `MirConstant::StringId` site,
185/// content-deduplicated through the program-wide [`intern_pool`].
186///
187/// The constant is embedded as an `iconst I64` in the JIT-emitted code, so
188/// the bits are static across every runtime occurrence of the site. The
189/// intern pool keeps one `Arc<String>` per distinct content alive for the
190/// program's lifetime (process-wide static `OnceLock<Mutex<HashMap<…>>>`);
191/// the iconst payload is `Arc::as_ptr(&pool_arc) as u64` — the same raw
192/// pointer shape as the prior `Arc::into_raw`-with-refcount-boost
193/// discipline, so consumers (`jit_arc_string_retain` /
194/// `jit_arc_string_release`, `KindedSlot::Drop` for `NativeKind::String`,
195/// VM-side `Arc::from_raw(bits as *const String)`) need NO change.
196///
197/// **Refcount discipline (preserved per call).** Each call bumps the
198/// strong count by 1 to preserve the pre-fix "active share" safety
199/// against unpaired releases (e.g. a JIT-emitted `release` without a
200/// matching `retain` would otherwise underflow when the pool's share
201/// is the only one). Pre-fix this was `Arc::increment_strong_count` on
202/// every freshly-allocated Arc (boost from 1 → 2); post-fix it is the
203/// same increment on the pool-owned Arc. Dedup at the allocation layer
204/// does NOT change this per-call refcount discipline.
205///
206/// **Heap-memory savings (the §5.1 measurement target).** The dedup
207/// property: repeat occurrences of the same constant (e.g. prog3's
208/// `print("hello") × 5`) share one underlying `Arc<String>`
209/// allocation — one `String` heap buffer + one Arc control block,
210/// strong_count = 1 (pool) + N (per-call boosts). Pre-fix: 5x
211/// `Arc::new("hello")` = 5 separate `String` heap buffers + 5 Arc
212/// control blocks. Post-fix: 1 of each, regardless of call count.
213/// `STRING_CONSTANT_ALLOCS` counts actual `Arc::new` invocations
214/// (= distinct-content allocations = leaked-allocation count per §F.1).
215#[inline]
216pub fn arc_string_constant(s: String) -> u64 {
217    let mut pool = intern_pool()
218        .lock()
219        .expect("string-constant intern pool mutex poisoned");
220    // `entry().or_insert_with` keeps the post-insert reference scoped
221    // inside the mutex guard so we can read the Arc's data pointer
222    // without dropping the guard mid-function.
223    let pool_arc = pool.entry(s).or_insert_with_key(|key| {
224        // Dedup miss: allocate one Arc, insert into the pool. The pool
225        // retains the permanent share — this allocation is the §F.1
226        // leak surface for this content, counted once.
227        super::arc::STRING_CONSTANT_ALLOCS
228            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
229        Arc::new(key.clone())
230        // Dedup hit (else branch): or_insert_with_key skips the closure;
231        // STRING_CONSTANT_ALLOCS does NOT increment (the counter
232        // measures actual leaked Arc<String> allocations per §F.1).
233    });
234    let ptr = Arc::as_ptr(pool_arc) as u64;
235    // SAFETY: `ptr` is the data pointer of `pool_arc`, a live Arc
236    // (pool holds one share). The increment bumps the Arc control-
237    // block refcount by 1 — the "active share" per the original
238    // pre-fix discipline, preserved across dedup so JIT-emitted code
239    // patterns with unpaired releases (e.g. the prog4 surfaced finding
240    // §3 of cluster-2-cw-E-string-leak-measurement.md) cannot
241    // underflow to 0.
242    unsafe {
243        Arc::increment_strong_count(ptr as *const String);
244    }
245    ptr
246}
247
248// ============================================================================
249// Tests
250// ============================================================================
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Refined Option A intern-pool: `arc_string_constant` returns a
257    /// non-null pointer whose Arc control block has strong_count >= 2
258    /// after a single call (the pool's permanent share + the per-call
259    /// active-share boost preserving pre-fix safety against unpaired
260    /// releases). Pre-fix: `Arc::increment_strong_count` on a fresh
261    /// `Arc::new` boosts 1 → 2. Post: same boost on the pool-owned Arc.
262    #[test]
263    fn test_arc_string_constant_returns_pool_owned_pointer() {
264        // Use a test-unique key to avoid cross-test contamination via
265        // the program-wide static intern pool.
266        let bits = arc_string_constant("test-refcount-boosted-key".to_string());
267        assert_ne!(bits, 0);
268
269        // SAFETY: `bits` is a pool-owned Arc's data pointer per
270        // `arc_string_constant`'s contract; the pool holds one share +
271        // one per-call boost. Temporary-adopt to inspect, then retire
272        // the temporary share via `adopted` drop.
273        unsafe {
274            Arc::increment_strong_count(bits as *const String);
275            let adopted = Arc::from_raw(bits as *const String);
276            // Pool's permanent share + per-call boost + our temporary
277            // adoption = ≥ 3. Pre-fix this was 2 + temporary = 3 as
278            // well. The `≥` form tolerates concurrent same-key calls
279            // from other tests (none in this module — keys are unique
280            // — but the looser bound is defensive against future
281            // refactors).
282            assert!(Arc::strong_count(&adopted) >= 3);
283            // `adopted` drops here, retiring the temporary share.
284        }
285    }
286
287    #[test]
288    fn test_jit_arc_string_retain_bumps_refcount() {
289        let arc = Arc::new("test".to_string());
290        let bits = Arc::into_raw(arc) as u64;
291
292        // refcount: 1
293        jit_arc_string_retain(bits);
294        // refcount: 2
295
296        unsafe {
297            let recovered = Arc::from_raw(bits as *const String);
298            assert_eq!(Arc::strong_count(&recovered), 2);
299            // Adopt restores: from_raw took 1 share; restore by bumping.
300            Arc::increment_strong_count(bits as *const String);
301            // Now refcount is back to 2 with `recovered` holding one.
302            drop(recovered);
303            // refcount: 1
304            Arc::decrement_strong_count(bits as *const String);
305            // refcount: 0 — allocation freed.
306        }
307    }
308
309    #[test]
310    fn test_jit_arc_string_release_drops_refcount() {
311        let arc = Arc::new("test".to_string());
312        // SAFETY: `arc` is alive; bumping its strong count is sound.
313        unsafe {
314            Arc::increment_strong_count(Arc::as_ptr(&arc));
315        }
316        let bits = Arc::into_raw(arc) as u64;
317        // refcount: 2 (original Arc + the increment)
318
319        jit_arc_string_release(bits);
320        // refcount: 1 — still alive
321
322        unsafe {
323            let recovered = Arc::from_raw(bits as *const String);
324            assert_eq!(Arc::strong_count(&recovered), 1);
325            // `recovered` drops here, retires the last share.
326        }
327    }
328
329    /// Null-bits safety: retain/release on bits=0 silently no-op.
330    /// Mirrors Round 7A's `jit_arc_result_retain` null-bits guard.
331    #[test]
332    fn test_jit_arc_string_retain_release_null_bits_noop() {
333        jit_arc_string_retain(0);
334        jit_arc_string_release(0);
335        // No segfault, no UB — null is the documented producer-site
336        // sentinel for an unallocated String slot.
337    }
338
339    /// Round-trip: a constant produced by `arc_string_constant` survives
340    /// multiple retain/release cycles without underflowing to 0. The
341    /// pool's permanent share keeps the allocation alive across
342    /// JIT-emitted active-share retain/release pairs.
343    ///
344    /// Pool ownership is permanent (never decremented) — do NOT manually
345    /// decrement the pool's share at test-end; that would corrupt the
346    /// pool entry and segfault subsequent same-key calls.
347    #[test]
348    fn test_arc_string_constant_survives_use_drop_cycle() {
349        let bits = arc_string_constant("test-survives-cycle-key".to_string());
350
351        // Simulate JIT-emitted retain/release pairs on the constant.
352        for _ in 0..10 {
353            jit_arc_string_retain(bits);
354            jit_arc_string_release(bits);
355        }
356
357        // Pool's permanent share keeps the allocation alive.
358        // SAFETY: pool-owned Arc data pointer remains valid.
359        let s: &String = unsafe { &*(bits as *const String) };
360        assert_eq!(s, "test-survives-cycle-key");
361
362        // Simulate the "single use-then-drop" pattern (release without
363        // prior retain). The pool's permanent share keeps the allocation
364        // alive — pre-refined-Option-A required `arc_string_constant`'s
365        // refcount-boost to survive this; post, the pool ownership
366        // covers the same case.
367        jit_arc_string_release(bits);
368        let s: &String = unsafe { &*(bits as *const String) };
369        assert_eq!(s, "test-survives-cycle-key");
370    }
371
372    /// VM-side consumer interop: `Arc::from_raw(bits as *const String)`
373    /// must recover the original String content. Same shape as
374    /// `set_methods.rs::result_slot_to_string_arc` — the VM-side consumer
375    /// bumps via `jit_arc_string_retain` before `Arc::from_raw` to adopt
376    /// a share without underflowing. Pool-owned permanent share remains
377    /// intact post-test.
378    #[test]
379    fn test_arc_string_constant_arc_from_raw_recovers_content() {
380        let bits = arc_string_constant("test-arc-from-raw-key".to_string());
381
382        // Bump refcount once so the VM-side `Arc::from_raw` consumer
383        // can adopt a share without underflowing the pool's permanent
384        // share.
385        jit_arc_string_retain(bits);
386
387        unsafe {
388            let recovered: Arc<String> = Arc::from_raw(bits as *const String);
389            assert_eq!(*recovered, "test-arc-from-raw-key");
390            // `recovered` retires its share here — pool's permanent
391            // share remains.
392        }
393    }
394
395    /// Refined Option A intern-pool: deduplication property — repeat
396    /// calls with the same content return the same iconst bits, so the
397    /// JIT-emitted code paths for `print("hello") × 5` all observe one
398    /// shared allocation. Validates the measurement-deliverable §5.1
399    /// disposition (prog3 5x "hello" leak: 5 distinct allocs → 1).
400    #[test]
401    fn test_arc_string_constant_deduplicates_same_content() {
402        let bits_a = arc_string_constant("test-dedup-key".to_string());
403        let bits_b = arc_string_constant("test-dedup-key".to_string());
404        let bits_c = arc_string_constant("test-dedup-key".to_string());
405
406        assert_ne!(bits_a, 0);
407        assert_eq!(bits_a, bits_b);
408        assert_eq!(bits_b, bits_c);
409
410        // Content recovers correctly from the shared pointer.
411        let s: &String = unsafe { &*(bits_a as *const String) };
412        assert_eq!(s, "test-dedup-key");
413    }
414
415    /// Refined Option A intern-pool: distinct content gets distinct
416    /// iconst bits (different pool entries, different allocations).
417    /// Validates dedup is content-keyed, not call-count-keyed.
418    #[test]
419    fn test_arc_string_constant_distinct_content_distinct_pointers() {
420        let bits_alpha = arc_string_constant("test-distinct-alpha-key".to_string());
421        let bits_beta = arc_string_constant("test-distinct-beta-key".to_string());
422
423        assert_ne!(bits_alpha, 0);
424        assert_ne!(bits_beta, 0);
425        assert_ne!(bits_alpha, bits_beta);
426
427        // Each recovers its own content.
428        let s_a: &String = unsafe { &*(bits_alpha as *const String) };
429        let s_b: &String = unsafe { &*(bits_beta as *const String) };
430        assert_eq!(s_a, "test-distinct-alpha-key");
431        assert_eq!(s_b, "test-distinct-beta-key");
432    }
433
434    /// Refined Option A intern-pool: dedup-pool entry survives multiple
435    /// calls — the second-and-subsequent calls reuse the pool entry
436    /// rather than allocating fresh. The underlying `String` heap
437    /// buffer + Arc control block are allocated once; per-call boosts
438    /// bump the strong_count.
439    ///
440    /// Pre-fix: N calls = N separate `Arc::new` allocations (each at
441    /// refcount=2). Post-fix: N calls = 1 `Arc::new` allocation (pool's
442    /// permanent share) + N per-call boosts (strong_count = 1 + N). The
443    /// heap-memory leak per-distinct-content is divided by call count.
444    #[test]
445    fn test_arc_string_constant_pool_share_invariant() {
446        let key = "test-pool-share-invariant-key".to_string();
447        let bits1 = arc_string_constant(key.clone());
448        let bits2 = arc_string_constant(key.clone());
449        let bits3 = arc_string_constant(key);
450        assert_eq!(bits1, bits2);
451        assert_eq!(bits2, bits3);
452        // Pool's permanent share keeps strong_count stable.
453        // SAFETY: bits1 is a pool-owned Arc data pointer; temporary
454        // increment-from_raw-drop adopts then retires one share without
455        // disturbing the pool's baseline.
456        let count = unsafe {
457            Arc::increment_strong_count(bits1 as *const String);
458            let adopted = Arc::from_raw(bits1 as *const String);
459            let c = Arc::strong_count(&adopted);
460            c
461            // `adopted` drops here, returns the temporary share.
462        };
463        // Pool holds 1 share + our temporary increment = ≥ 2; pre-fix
464        // this would be 2+ depending on how many `arc_string_constant`
465        // calls leaked (one boost per call). Either way, the
466        // strong_count remains finite (bounded by pool + active
467        // JIT-emitted retain/release cycles), NOT growing per call.
468        assert!(
469            count >= 2,
470            "expected at least 2 strong shares (pool's permanent + our \
471             temporary adoption), got {count}"
472        );
473    }
474}