1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
//! Snapshot of live VM state for read-only introspection by module functions.
//!
//! `VmStateSnapshot` captures the call stack, locals, args, and module bindings
//! at a point during execution and implements `VmStateAccessor` so that
//! extension modules (e.g., `std::state`) can inspect the VM without holding a
//! mutable borrow on it.
//!
//! # W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12)
//!
//! Built on top of W17-snapshot-roundtrip's kind-threaded serializer API
//! (`slot_to_serializable` / `serializable_to_slot` at
//! `shape-runtime::snapshot`). The pre-bulldozer implementation collected raw
//! bit patterns from the live VM via the deleted Wave-6.5-substep-1 shims and
//! the deleted hand-rolled retain-on-read discipline. The post-§2.7.7
//! replacement threads `NativeKind` from the parallel kind track at every read
//! site and exposes `KindedSlot` carriers through `FrameInfo`.
//!
//! The snapshot is filled lazily — captured via [`VirtualMachine::capture_vm_state`]
//! before each module-function dispatch by [`super::vm_impl::modules`]. Bodies in
//! `state_builtins/*` receive the snapshot via `ModuleContext.vm_state` and
//! project its `KindedSlot` carriers through `slot_to_serializable` to build
//! their `TypedReturn` payloads.
use shape_runtime::module_exports::{FrameInfo, VmStateAccessor};
use shape_value::{KindedSlot, NativeKind, ValueSlot};
use super::VirtualMachine;
/// Snapshot of VM state captured at a point during execution.
///
/// The snapshot owns kinded copies of the relevant VM state — `KindedSlot`'s
/// `Clone` / `Drop` impls dispatch on `NativeKind` to retire / bump heap
/// refcounts, so by the time the snapshot is constructed every slot owns its
/// own share. The live VM keeps its own shares; teardown is independent.
pub(crate) struct VmStateSnapshot {
/// All call frames from oldest (bottom) to newest (top).
/// The "current frame" is the last entry; `caller` is the second-to-last.
pub(crate) frames: Vec<FrameInfo>,
/// Captured args for the currently-executing function (or empty if at
/// top-level / no frames).
pub(crate) current_args: Vec<KindedSlot>,
/// Locals for the currently-executing function (name + KindedSlot).
/// Names come from `Function::param_names` when available; otherwise
/// they're "local_<idx>".
pub(crate) current_locals: Vec<(String, KindedSlot)>,
/// Module bindings (binding name + KindedSlot).
pub(crate) module_bindings: Vec<(String, KindedSlot)>,
/// Total instructions executed up to capture point.
pub(crate) instruction_count: usize,
}
impl VirtualMachine {
/// Capture a read-only snapshot of the current VM state.
///
/// **W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12).** Threads
/// `NativeKind` from the parallel stack/module-binding kind tracks
/// (§2.7.7 / §2.7.8) so every captured slot owns a typed Arc share
/// (where heap-bearing). The snapshot lives in `ModuleContext.vm_state`
/// for the duration of one module-function dispatch.
pub(crate) fn capture_vm_state(&self) -> VmStateSnapshot {
let frames = self.snapshot_frames_for_accessor();
let (current_args, current_locals) = self.snapshot_current_args_locals();
let module_bindings = self.snapshot_module_bindings_for_accessor();
VmStateSnapshot {
frames,
current_args,
current_locals,
module_bindings,
instruction_count: self.snapshot_instruction_count(),
}
}
/// Build the FrameInfo vector from the live `call_stack`.
///
/// Per-frame upvalues are recovered from `closure_heap_bits` /
/// `closure_heap_kind` via `OwnedClosureBlock::read_capture_kinded`
/// (§2.7.8 / Q10 — captures carry their parallel kind track on the
/// ClosureLayout side-table, not on the CallFrame raw u64 vec).
fn snapshot_frames_for_accessor(&self) -> Vec<FrameInfo> {
let mut out = Vec::with_capacity(self.call_stack.len());
for frame in self.call_stack.iter() {
let function_name = frame
.function_id
.and_then(|fid| self.program.functions.get(fid as usize))
.map(|f| f.name.clone())
.unwrap_or_default();
// Locals window: stack[base_pointer .. base_pointer+locals_count]
// with parallel kinds. The kinds track is lockstep with the
// stack data track per §2.7.7 invariant.
let base = frame.base_pointer;
let end = base.saturating_add(frame.locals_count).min(self.stack.len());
let mut locals: Vec<KindedSlot> = Vec::with_capacity(end - base);
for i in base..end {
let bits = self.stack[i];
let kind = self
.kinds
.get(i)
.copied()
.unwrap_or(NativeKind::Bool);
// Clone-on-read via clone_with_kind discipline: snapshot
// owns its own share.
let cloned = clone_slot_kinded(bits, kind);
locals.push(cloned);
}
// Upvalues: dispatch through OwnedClosureBlock when the frame
// is a closure call.
let upvalues = self.snapshot_frame_upvalues(frame);
out.push(FrameInfo {
function_id: frame.function_id,
function_name,
blob_hash: frame.blob_hash.map(|h| h.0),
local_ip: 0, // Per-frame local IP recovery is the
// W17-snapshot-callstack-localip follow-up.
locals,
upvalues,
args: Vec::new(), // The per-frame args are at the lower stack
// window (base_pointer - arity..base_pointer);
// recovery requires the per-call arity which
// is not stored on CallFrame today —
// W17-snapshot-frame-args follow-up.
});
}
out
}
/// Recover per-frame upvalues as `Vec<KindedSlot>` from
/// `closure_heap_bits` / `closure_heap_kind`.
///
/// Returns `None` for non-closure frames (`closure_heap_bits == None`).
/// For closure frames, walks the `OwnedClosureBlock` via
/// `read_capture_kinded(idx)` per ADR-006 §2.7.8 / Q10 — the
/// ClosureLayout side-table carries the parallel kind track.
fn snapshot_frame_upvalues(&self, frame: &super::CallFrame) -> Option<Vec<KindedSlot>> {
let bits = frame.closure_heap_bits?;
let _kind = frame.closure_heap_kind?;
if bits == 0 {
return None;
}
// SAFETY: per the §2.7.8 / Q10 construction contract, when
// closure_heap_bits is Some(bits), the bits are
// `OwnedClosureBlock::into_raw(...)` (an Arc-like share). The
// OwnedClosureBlock recovery is the same 5-arm receiver-recovery
// pattern as elsewhere — recover, clone, restore the original
// share. `read_capture_kinded` then walks the ClosureLayout
// side-table to source per-capture NativeKind.
let ptr = bits as *mut u8;
// The block layout exposes capture-count via the layout Arc which
// we can't access from here without re-deriving it. The closure
// block bytes themselves carry their `type_id`; we recover the
// layout via the executor's closure-layout cache.
let block = match self.try_borrow_closure_block(ptr) {
Some(b) => b,
None => return None,
};
let count = block.layout().capture_count();
let mut out: Vec<KindedSlot> = Vec::with_capacity(count);
for idx in 0..count {
// SAFETY: idx < capture_count; the block is borrowed live.
let (bits, kind) = unsafe { block.read_capture_kinded(idx) };
let cloned = clone_slot_kinded(bits, kind);
out.push(cloned);
}
Some(out)
}
/// Args + locals for the currently-executing (topmost) frame.
/// For a top-level VM with no frames, returns (empty, empty).
fn snapshot_current_args_locals(&self) -> (Vec<KindedSlot>, Vec<(String, KindedSlot)>) {
let Some(top) = self.call_stack.last() else {
return (Vec::new(), Vec::new());
};
// Locals window: same as frames-side recovery.
let base = top.base_pointer;
let end = base.saturating_add(top.locals_count).min(self.stack.len());
let func_opt = top
.function_id
.and_then(|fid| self.program.functions.get(fid as usize));
let mut locals: Vec<(String, KindedSlot)> = Vec::with_capacity(end - base);
for i in base..end {
let local_idx = i - base;
let name = func_opt
.and_then(|f| f.param_names.get(local_idx).cloned())
.unwrap_or_else(|| format!("local_{local_idx}"));
let bits = self.stack[i];
let kind = self.kinds.get(i).copied().unwrap_or(NativeKind::Bool);
let cloned = clone_slot_kinded(bits, kind);
locals.push((name, cloned));
}
// Args: the call-frame ABI stores args contiguously in the
// locals window for typed call shape — the first `arity` slots
// are args, the rest are body locals. Surface them as a separate
// vector for `state.args()` consumers.
let args: Vec<KindedSlot> = if let Some(func) = func_opt {
let arity = (func.arity as usize).min(top.locals_count);
let mut out = Vec::with_capacity(arity);
for i in 0..arity {
let slot_idx = base + i;
if slot_idx >= self.stack.len() {
break;
}
let bits = self.stack[slot_idx];
let kind = self.kinds.get(slot_idx).copied().unwrap_or(NativeKind::Bool);
out.push(clone_slot_kinded(bits, kind));
}
out
} else {
Vec::new()
};
(args, locals)
}
/// Module bindings captured at snapshot time.
///
/// Binding names come from the program's `module_binding_names` when
/// available; otherwise indexed names are used.
fn snapshot_module_bindings_for_accessor(&self) -> Vec<(String, KindedSlot)> {
let n = self
.module_bindings
.len()
.min(self.module_binding_kinds.len());
let mut out: Vec<(String, KindedSlot)> = Vec::with_capacity(n);
for i in 0..n {
let name = self
.program
.module_binding_names
.get(i)
.cloned()
.unwrap_or_else(|| format!("binding_{i}"));
let bits = self.module_bindings[i];
let kind = self.module_binding_kinds[i];
let cloned = clone_slot_kinded(bits, kind);
out.push((name, cloned));
}
out
}
fn snapshot_instruction_count(&self) -> usize {
self.instruction_count
}
/// Try to borrow the `OwnedClosureBlock` whose `ptr` matches a
/// `closure_heap_bits` payload. Returns `None` if no closure
/// layout is registered for the block's type_id.
///
/// This routes through the program's `closure_function_layouts`
/// table populated at load time per the §2.7.8 / Q10 layout-side-
/// table protocol.
fn try_borrow_closure_block(
&self,
ptr: *mut u8,
) -> Option<shape_value::v2::closure_raw::OwnedClosureBlock> {
use shape_value::v2::closure_raw::{
OwnedClosureBlock, retain_typed_closure, typed_closure_function_id,
};
if ptr.is_null() {
return None;
}
// SAFETY: the block's `function_id` is stored in the
// TypedClosureHeader at the top of the block. The closure-layout
// side-table is keyed by `func_id` (Vec<Option<Arc<ClosureLayout>>>
// per program.closure_function_layouts), so we index that table
// with the recovered function_id to recover the layout.
let fn_id = unsafe { typed_closure_function_id(ptr) };
let layout = self
.program
.closure_function_layouts
.get(fn_id as usize)
.and_then(|opt| opt.clone())?;
// SAFETY: ptr is a live OwnedClosureBlock allocation (per the
// §2.7.8 / Q10 construction contract on `closure_heap_bits`).
// We bump the strong-count share via `retain_typed_closure` so the
// resulting `OwnedClosureBlock`'s `Drop` doesn't free the share
// the live frame still owns. `from_raw` then takes a new share.
unsafe {
retain_typed_closure(ptr);
}
let block = unsafe { OwnedClosureBlock::from_raw(ptr as *const u8, layout) };
Some(block)
}
}
/// Clone a `(bits, kind)` pair through the §2.7.7 `clone_with_kind`
/// dispatch — bumping the strong-count share for heap-bearing kinds.
/// Wraps the result in a `KindedSlot` carrier.
fn clone_slot_kinded(bits: u64, kind: NativeKind) -> KindedSlot {
// W5 v0.3 fix (2026-05-17): bump the underlying refcount via
// `clone_with_kind` BEFORE wrapping in a `KindedSlot` carrier.
//
// The previous shape (`KindedSlot::new(...)` then `carrier.clone()`)
// was a share-accounting double-release: `KindedSlot::new` claims
// ownership without bumping the refcount, so the carrier and the
// live VM both claim the same share. The `carrier.clone()` bump and
// `carrier`'s Drop release exactly cancel each other, leaving the
// refcount unchanged — but the returned `cloned` KindedSlot owns a
// share that doesn't exist. When the snapshot drops later, the
// cloned's Drop decrements the refcount to 0 while the live VM
// binding/stack still holds an owning reference. The next access
// through the VM-side reference is a use-after-free.
//
// The correct shape mirrors `module_binding_read_owned_kinded` in
// `executor/mod.rs:792-796` and `OwnedClosureBlock::read_capture_kinded`'s
// call site discipline: explicit `clone_with_kind` retain followed
// by `KindedSlot::new` claim of the freshly minted share. The live
// VM keeps its own share unchanged.
//
// Empirically isolated via `eprintln` instrumentation in
// `docs/cluster-audits/v0.3-w5-v2-raw-residuals.md` §1; root cause
// identical to the cluster-1.5 share-accounting double-release class
// (Round 13 T5 closure-self / cluster-1.5 args/captures), surfaced
// at the snapshot-clone boundary rather than the closure-call
// boundary.
crate::executor::vm_impl::stack::clone_with_kind(bits, kind);
KindedSlot::new(ValueSlot::from_raw(bits), kind)
}
impl VmStateAccessor for VmStateSnapshot {
fn current_frame(&self) -> Option<FrameInfo> {
self.frames.last().cloned()
}
fn all_frames(&self) -> Vec<FrameInfo> {
self.frames.clone()
}
fn caller_frame(&self) -> Option<FrameInfo> {
// Caller is the second-to-last frame (the last is the currently-
// executing frame).
let len = self.frames.len();
if len < 2 {
None
} else {
Some(self.frames[len - 2].clone())
}
}
fn current_args(&self) -> Vec<KindedSlot> {
self.current_args.clone()
}
fn current_locals(&self) -> Vec<(String, KindedSlot)> {
self.current_locals.clone()
}
fn module_bindings(&self) -> Vec<(String, KindedSlot)> {
self.module_bindings.clone()
}
fn instruction_count(&self) -> usize {
self.instruction_count
}
}