From c07a1cc23024fe966bd2dc83004048ef9320bef1 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Fri, 20 Feb 2026 14:21:11 +0000 Subject: [PATCH] pr7.11 --- crates/console/prometeu-vm/src/scheduler.rs | 3 +- .../prometeu-vm/src/virtual_machine.rs | 424 +++++++++++++++++- 2 files changed, 416 insertions(+), 11 deletions(-) diff --git a/crates/console/prometeu-vm/src/scheduler.rs b/crates/console/prometeu-vm/src/scheduler.rs index 6b2ae6ba..361b282d 100644 --- a/crates/console/prometeu-vm/src/scheduler.rs +++ b/crates/console/prometeu-vm/src/scheduler.rs @@ -52,7 +52,8 @@ impl Scheduler { // ---------- Sleeping operations ---------- - /// Put a coroutine to sleep until `wake_tick` (inclusive). + /// Put a coroutine to sleep until `wake_tick`. + /// A coroutine is woken when `current_tick >= wake_tick`. /// The sleeping list is kept stably ordered to guarantee determinism. pub fn sleep_until(&mut self, coro: HeapRef, wake_tick: u64) { let entry = SleepEntry { wake_tick, seq: self.next_seq, coro }; diff --git a/crates/console/prometeu-vm/src/virtual_machine.rs b/crates/console/prometeu-vm/src/virtual_machine.rs index 8bd388e8..831718f4 100644 --- a/crates/console/prometeu-vm/src/virtual_machine.rs +++ b/crates/console/prometeu-vm/src/virtual_machine.rs @@ -89,7 +89,16 @@ pub struct VirtualMachine { /// Cooperative scheduler: set to true when `YIELD` opcode is executed. /// The runtime/scheduler should only act on this at safepoints (FRAME_SYNC). pub yield_requested: bool, - /// If set, the current coroutine requested to sleep until this tick (inclusive). + /// Absolute wake tick requested by the currently running coroutine (when it executes `SLEEP`). + /// + /// Canonical rule (authoritative): + /// - `SLEEP N` suspends the coroutine for exactly N full scheduler ticks AFTER the current + /// `FRAME_SYNC` completes. If `SLEEP` is executed during tick `T`, the coroutine must resume + /// in the frame whose end-of-frame tick will be `T + N + 1`. + /// - Implementation detail: we compute `wake_tick = current_tick + duration + 1` at the time + /// `SLEEP` executes. The scheduler wakes sleeping coroutines when `current_tick >= wake_tick`. + /// + /// This definition is deterministic and eliminates off-by-one ambiguity. pub sleep_requested_until: Option, /// Logical tick counter advanced at each FRAME_SYNC boundary. pub current_tick: u64, @@ -261,14 +270,22 @@ impl VirtualMachine { func_idx, }); - // Initialize the main coroutine to own the current execution context. - // State = Running; not enqueued into ready queue. + // Initialize the main coroutine object. + // IMPORTANT INVARIANT: + // - The RUNNING coroutine's authoritative execution state lives in the VM fields + // (pc, operand_stack, call_stack). + // - The heap-side coroutine object is authoritative ONLY when the coroutine is suspended + // (Ready/Sleeping/Finished). While running, its `stack`/`frames` should be empty. + // + // Therefore we do NOT clone the VM stacks into the heap here. We create the main + // coroutine object with empty stack/frames and mark it as Running, and the VM already + // holds the live execution context initialized above. let main_href = self.heap.allocate_coroutine( self.pc, CoroutineState::Running, 0, - self.operand_stack.clone(), - self.call_stack.clone(), + Vec::new(), + Vec::new(), ); self.current_coro = Some(main_href); self.scheduler.set_current(self.current_coro); @@ -524,11 +541,22 @@ impl VirtualMachine { // Do not end the slice here; we continue executing until a safepoint. } OpCode::Sleep => { - // Immediate is duration in ticks + // Immediate is duration in ticks. + // + // Canonical semantics: + // SLEEP N => suspend for exactly N full scheduler ticks AFTER the current + // FRAME_SYNC completes. If executed at tick T, resume in the frame whose + // end-of-frame tick will be T + N + 1. + // + // Implementation rule: + // wake_tick = current_tick + duration + 1 let duration = instr .imm_u32() .map_err(|e| LogicalFrameEndingReason::Panic(format!("{:?}", e)))? as u64; - let wake = self.current_tick.saturating_add(duration); + let wake = self + .current_tick + .saturating_add(duration) + .saturating_add(1); self.sleep_requested_until = Some(wake); // End the logical frame right after the instruction completes @@ -1521,7 +1549,7 @@ mod tests { assert!(vm.operand_stack.is_empty()); assert_eq!(vm.current_tick, 1); - // Frame 2: still sleeping (tick 1 < wake 2), immediate FrameSync, tick -> 2 + // Frame 2: still sleeping (tick 1 < wake 3), immediate FrameSync, tick -> 2 let rep2 = vm.run_budget(100, &mut native, &mut ctx).expect("run ok"); assert!(matches!(rep2.reason, LogicalFrameEndingReason::FrameSync)); // In the per-coroutine model, the VM may keep current context intact across idle frames; @@ -1529,9 +1557,15 @@ mod tests { assert_eq!(vm.operand_stack.len(), 0); assert_eq!(vm.current_tick, 2); - // Frame 3: wake condition met (current_tick >= wake), execute PUSH_I32 then FRAME_SYNC + // Frame 3: still sleeping (tick 2 < wake 3), immediate FrameSync, tick -> 3 let rep3 = vm.run_budget(100, &mut native, &mut ctx).expect("run ok"); assert!(matches!(rep3.reason, LogicalFrameEndingReason::FrameSync)); + assert_eq!(vm.operand_stack.len(), 0); + assert_eq!(vm.current_tick, 3); + + // Frame 4: wake condition met (current_tick >= wake), execute PUSH_I32 then FRAME_SYNC + let rep4 = vm.run_budget(100, &mut native, &mut ctx).expect("run ok"); + assert!(matches!(rep4.reason, LogicalFrameEndingReason::FrameSync)); // Value should now be on the stack assert_eq!(vm.peek().unwrap(), &Value::Int32(123)); @@ -1636,12 +1670,18 @@ mod tests { ticks_a.push((vm_a.pc, vm_a.current_tick, format!("{:?}", ra2.reason))); ticks_b.push((vm_b.pc, vm_b.current_tick, format!("{:?}", rb2.reason))); - // Slice 3 (wakes and pushes) + // Slice 3 let ra3 = vm_a.run_budget(100, &mut native, &mut ctx_a).unwrap(); let rb3 = vm_b.run_budget(100, &mut native, &mut ctx_b).unwrap(); ticks_a.push((vm_a.pc, vm_a.current_tick, format!("{:?}", ra3.reason))); ticks_b.push((vm_b.pc, vm_b.current_tick, format!("{:?}", rb3.reason))); + // Slice 4 (wakes and pushes) + let ra4 = vm_a.run_budget(100, &mut native, &mut ctx_a).unwrap(); + let rb4 = vm_b.run_budget(100, &mut native, &mut ctx_b).unwrap(); + ticks_a.push((vm_a.pc, vm_a.current_tick, format!("{:?}", ra4.reason))); + ticks_b.push((vm_b.pc, vm_b.current_tick, format!("{:?}", rb4.reason))); + assert_eq!(ticks_a, ticks_b, "Sleep/wake slices must match across runs"); assert_eq!(vm_a.peek().unwrap(), &Value::Int32(7)); assert_eq!(vm_b.peek().unwrap(), &Value::Int32(7)); @@ -3374,6 +3414,370 @@ mod tests { assert_eq!(vm.heap.len(), 1, "only main coroutine should remain"); } + #[test] + fn test_coroutines_strict_alternation_with_yield() { + use prometeu_bytecode::FunctionMeta; + + // Build function A: PUSH 1; YIELD; FRAME_SYNC; JMP 0 (loop) + let mut fn_a = Vec::new(); + fn_a.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_a.extend_from_slice(&1i32.to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Jmp as u16).to_le_bytes()); + fn_a.extend_from_slice(&0u32.to_le_bytes()); + + // Build function B: PUSH 2; YIELD; FRAME_SYNC; JMP 0 (loop) + let mut fn_b = Vec::new(); + fn_b.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_b.extend_from_slice(&2i32.to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Jmp as u16).to_le_bytes()); + fn_b.extend_from_slice(&0u32.to_le_bytes()); + + // Main: SPAWN A; SPAWN B; SLEEP 100; HALT + let mut main = Vec::new(); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&1u32.to_le_bytes()); // fn A idx + main.extend_from_slice(&0u32.to_le_bytes()); // arg count + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&2u32.to_le_bytes()); // fn B idx + main.extend_from_slice(&0u32.to_le_bytes()); // arg count + main.extend_from_slice(&(OpCode::Sleep as u16).to_le_bytes()); + main.extend_from_slice(&100u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + // Assemble ROM: [main][A][B] + let off_main = 0usize; + let off_a = main.len(); + let off_b = off_a + fn_a.len(); + let mut rom = Vec::with_capacity(main.len() + fn_a.len() + fn_b.len()); + rom.extend_from_slice(&main); + rom.extend_from_slice(&fn_a); + rom.extend_from_slice(&fn_b); + + // VM with three functions (0=main, 1=A, 2=B) + let mut vm = new_test_vm(rom.clone(), vec![]); + vm.program.functions = std::sync::Arc::from(vec![ + FunctionMeta { code_offset: off_main as u32, code_len: main.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_a as u32, code_len: fn_a.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_b as u32, code_len: fn_b.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + ]); + + let mut native = MockNative; + let mut ctx = HostContext::new(None); + + // Frame 1: main sleeps; from now on A and B should strictly alternate. + let _ = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + + // Locate coroutine handles for A (fn_idx=1) and B (fn_idx=2) + let mut a_href = None; + let mut b_href = None; + // Consider currently running coroutine + if let Some(cur) = vm.current_coro { + if let Some(f) = vm.call_stack.last() { + if f.func_idx == 1 { a_href = Some(cur); } + if f.func_idx == 2 { b_href = Some(cur); } + } + } + // And also consider suspended (Ready/Sleeping) coroutines + for h in vm.heap.suspended_coroutine_handles() { + if let Some(co) = vm.heap.coroutine_data(h) { + if let Some(f) = co.frames.last() { + if f.func_idx == 1 { a_href = Some(h); } + if f.func_idx == 2 { b_href = Some(h); } + } + } + } + let a_href = a_href.expect("coroutine A not found"); + let b_href = b_href.expect("coroutine B not found"); + + let mut prev_a = vm.heap.coroutine_data(a_href).unwrap().stack.len(); + let mut prev_b = vm.heap.coroutine_data(b_href).unwrap().stack.len(); + + let mut trace = Vec::new(); + for _ in 0..6 { + let _ = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + let a_now = vm.heap.coroutine_data(a_href).unwrap().stack.len(); + let b_now = vm.heap.coroutine_data(b_href).unwrap().stack.len(); + if a_now > prev_a { trace.push(1); } + else if b_now > prev_b { trace.push(2); } + else { panic!("no coroutine progress detected this frame"); } + prev_a = a_now; prev_b = b_now; + } + + assert_eq!(trace, vec![1, 2, 1, 2, 1, 2], "Coroutines must strictly alternate under Yield"); + } + + #[test] + fn test_sleep_does_not_stall_others_and_wakes_at_exact_tick() { + use prometeu_bytecode::FunctionMeta; + + // Function A: SLEEP N; PUSH 100; YIELD; FRAME_SYNC; HALT + let sleep_n: u32 = 3; + let mut fn_a = Vec::new(); + fn_a.extend_from_slice(&(OpCode::Sleep as u16).to_le_bytes()); + fn_a.extend_from_slice(&sleep_n.to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_a.extend_from_slice(&100i32.to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + // Function B: PUSH 1; YIELD; FRAME_SYNC; JMP 0 (increments every frame) + let mut fn_b = Vec::new(); + fn_b.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_b.extend_from_slice(&1i32.to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Jmp as u16).to_le_bytes()); + fn_b.extend_from_slice(&0u32.to_le_bytes()); + + // Main: SPAWN A; SPAWN B; SLEEP big; HALT + let mut main = Vec::new(); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&1u32.to_le_bytes()); + main.extend_from_slice(&0u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&2u32.to_le_bytes()); + main.extend_from_slice(&0u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Sleep as u16).to_le_bytes()); + main.extend_from_slice(&100u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + let off_main = 0usize; + let off_a = main.len(); + let off_b = off_a + fn_a.len(); + let mut rom = Vec::new(); + rom.extend_from_slice(&main); + rom.extend_from_slice(&fn_a); + rom.extend_from_slice(&fn_b); + + let mut vm = new_test_vm(rom.clone(), vec![]); + vm.program.functions = std::sync::Arc::from(vec![ + FunctionMeta { code_offset: off_main as u32, code_len: main.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_a as u32, code_len: fn_a.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_b as u32, code_len: fn_b.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + ]); + + let mut native = MockNative; + let mut ctx = HostContext::new(None); + + // Frame 1: main sleeps, tick -> 1 + let _ = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + assert_eq!(vm.current_tick, 1); + + // Identify A and B coroutine handles (consider both running and suspended) + let mut a_href = None; + let mut b_href = None; + if let Some(cur) = vm.current_coro { + if let Some(f) = vm.call_stack.last() { + if f.func_idx == 1 { a_href = Some(cur); } + if f.func_idx == 2 { b_href = Some(cur); } + } + } + for h in vm.heap.suspended_coroutine_handles() { + if let Some(co) = vm.heap.coroutine_data(h) { + if let Some(f) = co.frames.last() { + if f.func_idx == 1 { a_href = Some(h); } + if f.func_idx == 2 { b_href = Some(h); } + } + } + } + let a_href = a_href.expect("A not found"); + let b_href = b_href.expect("B not found"); + // Count how many frames B runs while A sleeps using the scheduler's next-to-run handle. + // Stop when A is scheduled to run, then execute that frame and record its end-of-frame tick. + let mut ones_before = 0usize; + let mut woke_at_tick = 0u64; + let mut seen_a_once = false; + for _ in 0..1000 { + if let Some(next) = vm.scheduler.current() { + if next == a_href { + if seen_a_once { + // A has slept before and is about to run again (wake). Run and record. + let _ = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + woke_at_tick = vm.current_tick; + break; + } else { + // First time A runs (to execute SLEEP N); do not count as wake yet. + seen_a_once = true; + } + } else if next == b_href { + ones_before += 1; + } + } + let _ = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + } + + // Canonical semantics: wake_tick = current_tick_at_sleep + N + 1. + // The scheduler wakes sleepers at the end of that tick, so the coroutine runs + // in the following frame, and we observe its heap stack update at end tick = wake_tick + 1. + // A executes SLEEP at its first run (tick 1), so wake_tick = 1 + N + 1, observed tick = +1. + let expected_observed_end_tick = 1u64 + sleep_n as u64 + 2u64; + assert_eq!(woke_at_tick, expected_observed_end_tick, "A must wake at the exact tick (+1 frame to observe)"); + // And B must have produced at least N items (one per frame) before A's wake. + assert!(ones_before as u64 >= sleep_n as u64, "B must keep running while A sleeps"); + } + + #[test] + fn test_multi_coroutine_determinism_across_runs() { + use prometeu_bytecode::FunctionMeta; + + // Reuse alternation program from previous test + let mut fn_a = Vec::new(); + fn_a.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_a.extend_from_slice(&1i32.to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_a.extend_from_slice(&(OpCode::Jmp as u16).to_le_bytes()); + fn_a.extend_from_slice(&0u32.to_le_bytes()); + + let mut fn_b = Vec::new(); + fn_b.extend_from_slice(&(OpCode::PushI32 as u16).to_le_bytes()); + fn_b.extend_from_slice(&2i32.to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Yield as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_b.extend_from_slice(&(OpCode::Jmp as u16).to_le_bytes()); + fn_b.extend_from_slice(&0u32.to_le_bytes()); + + let mut main = Vec::new(); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&1u32.to_le_bytes()); + main.extend_from_slice(&0u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&2u32.to_le_bytes()); + main.extend_from_slice(&0u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Sleep as u16).to_le_bytes()); + main.extend_from_slice(&100u32.to_le_bytes()); + main.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + let off_main = 0usize; + let off_a = main.len(); + let off_b = off_a + fn_a.len(); + let mut rom = Vec::new(); + rom.extend_from_slice(&main); + rom.extend_from_slice(&fn_a); + rom.extend_from_slice(&fn_b); + + let mut vm1 = new_test_vm(rom.clone(), vec![]); + let mut vm2 = new_test_vm(rom.clone(), vec![]); + let fm: std::sync::Arc<[prometeu_bytecode::FunctionMeta]> = std::sync::Arc::from(vec![ + FunctionMeta { code_offset: off_main as u32, code_len: main.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_a as u32, code_len: fn_a.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + FunctionMeta { code_offset: off_b as u32, code_len: fn_b.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + ]); + vm1.program.functions = fm.clone(); + vm2.program.functions = fm; + + let mut native = MockNative; + let mut c1 = HostContext::new(None); + let mut c2 = HostContext::new(None); + + // Burn first frame (main sleeps) + let _ = vm1.run_budget(100, &mut native, &mut c1).unwrap(); + let _ = vm2.run_budget(100, &mut native, &mut c2).unwrap(); + + // Discover A/B handles in both VMs + let find_ab = |vm: &VirtualMachine| { + let mut a = None; let mut b = None; + // running + if let Some(cur) = vm.current_coro { + if let Some(f) = vm.call_stack.last() { + if f.func_idx == 1 { a = Some(cur); } + if f.func_idx == 2 { b = Some(cur); } + } + } + // suspended + for h in vm.heap.suspended_coroutine_handles() { + if let Some(co) = vm.heap.coroutine_data(h) { + if let Some(f) = co.frames.last() { + if f.func_idx == 1 { a = Some(h); } + if f.func_idx == 2 { b = Some(h); } + } + } + } + (a.expect("A missing"), b.expect("B missing")) + }; + let (a1, b1) = find_ab(&vm1); + let (a2, b2) = find_ab(&vm2); + + let mut a1_prev = vm1.heap.coroutine_data(a1).unwrap().stack.len(); + let mut b1_prev = vm1.heap.coroutine_data(b1).unwrap().stack.len(); + let mut a2_prev = vm2.heap.coroutine_data(a2).unwrap().stack.len(); + let mut b2_prev = vm2.heap.coroutine_data(b2).unwrap().stack.len(); + + let mut trace1 = Vec::new(); + let mut trace2 = Vec::new(); + for _ in 0..8 { + let _ = vm1.run_budget(100, &mut native, &mut c1).unwrap(); + let a_now = vm1.heap.coroutine_data(a1).unwrap().stack.len(); + let b_now = vm1.heap.coroutine_data(b1).unwrap().stack.len(); + if a_now > a1_prev { trace1.push(1); } else if b_now > b1_prev { trace1.push(2); } else { panic!("no progress 1"); } + a1_prev = a_now; b1_prev = b_now; + + let _ = vm2.run_budget(100, &mut native, &mut c2).unwrap(); + let a2_now = vm2.heap.coroutine_data(a2).unwrap().stack.len(); + let b2_now = vm2.heap.coroutine_data(b2).unwrap().stack.len(); + if a2_now > a2_prev { trace2.push(1); } else if b2_now > b2_prev { trace2.push(2); } else { panic!("no progress 2"); } + a2_prev = a2_now; b2_prev = b2_now; + } + + assert_eq!(trace1, trace2, "Execution trace (coroutine IDs) must match exactly across runs"); + } + + #[test] + fn test_gc_with_suspended_coroutine_runtime() { + use crate::object::ObjectKind; + use prometeu_bytecode::FunctionMeta; + + // Function F (idx 1): SLEEP 10; FRAME_SYNC; HALT + let mut fn_f = Vec::new(); + fn_f.extend_from_slice(&(OpCode::Sleep as u16).to_le_bytes()); + fn_f.extend_from_slice(&10u32.to_le_bytes()); + fn_f.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + fn_f.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + // Main (idx 0): SPAWN F with 1 argument (the HeapRef we preload); FRAME_SYNC; HALT + let mut main = Vec::new(); + main.extend_from_slice(&(OpCode::Spawn as u16).to_le_bytes()); + main.extend_from_slice(&1u32.to_le_bytes()); // func idx + main.extend_from_slice(&1u32.to_le_bytes()); // arg count = 1 + main.extend_from_slice(&(OpCode::FrameSync as u16).to_le_bytes()); + main.extend_from_slice(&(OpCode::Halt as u16).to_le_bytes()); + + let off_main = 0usize; + let off_f = main.len(); + let mut rom = Vec::new(); + rom.extend_from_slice(&main); + rom.extend_from_slice(&fn_f); + + let mut vm = new_test_vm(rom.clone(), vec![]); + vm.program.functions = std::sync::Arc::from(vec![ + FunctionMeta { code_offset: off_main as u32, code_len: main.len() as u32, param_slots: 0, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + // Function F takes 1 parameter (the HeapRef) which stays on its stack while sleeping + FunctionMeta { code_offset: off_f as u32, code_len: fn_f.len() as u32, param_slots: 1, local_slots: 0, return_slots: 0, max_stack_slots: 8 }, + ]); + + // Force GC at first safepoint to stress retention + vm.gc_alloc_threshold = 1; + + // Allocate a heap object and preload it onto main's operand stack so SPAWN consumes it as arg. + let captured = vm.heap.allocate_object(ObjectKind::Bytes, &[0xAB, 0xCD, 0xEF]); + vm.operand_stack.push(Value::HeapRef(captured)); + + let mut native = MockNative; + let mut ctx = HostContext::new(None); + + // Run main: SPAWN consumes the HeapRef as arg and creates sleeping coroutine; FRAME_SYNC triggers GC + let rep = vm.run_budget(100, &mut native, &mut ctx).unwrap(); + assert!(matches!(rep.reason, LogicalFrameEndingReason::FrameSync)); + + // The captured object must remain alive because it is referenced by the sleeping coroutine's stack + assert!(vm.heap.is_valid(captured), "captured object must remain alive while coroutine sleeps"); + } + #[test] fn test_make_closure_zero_captures() { use prometeu_bytecode::{FunctionMeta, Value};