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}