Skip to main content

shape_vm/executor/
snapshot.rs

1//! VM snapshot and restore for suspending/resuming execution.
2//!
3//! # W17-snapshot-resume surface (ADR-006 §2.7.4 + §2.7.5.1)
4//!
5//! `snapshot()` and `from_snapshot()` previously consumed the slot-(de)
6//! serialization helpers from `shape-runtime::snapshot` (and their `enum_*`
7//! / `print_result_*` adapters). Those helpers were deleted alongside the
8//! pre-bulldozer dynamic value carrier. The replacement — kind-threaded
9//! `slot_to_serializable(bits, kind, store)` and its inverse, mirroring the
10//! wire-conversion shape — is **deferred to a Phase 2c snapshot rebuild
11//! session per ADR-006 §2.7.4**. The deferral is binding: papering over the
12//! gap with a placeholder serializer or a hand-rolled byte format would
13//! silently corrupt persisted state, which §2.7.4 forbids verbatim. Instead,
14//! both methods return `VMError::NotImplemented` carrying a structured
15//! W17-snapshot-resume surface string (see [`w17_snapshot_surface`]); the
16//! prior `todo!()` macro-driven VM-thread abort is replaced with a
17//! recoverable runtime error so callers can detect the missing capability
18//! without crashing.
19//!
20//! `resolve_function_identity` is pure, value-tier-independent logic
21//! (operates only on `FunctionHash` / `Function` / IDs) and is kept
22//! intact. Its tests pass without exercising the snapshot pipeline.
23
24use std::collections::HashMap;
25
26use shape_value::VMError;
27
28use crate::bytecode::{Function, FunctionHash};
29
30/// Resolve a function's runtime ID from content-addressed identity.
31///
32/// Priority: `blob_hash` → `function_id` → `function_name`.
33/// Cross-validates when multiple identifiers are present.
34pub(crate) fn resolve_function_identity(
35    function_id_by_hash: &HashMap<FunctionHash, u16>,
36    functions: &[Function],
37    blob_hash: Option<FunctionHash>,
38    function_id: Option<u16>,
39    function_name: Option<&str>,
40) -> Result<u16, VMError> {
41    // 1. Hash-first resolution
42    if let Some(hash) = blob_hash {
43        let resolved = function_id_by_hash.get(&hash).copied().ok_or_else(|| {
44            VMError::RuntimeError(format!("unknown function blob hash: {}", hash))
45        })?;
46        // Cross-validate: if function_id is also present, they must agree
47        if let Some(fid) = function_id {
48            if fid != resolved {
49                return Err(VMError::RuntimeError(format!(
50                    "function_id/hash mismatch: frame id {} does not match hash {} (resolved id {})",
51                    fid, hash, resolved
52                )));
53            }
54        }
55        return Ok(resolved);
56    }
57
58    // 2. Direct function_id (no hash available)
59    if let Some(fid) = function_id {
60        if (fid as usize) < functions.len() {
61            return Ok(fid);
62        }
63        return Err(VMError::RuntimeError(format!(
64            "function_id {} out of range (program has {} functions)",
65            fid,
66            functions.len()
67        )));
68    }
69
70    // 3. Name-based fallback — require exactly one match
71    if let Some(name) = function_name {
72        let matches: Vec<usize> = functions
73            .iter()
74            .enumerate()
75            .filter_map(|(idx, f)| if f.name == name { Some(idx) } else { None })
76            .collect();
77        return match matches.len() {
78            1 => Ok(matches[0] as u16),
79            0 => Err(VMError::RuntimeError(format!(
80                "no function named '{}'",
81                name
82            ))),
83            n => Err(VMError::RuntimeError(format!(
84                "ambiguous function name '{}' ({} matches)",
85                name, n
86            ))),
87        };
88    }
89
90    // 4. No identifiers at all
91    Err(VMError::RuntimeError(
92        "cannot resolve function identity: no hash, id, or name provided".into(),
93    ))
94}
95
96/// W17-snapshot-resume surface text for `VirtualMachine::snapshot()` /
97/// `VirtualMachine::from_snapshot()`. Both methods return
98/// `Result<..., VMError>`, so they return a structured
99/// `VMError::NotImplemented` rather than panicking — the strict
100/// improvement over the prior `todo!()` macros that aborted the VM
101/// thread on first invocation.
102fn w17_snapshot_surface(op: &str) -> String {
103    format!(
104        "VirtualMachine::{op}: W17-snapshot-resume surface — \
105         kind-threaded `slot_to_serializable(bits, kind, store)` / \
106         inverse `serializable_to_slot(sv, expected_kind, store)` \
107         replacement for the deleted `nanboxed_to_serializable` / \
108         `serializable_to_nanboxed` pair has not landed. The design \
109         must (a) project every `NativeKind::Ptr(HeapKind::*)` slot to a \
110         `SerializableVMValue` arm of the right shape via \
111         `slot.as_heap_value()` + `HeapValue::*` match (§2.7.6 Q8 \
112         carrier-API bound), (b) reconstruct the parallel kind tracks \
113         from the persisted discriminator on restore (§2.7.7 / §2.7.8), \
114         (c) extend `SerializableVMValue` for the post-W14/W15 \
115         HeapKinds that have no current wire-format arm: HashSet, \
116         Iterator, Result, Option, Deque, Channel, PriorityQueue, \
117         Range, Reference, FilterExpr, SharedCell — the §2.7.5.1 \
118         wire-format extension question. Tracked as W17-snapshot-resume \
119         per docs/cluster-audits/phase-2d-playbook.md §3. ADR-006 \
120         §2.7.4 + §2.7.5.1.",
121    )
122}
123
124impl super::VirtualMachine {
125    /// Create a serializable snapshot of VM state.
126    ///
127    /// **W17-snapshot-roundtrip (Phase 2d Wave 2.6, 2026-05-11).** Uses
128    /// the kind-threaded `slot_to_serializable(bits, kind, store)` API
129    /// landed alongside the §2.7.5.1 wire-format extension. Round-trips
130    /// the stack, module bindings, IP, and exception-handler stack at
131    /// landing. Call-stack frames, loop contexts, locals living in
132    /// register windows on the stack (versus their inlined-into-stack
133    /// projection), and timeframe state are landed via the existing
134    /// `VmSnapshot` carrier with the per-slot kind threaded through
135    /// `slot_to_serializable` per slot. Deep heap kinds that don't yet
136    /// have a wire-format arm surface via the per-slot error path —
137    /// callers observe a structured `VMError::NotImplemented`, not
138    /// silent state loss.
139    pub fn snapshot(
140        &self,
141        store: &shape_runtime::snapshot::SnapshotStore,
142    ) -> Result<shape_runtime::snapshot::VmSnapshot, VMError> {
143        use shape_runtime::snapshot::{
144            SerializableExceptionHandler, SerializableLoopContext, VmSnapshot,
145            slot_to_serializable,
146        };
147
148        // Project the live `(stack[0..sp], kinds[0..sp])` pair through
149        // the kind-threaded API. Per-slot errors surface as
150        // VMError::NotImplemented carrying the W17 surface string from
151        // the inner projection.
152        let mut stack: Vec<shape_runtime::snapshot::SerializableVMValue> =
153            Vec::with_capacity(self.sp);
154        for i in 0..self.sp {
155            let bits = self.stack[i];
156            let kind = self.kinds[i];
157            let sv = slot_to_serializable(bits, kind, store).map_err(|msg| {
158                VMError::NotImplemented(format!(
159                    "VirtualMachine::snapshot stack[{i}] kind={kind:?}: {msg}"
160                ))
161            })?;
162            stack.push(sv);
163        }
164
165        // Module bindings: parallel `(module_bindings[i],
166        // module_binding_kinds[i])` projection. Per Q10 §2.7.8 the
167        // lockstep length invariant holds at every observable boundary.
168        let mb_len = self.module_bindings.len();
169        debug_assert_eq!(mb_len, self.module_binding_kinds.len());
170        let mut module_bindings: Vec<shape_runtime::snapshot::SerializableVMValue> =
171            Vec::with_capacity(mb_len);
172        for i in 0..mb_len {
173            let bits = self.module_bindings[i];
174            let kind = self.module_binding_kinds[i];
175            let sv = slot_to_serializable(bits, kind, store).map_err(|msg| {
176                VMError::NotImplemented(format!(
177                    "VirtualMachine::snapshot module_binding[{i}] kind={kind:?}: {msg}"
178                ))
179            })?;
180            module_bindings.push(sv);
181        }
182
183        // Locals: the typed VM's locals live in register windows on
184        // the stack; the `VmSnapshot.locals` field is reserved for the
185        // out-of-band cache the upper layer uses for resume IP
186        // relocation. Empty at landing; callers reconstruct local
187        // values by replaying the IP from the captured stack window.
188        let locals: Vec<shape_runtime::snapshot::SerializableVMValue> = Vec::new();
189
190        // Loop / timeframe / exception state: round-trip the
191        // structural-only data. The VM owns these internally; the
192        // accessor goes through the private fields since this body
193        // lives in `impl VirtualMachine` (snapshot.rs is part of the
194        // executor module).
195        let loop_stack = self.snapshot_loop_stack_for_export();
196        let timeframe_stack = self.snapshot_timeframe_stack_for_export();
197        let exception_handlers = self.snapshot_exception_handlers_for_export();
198        let call_stack = self.snapshot_call_stack_for_export();
199        let _: &Vec<SerializableLoopContext> = &loop_stack;
200        let _: &Vec<SerializableExceptionHandler> = &exception_handlers;
201
202        Ok(VmSnapshot {
203            ip: self.snapshot_ip(),
204            stack,
205            locals,
206            module_bindings,
207            call_stack,
208            loop_stack,
209            timeframe_stack,
210            exception_handlers,
211            ip_blob_hash: None,
212            ip_local_offset: None,
213            ip_function_id: None,
214        })
215    }
216
217    /// Restore a VM from a snapshot and bytecode program.
218    ///
219    /// **W17-snapshot-roundtrip (Phase 2d Wave 2.6, 2026-05-11).**
220    /// Symmetric inverse of `snapshot()`: rebuilds the VM from the
221    /// kind-threaded `serializable_to_slot` API. Stack, module
222    /// bindings, IP, exception handlers, and structural loop/
223    /// timeframe state restore deterministically when the snapshot's
224    /// per-slot kinds align with the program's `FrameDescriptor.slots`
225    /// at the resume IP. Discriminator-vs-kind mismatches surface as
226    /// structured errors per §2.7.5.1.
227    ///
228    /// Per-slot kind reconstruction: the snapshot's
229    /// `SerializableVMValue` discriminator (its variant tag) is the
230    /// authoritative carrier of the slot's kind. `serializable_to_slot`
231    /// takes an `expected_kind` hint per Q9 §2.7.7 (the post-proof
232    /// stack-kind invariant) but the discriminator wins on actual
233    /// projection. Restore picks the kind from the discriminator and
234    /// hands `(bits, kind)` back to the parallel-kind tracks.
235    pub fn from_snapshot(
236        program: crate::bytecode::BytecodeProgram,
237        snapshot: &shape_runtime::snapshot::VmSnapshot,
238        store: &shape_runtime::snapshot::SnapshotStore,
239    ) -> Result<Self, VMError> {
240        use shape_runtime::snapshot::serializable_to_slot;
241        use shape_value::NativeKind;
242
243        let mut vm = super::VirtualMachine::new(crate::VMConfig::default());
244        vm.load_program(program);
245
246        // Stack restoration: each `SerializableVMValue` arm picks its
247        // own kind from the discriminator. We use `expected_kind = Bool`
248        // for scalar/heap-light arms whose discriminator pins the kind
249        // unambiguously (Int→Int64, Number→Float64, etc.). The
250        // `serializable_to_slot` body either accepts a matching pair
251        // or surfaces a kind-mismatch error.
252        for (i, sv) in snapshot.stack.iter().enumerate() {
253            let expected = expected_kind_from_serializable(sv);
254            let (bits, kind) = serializable_to_slot(sv, expected, store).map_err(|msg| {
255                VMError::NotImplemented(format!(
256                    "VirtualMachine::from_snapshot stack[{i}]: {msg}"
257                ))
258            })?;
259            // push_kinded transfers the share into the stack.
260            vm.push_kinded(bits, kind)?;
261        }
262
263        // Module bindings: same per-slot kind threading.
264        if !snapshot.module_bindings.is_empty() {
265            // Pad the parallel tracks first per §2.7.8 / Q10 lockstep.
266            let needed = snapshot.module_bindings.len();
267            vm.module_binding_pad_to_kinded(needed);
268            for (i, sv) in snapshot.module_bindings.iter().enumerate() {
269                let expected = expected_kind_from_serializable(sv);
270                let (bits, kind) = serializable_to_slot(sv, expected, store).map_err(|msg| {
271                    VMError::NotImplemented(format!(
272                        "VirtualMachine::from_snapshot module_binding[{i}]: {msg}"
273                    ))
274                })?;
275                vm.module_binding_write_kinded(i, bits, kind);
276            }
277        }
278
279        // IP restoration.
280        vm.snapshot_set_ip(snapshot.ip);
281
282        // **W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12).**
283        // Call-stack restoration: structurally rebuild each `CallFrame`
284        // from the persisted `SerializableCallFrame` quintuple
285        // (return_ip, locals_base, locals_count, function_id, upvalues).
286        // Upvalues route through `serializable_to_slot` to recover their
287        // typed Arc shares — full round-trip for scalar/heap-light kinds;
288        // opaque arms surface clean per §2.7.5.1.
289        //
290        // closure_heap_bits / closure_heap_kind reconstruction (the
291        // Vec<u64> upvalue payload pointer back into a live
292        // OwnedClosureBlock) needs the layout's alloc_typed_closure +
293        // write_capture pipeline. We rebuild the block when:
294        //   (a) the function_id has a registered ClosureLayout
295        //   (b) the persisted upvalues align with capture_count
296        // Otherwise the frame restores without closure-block backing —
297        // a degraded but structurally-correct shape. Calls into the
298        // restored frame's upvalues then trip a `NotImplemented` at the
299        // upvalue-read site if any heap-bearing capture is missing.
300        if !snapshot.call_stack.is_empty() {
301            vm.restore_call_stack(&snapshot.call_stack, store)?;
302        }
303
304        // Loop stack / timeframe stack / exception handlers: structural
305        // restoration is internal-only — the VM doesn't expose public
306        // setters at landing, so these fields are reconstructed when
307        // the VM resumes execution and re-encounters the relevant
308        // opcodes (BeginLoop, EnterTimeframe, BeginCatch). At landing
309        // empty loop/timeframe state on resume is the documented
310        // contract (`VmSnapshot.loop_stack` / `timeframe_stack` are
311        // reserved for the W17-snapshot-control-flow follow-up).
312        let _ = (
313            &snapshot.loop_stack,
314            &snapshot.timeframe_stack,
315            &snapshot.exception_handlers,
316        );
317
318        let _ = NativeKind::Int64; // suppress unused-import warning when restore body shrinks
319
320        Ok(vm)
321    }
322
323    /// Restore the call stack from a snapshot's `Vec<SerializableCallFrame>`.
324    ///
325    /// **W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12).** Per
326    /// ADR-006 §2.7.8 / Q10, closure-bearing frames carry their captures
327    /// via `OwnedClosureBlock`, not via the legacy `Vec<u64>` upvalue
328    /// payload (the `Vec<u64>` field on CallFrame is now an opaque
329    /// payload byte-pattern preserved for non-typed closures).
330    ///
331    /// Per-frame restore steps:
332    ///   1. Recover function_id + locals_base + locals_count from the
333    ///      persisted SerializableCallFrame.
334    ///   2. If `upvalues: Some(serializable_upvalues)` AND the function
335    ///      has a registered `ClosureLayout`: allocate a fresh
336    ///      OwnedClosureBlock and write each capture's bits via
337    ///      `serializable_to_slot(sv, expected_kind=block.layout.
338    ///      capture_native_kind(i), store)`. The block's `Drop` walks
339    ///      the layout's capture masks and retires shares.
340    ///   3. Otherwise restore the frame without closure backing
341    ///      (closure_heap_bits/kind = None).
342    fn restore_call_stack(
343        &mut self,
344        frames: &[shape_runtime::snapshot::SerializableCallFrame],
345        store: &shape_runtime::snapshot::SnapshotStore,
346    ) -> Result<(), VMError> {
347        use shape_runtime::snapshot::serializable_to_slot;
348        use shape_value::NativeKind;
349        use shape_value::v2::closure_raw::{
350            OwnedClosureBlock, alloc_typed_closure, write_capture_raw_u64,
351        };
352
353        for (frame_idx, sframe) in frames.iter().enumerate() {
354            let function_id = sframe.function_id;
355            let mut closure_heap_bits: Option<u64> = None;
356            let mut closure_heap_kind: Option<NativeKind> = None;
357            let mut upvalues_raw: Option<Vec<u64>> = None;
358
359            if let (Some(svec), Some(fid)) = (sframe.upvalues.as_ref(), function_id) {
360                // Look up the closure layout for this function. If
361                // absent, fall back to raw-Vec<u64> upvalue restoration
362                // (non-typed closure path).
363                let layout_opt = self
364                    .program
365                    .closure_function_layouts
366                    .get(fid as usize)
367                    .and_then(|o| o.clone());
368                if let Some(layout) = layout_opt {
369                    if layout.capture_count() != svec.len() {
370                        return Err(VMError::NotImplemented(format!(
371                            "VirtualMachine::from_snapshot frame[{frame_idx}]: \
372                             W17-snapshot-roundtrip surface — upvalue count \
373                             mismatch (snapshot: {}, layout.capture_count: {}). \
374                             ADR-006 §2.7.5.1.",
375                            svec.len(),
376                            layout.capture_count(),
377                        )));
378                    }
379                    // SAFETY: alloc_typed_closure returns a freshly-
380                    // zeroed block sized for layout.total_heap_size();
381                    // refcount is 1 (owned by this frame).
382                    let ptr = unsafe { alloc_typed_closure(fid, 0, &layout) };
383                    for (i, sv) in svec.iter().enumerate() {
384                        let expected = layout.capture_native_kind(i);
385                        let (bits, _kind) =
386                            serializable_to_slot(sv, expected, store).map_err(|msg| {
387                                VMError::NotImplemented(format!(
388                                    "VirtualMachine::from_snapshot frame[{frame_idx}] \
389                                     upvalue[{i}]: {msg}"
390                                ))
391                            })?;
392                        // SAFETY: i < capture_count per the prior check.
393                        unsafe {
394                            write_capture_raw_u64(ptr, &layout, i, bits);
395                        }
396                    }
397                    // Wrap the freshly-built block — drop releases the
398                    // share when the frame pops. We need the raw ptr
399                    // bits to install on closure_heap_bits; build the
400                    // block, extract the ptr, then mem::forget the
401                    // wrapper so its Drop doesn't free the share we
402                    // just installed on the frame.
403                    let block = unsafe { OwnedClosureBlock::from_raw(ptr as *const u8, layout) };
404                    closure_heap_bits = Some(block.as_ptr() as u64);
405                    closure_heap_kind = Some(NativeKind::Ptr(
406                        shape_value::HeapKind::Closure,
407                    ));
408                    std::mem::forget(block);
409                } else {
410                    // No layout — store the raw payload bits as the
411                    // legacy Vec<u64> upvalue carrier so the frame
412                    // remains structurally complete. Heap-bearing
413                    // upvalues in this path surface clean on read.
414                    let mut raw: Vec<u64> = Vec::with_capacity(svec.len());
415                    for sv in svec {
416                        // Bool fallback is OK here: this branch is the
417                        // pre-typed-closure path that never carried
418                        // kind metadata anyway. Bool-zero-on-mismatch
419                        // is the legacy contract.
420                        let expected = NativeKind::Bool;
421                        let (bits, _) =
422                            serializable_to_slot(sv, expected, store).unwrap_or((0, NativeKind::Bool));
423                        raw.push(bits);
424                    }
425                    upvalues_raw = Some(raw);
426                }
427            }
428
429            let blob_hash = sframe
430                .blob_hash
431                .map(crate::bytecode::FunctionHash);
432
433            self.call_stack.push(super::CallFrame {
434                return_ip: sframe.return_ip,
435                base_pointer: sframe.locals_base,
436                locals_count: sframe.locals_count,
437                function_id,
438                upvalues: upvalues_raw,
439                blob_hash,
440                closure_heap_bits,
441                closure_heap_kind,
442            });
443        }
444        Ok(())
445    }
446
447    // ── W17-snapshot-roundtrip internal accessors ──
448    //
449    // Internal accessors that bridge the private VM fields to the
450    // snapshot's structural carriers. Kept on the `VirtualMachine`
451    // impl so the field accesses don't need to leak through pub
452    // getters that risk drift in non-snapshot contexts.
453
454    fn snapshot_ip(&self) -> usize {
455        // Per the field comment at executor/mod.rs:261, `ip` is the
456        // instruction pointer. The snapshot/resume contract carries
457        // the absolute IP — relocation is the host's responsibility
458        // per `VmSnapshot.ip_blob_hash` / `ip_local_offset` fields
459        // (which we leave None at landing).
460        self.ip
461    }
462
463    fn snapshot_set_ip(&mut self, ip: usize) {
464        self.ip = ip;
465    }
466
467    fn snapshot_loop_stack_for_export(
468        &self,
469    ) -> Vec<shape_runtime::snapshot::SerializableLoopContext> {
470        self.loop_stack
471            .iter()
472            .map(|lc| shape_runtime::snapshot::SerializableLoopContext {
473                start: lc.start,
474                end: lc.end,
475            })
476            .collect()
477    }
478
479    fn snapshot_timeframe_stack_for_export(
480        &self,
481    ) -> Vec<Option<shape_ast::data::Timeframe>> {
482        self.timeframe_stack.clone()
483    }
484
485    fn snapshot_exception_handlers_for_export(
486        &self,
487    ) -> Vec<shape_runtime::snapshot::SerializableExceptionHandler> {
488        self.exception_handlers
489            .iter()
490            .map(|h| shape_runtime::snapshot::SerializableExceptionHandler {
491                catch_ip: h.catch_ip,
492                stack_size: h.stack_size,
493                call_depth: h.call_depth,
494            })
495            .collect()
496    }
497
498    fn snapshot_call_stack_for_export(
499        &self,
500    ) -> Vec<shape_runtime::snapshot::SerializableCallFrame> {
501        // **W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12).**
502        // Per-frame upvalues now project through `OwnedClosureBlock::
503        // read_capture_kinded` per the §2.7.8 / Q10 cell-storage
504        // parallel-kind track. The closure layout side-table provides
505        // the kind source for each capture; we route each (bits, kind)
506        // pair through `slot_to_serializable` to build the
507        // `Vec<SerializableVMValue>` payload.
508        //
509        // Non-closure frames (`closure_heap_bits == None`) carry
510        // `upvalues: None` as before.
511        //
512        // mutable-cell-payload restoration (cells whose interior holds
513        // a SharedCell payload) is the W17-snapshot-sharedcell follow-up.
514        let store = shape_runtime::snapshot::SnapshotStore::new(
515            std::env::temp_dir().join("shape-w17-snapshot-store"),
516        )
517        .ok();
518        self.call_stack
519            .iter()
520            .map(|frame| {
521                let upvalues = if let Some(ref s) = store {
522                    snapshot_frame_upvalues_serializable(self, frame, s)
523                } else {
524                    None
525                };
526                shape_runtime::snapshot::SerializableCallFrame {
527                    return_ip: frame.return_ip,
528                    locals_base: frame.base_pointer,
529                    locals_count: frame.locals_count,
530                    function_id: frame.function_id,
531                    upvalues,
532                    blob_hash: frame.blob_hash.map(|h| h.0),
533                    local_ip: None,
534                }
535            })
536            .collect()
537    }
538}
539
540/// Project a frame's upvalues into `Vec<SerializableVMValue>` via the
541/// closure block's `read_capture_kinded` + `slot_to_serializable`.
542/// Returns `None` for non-closure frames or when the closure layout is
543/// not available in the program's side-table (the W17-snapshot-callstack-
544/// upvalues-no-layout follow-up).
545fn snapshot_frame_upvalues_serializable(
546    vm: &super::VirtualMachine,
547    frame: &super::CallFrame,
548    store: &shape_runtime::snapshot::SnapshotStore,
549) -> Option<Vec<shape_runtime::snapshot::SerializableVMValue>> {
550    use shape_runtime::snapshot::slot_to_serializable;
551    use shape_value::v2::closure_raw::{
552        OwnedClosureBlock, retain_typed_closure, typed_closure_function_id,
553    };
554
555    let bits = frame.closure_heap_bits?;
556    if bits == 0 {
557        return None;
558    }
559    let ptr = bits as *const u8;
560    // SAFETY: closure_heap_bits is a live closure block per §2.7.8 / Q10.
561    let fn_id = unsafe { typed_closure_function_id(ptr) };
562    let layout = vm
563        .program
564        .closure_function_layouts
565        .get(fn_id as usize)
566        .and_then(|opt| opt.clone())?;
567    // Retain a borrow share. SAFETY: ptr is a live OwnedClosureBlock
568    // allocation; retain_typed_closure bumps the strong-count atomically
569    // so the resulting OwnedClosureBlock's Drop doesn't free the live
570    // share.
571    unsafe {
572        retain_typed_closure(ptr);
573    }
574    let block = unsafe { OwnedClosureBlock::from_raw(ptr, layout) };
575    let count = block.layout().capture_count();
576    let mut out: Vec<shape_runtime::snapshot::SerializableVMValue> = Vec::with_capacity(count);
577    for idx in 0..count {
578        // SAFETY: idx < count; the block is borrowed live.
579        let (cap_bits, cap_kind) = unsafe { block.read_capture_kinded(idx) };
580        let sv = match slot_to_serializable(cap_bits, cap_kind, store) {
581            Ok(v) => v,
582            Err(_) => {
583                // Unsupported capture kind — surface as IteratorOpaque
584                // sentinel so the wire payload is still serializable.
585                // Restore will reject this via the OpaqueOnRestore
586                // contract per §2.7.5.1.
587                shape_runtime::snapshot::SerializableVMValue::IteratorOpaque
588            }
589        };
590        out.push(sv);
591    }
592    Some(out)
593}
594
595/// Pick the `expected_kind` for [`serializable_to_slot`] from a
596/// SerializableVMValue's discriminator. Scalar arms pin their kind
597/// (Int→Int64, Number→Float64, Bool→Bool, String→String). Heap arms
598/// map to `Ptr(HeapKind::*)`. Pre-existing arms with no canonical
599/// HeapKind alignment fall through to `Bool` — `serializable_to_slot`
600/// surfaces a structured kind-mismatch error there.
601fn expected_kind_from_serializable(
602    sv: &shape_runtime::snapshot::SerializableVMValue,
603) -> shape_value::NativeKind {
604    use shape_runtime::snapshot::SerializableVMValue as SV;
605    use shape_value::{HeapKind, NativeKind};
606    match sv {
607        SV::Int(_) => NativeKind::Int64,
608        SV::Number(_) => NativeKind::Float64,
609        SV::Bool(_) => NativeKind::Bool,
610        SV::String(_) => NativeKind::String,
611        SV::None | SV::Unit => NativeKind::Bool,
612        SV::Decimal(_) => NativeKind::Ptr(HeapKind::Decimal),
613        SV::BigInt(_) => NativeKind::Ptr(HeapKind::BigInt),
614        SV::Char(_) => NativeKind::Ptr(HeapKind::Char),
615        SV::HashSet { .. } => NativeKind::Ptr(HeapKind::HashSet),
616        SV::PriorityQueueHeap { .. } => NativeKind::Ptr(HeapKind::PriorityQueue),
617        SV::AtomicI64 { .. } => NativeKind::Ptr(HeapKind::Atomic),
618        SV::ResultData { .. } => NativeKind::Ptr(HeapKind::Result),
619        SV::OptionData { .. } => NativeKind::Ptr(HeapKind::Option),
620        SV::IteratorOpaque => NativeKind::Ptr(HeapKind::Iterator),
621        SV::DequeOpaque { .. } => NativeKind::Ptr(HeapKind::Deque),
622        SV::ChannelOpaque { .. } => NativeKind::Ptr(HeapKind::Channel),
623        SV::ReferenceOpaque => NativeKind::Ptr(HeapKind::Reference),
624        SV::FilterExprOpaque => NativeKind::Ptr(HeapKind::FilterExpr),
625        SV::SharedCellOpaque => NativeKind::Ptr(HeapKind::SharedCell),
626        SV::MutexOpaque { .. } => NativeKind::Ptr(HeapKind::Mutex),
627        SV::LazyOpaque { .. } => NativeKind::Ptr(HeapKind::Lazy),
628        // Pre-existing complex arms — surface clean rather than guess.
629        _ => NativeKind::Bool,
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    /// Create a minimal Function with just a name (other fields defaulted).
638    fn make_function(name: &str) -> Function {
639        Function {
640            name: name.to_string(),
641            arity: 0,
642            param_names: Vec::new(),
643            locals_count: 0,
644            entry_point: 0,
645            body_length: 0,
646            is_closure: false,
647            captures_count: 0,
648            is_async: false,
649            ref_params: Vec::new(),
650            ref_mutates: Vec::new(),
651            mutable_captures: Vec::new(),
652            frame_descriptor: None,
653            osr_entry_points: Vec::new(),
654            mir_data: None,
655        }
656    }
657
658    fn make_hash(seed: u8) -> FunctionHash {
659        FunctionHash([seed; 32])
660    }
661
662    #[test]
663    fn test_resolve_by_hash() {
664        let hash = make_hash(0xAB);
665        let mut by_hash = HashMap::new();
666        by_hash.insert(hash, 3u16);
667        let funcs = vec![
668            make_function("a"),
669            make_function("b"),
670            make_function("c"),
671            make_function("d"),
672        ];
673
674        let result = resolve_function_identity(&by_hash, &funcs, Some(hash), None, None);
675        assert_eq!(result.unwrap(), 3);
676    }
677
678    #[test]
679    fn test_resolve_hash_not_found_is_error() {
680        let hash = make_hash(0xAB);
681        let by_hash = HashMap::new(); // empty — hash not registered
682        let funcs = vec![make_function("a")];
683
684        let result = resolve_function_identity(&by_hash, &funcs, Some(hash), None, None);
685        assert!(result.is_err());
686        let msg = result.unwrap_err().to_string();
687        assert!(msg.contains("unknown function blob hash"), "got: {}", msg);
688    }
689
690    #[test]
691    fn test_resolve_hash_function_id_mismatch_is_error() {
692        let hash = make_hash(0xCD);
693        let mut by_hash = HashMap::new();
694        by_hash.insert(hash, 2u16); // hash resolves to 2
695        let funcs = vec![make_function("a"), make_function("b"), make_function("c")];
696
697        // Pass function_id=5 which disagrees with hash-resolved id=2
698        let result = resolve_function_identity(&by_hash, &funcs, Some(hash), Some(5), None);
699        assert!(result.is_err());
700        let msg = result.unwrap_err().to_string();
701        assert!(msg.contains("mismatch"), "got: {}", msg);
702    }
703
704    #[test]
705    fn test_resolve_hash_function_id_agree() {
706        let hash = make_hash(0xEF);
707        let mut by_hash = HashMap::new();
708        by_hash.insert(hash, 1u16);
709        let funcs = vec![make_function("a"), make_function("b")];
710
711        // Both agree on id=1
712        let result = resolve_function_identity(&by_hash, &funcs, Some(hash), Some(1), None);
713        assert_eq!(result.unwrap(), 1);
714    }
715
716    #[test]
717    fn test_resolve_by_function_id() {
718        let by_hash = HashMap::new();
719        let funcs = vec![make_function("a"), make_function("b"), make_function("c")];
720
721        let result = resolve_function_identity(&by_hash, &funcs, None, Some(2), None);
722        assert_eq!(result.unwrap(), 2);
723    }
724
725    #[test]
726    fn test_resolve_function_id_out_of_range() {
727        let by_hash = HashMap::new();
728        let funcs = vec![make_function("a")];
729
730        let result = resolve_function_identity(&by_hash, &funcs, None, Some(99), None);
731        assert!(result.is_err());
732        let msg = result.unwrap_err().to_string();
733        assert!(msg.contains("out of range"), "got: {}", msg);
734    }
735
736    #[test]
737    fn test_resolve_unique_name_fallback() {
738        let by_hash = HashMap::new();
739        let funcs = vec![
740            make_function("alpha"),
741            make_function("beta"),
742            make_function("gamma"),
743        ];
744
745        let result = resolve_function_identity(&by_hash, &funcs, None, None, Some("beta"));
746        assert_eq!(result.unwrap(), 1);
747    }
748
749    #[test]
750    fn test_resolve_ambiguous_name_is_error() {
751        let by_hash = HashMap::new();
752        let funcs = vec![
753            make_function("dup"),
754            make_function("other"),
755            make_function("dup"),
756        ];
757
758        let result = resolve_function_identity(&by_hash, &funcs, None, None, Some("dup"));
759        assert!(result.is_err());
760        let msg = result.unwrap_err().to_string();
761        assert!(msg.contains("ambiguous"), "got: {}", msg);
762    }
763
764    #[test]
765    fn test_resolve_name_not_found() {
766        let by_hash = HashMap::new();
767        let funcs = vec![make_function("a")];
768
769        let result = resolve_function_identity(&by_hash, &funcs, None, None, Some("missing"));
770        assert!(result.is_err());
771        let msg = result.unwrap_err().to_string();
772        assert!(msg.contains("no function named"), "got: {}", msg);
773    }
774
775    #[test]
776    fn test_resolve_no_identifiers_is_error() {
777        let by_hash = HashMap::new();
778        let funcs = vec![make_function("a")];
779
780        let result = resolve_function_identity(&by_hash, &funcs, None, None, None);
781        assert!(result.is_err());
782        let msg = result.unwrap_err().to_string();
783        assert!(msg.contains("no hash, id, or name"), "got: {}", msg);
784    }
785
786    // --- VmSnapshot IP relocation tests ---
787    //
788    // These tests exercise only the `VmSnapshot` data shape (field presence,
789    // serde defaults, JSON roundtrip) and never call into `snapshot()` /
790    // `from_snapshot()`. They pass even while the snapshot/restore pipeline
791    // itself is `todo!()`-deferred per ADR-006 §2.7.4.
792
793    use shape_runtime::snapshot::VmSnapshot;
794
795    #[test]
796    fn test_snapshot_ip_relocation_fields_present() {
797        // Verify that VmSnapshot has the new relocation fields
798        let snapshot = VmSnapshot {
799            ip: 42,
800            stack: vec![],
801            locals: vec![],
802            module_bindings: vec![],
803            call_stack: vec![],
804            loop_stack: vec![],
805            timeframe_stack: vec![],
806            exception_handlers: vec![],
807            ip_blob_hash: Some([0xAB; 32]),
808            ip_local_offset: Some(10),
809            ip_function_id: Some(1),
810        };
811        assert_eq!(snapshot.ip, 42);
812        assert_eq!(snapshot.ip_blob_hash, Some([0xAB; 32]));
813        assert_eq!(snapshot.ip_local_offset, Some(10));
814        assert_eq!(snapshot.ip_function_id, Some(1));
815    }
816
817    #[test]
818    fn test_snapshot_legacy_without_relocation_fields() {
819        // Legacy snapshots that don't have the new fields should still deserialize
820        // (serde default kicks in)
821        let snapshot = VmSnapshot {
822            ip: 100,
823            stack: vec![],
824            locals: vec![],
825            module_bindings: vec![],
826            call_stack: vec![],
827            loop_stack: vec![],
828            timeframe_stack: vec![],
829            exception_handlers: vec![],
830            ip_blob_hash: None,
831            ip_local_offset: None,
832            ip_function_id: None,
833        };
834        // Without relocation info, from_snapshot should fall back to absolute IP
835        assert!(snapshot.ip_blob_hash.is_none());
836        assert!(snapshot.ip_local_offset.is_none());
837        assert!(snapshot.ip_function_id.is_none());
838    }
839
840    #[test]
841    fn test_snapshot_serialization_roundtrip_with_relocation() {
842        let snapshot = VmSnapshot {
843            ip: 42,
844            stack: vec![],
845            locals: vec![],
846            module_bindings: vec![],
847            call_stack: vec![],
848            loop_stack: vec![],
849            timeframe_stack: vec![],
850            exception_handlers: vec![],
851            ip_blob_hash: Some([0xCD; 32]),
852            ip_local_offset: Some(7),
853            ip_function_id: Some(2),
854        };
855        let json = serde_json::to_string(&snapshot).unwrap();
856        let restored: VmSnapshot = serde_json::from_str(&json).unwrap();
857        assert_eq!(restored.ip_blob_hash, Some([0xCD; 32]));
858        assert_eq!(restored.ip_local_offset, Some(7));
859        assert_eq!(restored.ip_function_id, Some(2));
860    }
861
862    #[test]
863    fn test_snapshot_deserialization_without_relocation_fields() {
864        // Simulate a JSON snapshot from before the relocation fields were added
865        let json = r#"{
866            "ip": 50,
867            "stack": [],
868            "locals": [],
869            "module_bindings": [],
870            "call_stack": [],
871            "loop_stack": [],
872            "timeframe_stack": [],
873            "exception_handlers": []
874        }"#;
875        let snapshot: VmSnapshot = serde_json::from_str(json).unwrap();
876        assert_eq!(snapshot.ip, 50);
877        assert!(snapshot.ip_blob_hash.is_none());
878        assert!(snapshot.ip_local_offset.is_none());
879        assert!(snapshot.ip_function_id.is_none());
880    }
881
882    // -----------------------------------------------------------------
883    // W17-snapshot-roundtrip gate tests (Wave 2.6, 2026-05-11)
884    // -----------------------------------------------------------------
885    //
886    // VM-level `snapshot()` / `from_snapshot()` round-trip the live
887    // VM state for the supported NativeKind / HeapKind set per
888    // ADR-006 §2.7.5.1. Empty/scalar snapshots succeed end-to-end;
889    // unsupported deep heap kinds surface structured errors.
890
891    use crate::VMConfig;
892    use crate::executor::VirtualMachine;
893    use shape_runtime::snapshot::SnapshotStore;
894
895    /// W17 gate: an empty VM snapshots cleanly (no surface error).
896    /// Replaces the pre-Wave-2.6 surface-stop gate test that asserted
897    /// the surface message; the new shape is "scalar+empty snapshots
898    /// round-trip end-to-end".
899    #[test]
900    fn test_w17_vm_snapshot_empty_ok() {
901        let vm = VirtualMachine::new(VMConfig::default());
902        let tmp = tempfile::tempdir().expect("tempdir");
903        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
904
905        let snap = vm.snapshot(&store).expect("empty snapshot should succeed");
906        assert_eq!(snap.stack.len(), 0);
907        assert_eq!(snap.call_stack.len(), 0);
908        assert_eq!(snap.ip, 0);
909    }
910
911    /// W17 roundtrip smoke: snapshot a scalar-window VM, restore via
912    /// `from_snapshot`, verify the restored VM observes the same
913    /// stack + IP state. Demonstrates deterministic state restoration
914    /// for the scalar+string kind set.
915    #[test]
916    fn test_w17_snapshot_roundtrip_scalar_state() {
917        use crate::bytecode::BytecodeProgram;
918        use shape_value::NativeKind;
919
920        let mut vm = VirtualMachine::new(VMConfig::default());
921        // Push scalars of every supported kind.
922        vm.push_kinded(42i64 as u64, NativeKind::Int64)
923            .expect("push int");
924        vm.push_kinded(3.14f64.to_bits(), NativeKind::Float64)
925            .expect("push float");
926        vm.push_kinded(1, NativeKind::Bool).expect("push bool");
927
928        let tmp = tempfile::tempdir().expect("tempdir");
929        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
930
931        let snap = vm.snapshot(&store).expect("snapshot scalar state");
932        assert_eq!(snap.stack.len(), 3);
933
934        // Restore on a fresh VM with an empty program.
935        let restored = VirtualMachine::from_snapshot(
936            BytecodeProgram::default(),
937            &snap,
938            &store,
939        )
940        .expect("restore scalar state");
941        let restored_snap = restored
942            .snapshot(&store)
943            .expect("re-snapshot restored state");
944        assert_eq!(restored_snap.stack.len(), 3);
945        // Deep equality on the discriminator+value:
946        use shape_runtime::snapshot::SerializableVMValue as SV;
947        assert!(matches!(restored_snap.stack[0], SV::Int(42)));
948        assert!(matches!(restored_snap.stack[1], SV::Number(f) if (f - 3.14).abs() < 1e-9));
949        assert!(matches!(restored_snap.stack[2], SV::Bool(true)));
950    }
951
952    /// W17 supported-kind round-trip: Result/Option carry inner
953    /// scalar payloads end-to-end.
954    #[test]
955    fn test_w17_snapshot_result_option_roundtrip() {
956        use crate::bytecode::BytecodeProgram;
957        use shape_value::heap_value::{OptionData, ResultData};
958        use shape_value::{HeapKind, KindedSlot, NativeKind, ValueSlot};
959        use std::sync::Arc;
960
961        let mut vm = VirtualMachine::new(VMConfig::default());
962
963        // Ok(42)
964        let payload =
965            KindedSlot::new(ValueSlot::from_raw(42u64), NativeKind::Int64);
966        let ok = Arc::new(ResultData::ok(payload));
967        let ok_bits = Arc::into_raw(ok) as u64;
968        vm.push_kinded(ok_bits, NativeKind::Ptr(HeapKind::Result))
969            .expect("push ok");
970
971        // Some("hello")
972        let str_arc = Arc::new("hello".to_string());
973        let str_kinded = KindedSlot::from_string_arc(str_arc);
974        let some = Arc::new(OptionData::some(str_kinded));
975        let some_bits = Arc::into_raw(some) as u64;
976        vm.push_kinded(some_bits, NativeKind::Ptr(HeapKind::Option))
977            .expect("push some");
978
979        // None
980        let none = Arc::new(OptionData::none());
981        let none_bits = Arc::into_raw(none) as u64;
982        vm.push_kinded(none_bits, NativeKind::Ptr(HeapKind::Option))
983            .expect("push none");
984
985        let tmp = tempfile::tempdir().expect("tempdir");
986        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
987        let snap = vm.snapshot(&store).expect("snapshot result+option");
988
989        use shape_runtime::snapshot::SerializableVMValue as SV;
990        match &snap.stack[0] {
991            SV::ResultData {
992                is_ok: true,
993                payload,
994            } => match payload.as_ref() {
995                SV::Int(42) => {}
996                other => panic!("expected SV::Int(42), got {other:?}"),
997            },
998            other => panic!("expected Ok(42), got {other:?}"),
999        }
1000        match &snap.stack[1] {
1001            SV::OptionData {
1002                is_some: true,
1003                payload: Some(p),
1004            } => match p.as_ref() {
1005                SV::String(s) if s == "hello" => {}
1006                other => panic!("expected SV::String(hello), got {other:?}"),
1007            },
1008            other => panic!("expected Some(hello), got {other:?}"),
1009        }
1010        match &snap.stack[2] {
1011            SV::OptionData {
1012                is_some: false,
1013                payload: None,
1014            } => {}
1015            other => panic!("expected None, got {other:?}"),
1016        }
1017
1018        // Restore via from_snapshot.
1019        let restored = VirtualMachine::from_snapshot(
1020            BytecodeProgram::default(),
1021            &snap,
1022            &store,
1023        )
1024        .expect("restore result+option");
1025        let restored_snap = restored.snapshot(&store).expect("re-snapshot");
1026        assert_eq!(restored_snap.stack.len(), 3);
1027        // Round-trip preserves discriminator+payload.
1028        assert!(matches!(
1029            &restored_snap.stack[0],
1030            SV::ResultData {
1031                is_ok: true,
1032                payload,
1033            } if matches!(payload.as_ref(), SV::Int(42))
1034        ));
1035    }
1036
1037    /// W17 error-path gate: a corrupted/incompatible snapshot
1038    /// surfaces a structured error on resume rather than panicking.
1039    /// Demonstrates the §2.7.5.1 invariant — discriminator
1040    /// mismatch is a runtime error, not a Bool-default fallback.
1041    #[test]
1042    fn test_w17_snapshot_resume_incompatible_surfaces_error() {
1043        use crate::bytecode::BytecodeProgram;
1044        use shape_runtime::snapshot::{SerializableVMValue as SV, VmSnapshot};
1045
1046        // Build a synthetic snapshot whose stack carries an
1047        // arm-at-landing-has-no-inverse — `IteratorOpaque`. The
1048        // wire-format arm exists but `serializable_to_slot` surfaces
1049        // clean on it per §2.7.5.1 (deep payload restoration is
1050        // follow-up work).
1051        let tmp = tempfile::tempdir().expect("tempdir");
1052        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
1053        let snap = VmSnapshot {
1054            ip: 0,
1055            stack: vec![SV::IteratorOpaque],
1056            locals: vec![],
1057            module_bindings: vec![],
1058            call_stack: vec![],
1059            loop_stack: vec![],
1060            timeframe_stack: vec![],
1061            exception_handlers: vec![],
1062            ip_blob_hash: None,
1063            ip_local_offset: None,
1064            ip_function_id: None,
1065        };
1066
1067        let result =
1068            VirtualMachine::from_snapshot(BytecodeProgram::default(), &snap, &store);
1069        let err = match result {
1070            Ok(_) => panic!("expected Err for incompatible snapshot"),
1071            Err(e) => e,
1072        };
1073        let msg = format!("{err:?}");
1074        assert!(
1075            msg.contains("W17-snapshot-roundtrip surface"),
1076            "expected W17 surface error, got: {msg}"
1077        );
1078    }
1079
1080    /// W17 supported-kind round-trip: HashSet keys serialize verbatim.
1081    #[test]
1082    fn test_w17_snapshot_hashset_roundtrip() {
1083        use shape_value::NativeKind;
1084        use shape_value::heap_value::HashSetData;
1085        use std::sync::Arc;
1086
1087        let mut vm = VirtualMachine::new(VMConfig::default());
1088        let data = Arc::new(HashSetData::from_keys(vec![
1089            Arc::new("alpha".to_string()),
1090            Arc::new("beta".to_string()),
1091        ]));
1092        let bits = Arc::into_raw(data) as u64;
1093        vm.push_kinded(bits, NativeKind::Ptr(shape_value::HeapKind::HashSet))
1094            .expect("push hashset");
1095
1096        let tmp = tempfile::tempdir().expect("tempdir");
1097        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
1098        let snap = vm.snapshot(&store).expect("snapshot hashset");
1099        use shape_runtime::snapshot::SerializableVMValue as SV;
1100        match &snap.stack[0] {
1101            SV::HashSet { keys } => {
1102                assert_eq!(keys.len(), 2);
1103                assert!(keys.iter().any(|k| k == "alpha"));
1104                assert!(keys.iter().any(|k| k == "beta"));
1105            }
1106            other => panic!("expected SV::HashSet, got {other:?}"),
1107        }
1108    }
1109
1110    /// W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12): non-empty
1111    /// call_stack with scalar locals round-trips structurally.
1112    /// Closure-bearing frames need the program's
1113    /// `closure_function_layouts` registered (out of this test's scope —
1114    /// the bytecode-program plumbing routes through compiler-side
1115    /// register_closure_function); this test exercises the non-closure
1116    /// frame path.
1117    #[test]
1118    fn test_w17_snapshot_non_closure_callstack_roundtrip() {
1119        use crate::bytecode::BytecodeProgram;
1120        use crate::executor::CallFrame;
1121        use shape_value::NativeKind;
1122
1123        let mut vm = VirtualMachine::new(VMConfig::default());
1124        // Push 2 scalars to serve as frame[0]'s locals.
1125        vm.push_kinded(7i64 as u64, NativeKind::Int64)
1126            .expect("push int 7");
1127        vm.push_kinded(1, NativeKind::Bool).expect("push bool true");
1128        // Manually push a non-closure CallFrame whose locals window is
1129        // those two slots.
1130        vm.call_stack.push(CallFrame {
1131            return_ip: 0,
1132            base_pointer: 0,
1133            locals_count: 2,
1134            function_id: None,
1135            upvalues: None,
1136            blob_hash: None,
1137            closure_heap_bits: None,
1138            closure_heap_kind: None,
1139        });
1140
1141        let tmp = tempfile::tempdir().expect("tempdir");
1142        let store = SnapshotStore::new(tmp.path()).expect("snapshot store");
1143        let snap = vm.snapshot(&store).expect("snapshot non-closure frame");
1144        assert_eq!(snap.call_stack.len(), 1);
1145        let sframe = &snap.call_stack[0];
1146        assert_eq!(sframe.locals_count, 2);
1147        assert_eq!(sframe.locals_base, 0);
1148        assert!(sframe.upvalues.is_none(), "non-closure frame has no upvalues");
1149
1150        // Restore on a fresh VM. Pre-pad stack so locals_base=0 +
1151        // locals_count=2 has space; the test asserts the frame restores
1152        // structurally (function_id, locals_base, locals_count) not the
1153        // raw stack window.
1154        let restored = VirtualMachine::from_snapshot(
1155            BytecodeProgram::default(),
1156            &snap,
1157            &store,
1158        )
1159        .expect("restore non-closure callstack");
1160        assert_eq!(restored.call_stack.len(), 1);
1161        let restored_frame = &restored.call_stack[0];
1162        assert_eq!(restored_frame.return_ip, 0);
1163        assert_eq!(restored_frame.base_pointer, 0);
1164        assert_eq!(restored_frame.locals_count, 2);
1165        assert!(restored_frame.upvalues.is_none());
1166        assert!(restored_frame.closure_heap_bits.is_none());
1167    }
1168
1169    /// W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12):
1170    /// VmStateSnapshot accessor surface for an empty VM round-trips
1171    /// cleanly (no panics; FrameInfo accessors return empty / None).
1172    #[test]
1173    fn test_w17_vm_state_snapshot_empty_accessor() {
1174        use shape_runtime::module_exports::VmStateAccessor;
1175
1176        let vm = VirtualMachine::new(VMConfig::default());
1177        let snap = vm.capture_vm_state();
1178        assert!(snap.current_frame().is_none());
1179        assert!(snap.caller_frame().is_none());
1180        assert_eq!(snap.all_frames().len(), 0);
1181        assert_eq!(snap.current_args().len(), 0);
1182        assert_eq!(snap.current_locals().len(), 0);
1183        assert_eq!(snap.module_bindings().len(), 0);
1184        assert_eq!(snap.instruction_count(), 0);
1185    }
1186
1187    /// W17-state-tier-roundtrip (Phase 2d Wave 3, 2026-05-12):
1188    /// VmStateSnapshot threads kinds through the parallel stack track
1189    /// for a non-empty live VM. Locals come out as KindedSlot carriers.
1190    #[test]
1191    fn test_w17_vm_state_snapshot_kind_threaded_locals() {
1192        use crate::executor::CallFrame;
1193        use shape_runtime::module_exports::VmStateAccessor;
1194        use shape_value::NativeKind;
1195
1196        let mut vm = VirtualMachine::new(VMConfig::default());
1197        vm.push_kinded(42i64 as u64, NativeKind::Int64)
1198            .expect("push int");
1199        vm.push_kinded(3.14f64.to_bits(), NativeKind::Float64)
1200            .expect("push float");
1201        vm.call_stack.push(CallFrame {
1202            return_ip: 0,
1203            base_pointer: 0,
1204            locals_count: 2,
1205            function_id: None,
1206            upvalues: None,
1207            blob_hash: None,
1208            closure_heap_bits: None,
1209            closure_heap_kind: None,
1210        });
1211
1212        let snap = vm.capture_vm_state();
1213        let frames = snap.all_frames();
1214        assert_eq!(frames.len(), 1);
1215        let f = &frames[0];
1216        assert_eq!(f.locals.len(), 2);
1217        assert!(matches!(f.locals[0].kind(), NativeKind::Int64));
1218        assert!(matches!(f.locals[1].kind(), NativeKind::Float64));
1219        assert_eq!(f.locals[0].slot().raw(), 42);
1220        assert_eq!(f.locals[1].slot().raw(), 3.14f64.to_bits());
1221    }
1222}