From 6f6c82e5aa5dd3b0cbb0bde92df1eb68f707bc94 Mon Sep 17 00:00:00 2001 From: fffonion Date: Thu, 27 Aug 2026 07:18:03 +0800 Subject: [PATCH 1/2] refactor(vm): split runtime state from VM facade --- src/vm/aot/artifact.rs | 23 +- src/vm/aot/compile.rs | 2 +- src/vm/aot/runtime.rs | 61 +- src/vm/engine.rs | 141 +++++ src/vm/epoch.rs | 86 ++- src/vm/fuel.rs | 79 +-- src/vm/host.rs | 361 ++++++------ src/vm/host_runtime.rs | 59 ++ src/vm/instance.rs | 270 +++++++++ src/vm/jit/diagnostics.rs | 30 +- src/vm/jit/runtime.rs | 416 ++++++++------ src/vm/mod.rs | 1051 +++++++++++++++-------------------- src/vm/native/bridge.rs | 224 ++++---- src/vm/native/layout.rs | 26 +- src/vm/program.rs | 22 + src/vm/run_context.rs | 174 ++++++ src/vm/superinstructions.rs | 20 +- src/vm/tests.rs | 167 +++--- 18 files changed, 1913 insertions(+), 1299 deletions(-) create mode 100644 src/vm/engine.rs create mode 100644 src/vm/host_runtime.rs create mode 100644 src/vm/instance.rs create mode 100644 src/vm/program.rs create mode 100644 src/vm/run_context.rs diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 37e31f83..0703828f 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -108,11 +108,12 @@ impl From for AotArtifactError { impl Vm { pub fn encode_aot_artifact(&mut self) -> Result, AotArtifactError> { - if self.aot_program.is_none() { + if self.engine.aot_program.is_none() { self.compile_aot()?; } let program_hash = self.ensure_program_cache_key(); let aot_program = self + .engine .aot_program .as_ref() .ok_or(AotArtifactError::MissingAotProgram)?; @@ -135,8 +136,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - self.aot_program = Some(compiled); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compiled); + self.engine.aot_exec_count = 0; Ok(()) } @@ -159,8 +160,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - vm.aot_program = Some(compiled); - vm.aot_exec_count = 0; + vm.engine.aot_program = Some(compiled); + vm.engine.aot_exec_count = 0; Ok(vm) } @@ -201,7 +202,11 @@ fn encode_artifact( write_string("os", std::env::consts::OS, &mut out)?; write_string("backend", selected_codegen_backend(), &mut out)?; - write_u32("vm ip offset", std::mem::offset_of!(Vm, ip), &mut out)?; + write_u32( + "vm ip offset", + std::mem::offset_of!(Vm, instance.ip), + &mut out, + )?; write_u32( "native helper offset", helper_entry_offset() as usize, @@ -283,7 +288,7 @@ fn decode_artifact( )?; validate_runtime_field( "vm ip offset", - std::mem::offset_of!(Vm, ip).to_string(), + std::mem::offset_of!(Vm, instance.ip).to_string(), cursor.read_u32()?.to_string(), )?; validate_runtime_field( @@ -446,7 +451,8 @@ mod tests { bc.ret(); let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); vm.compile_aot().expect("aot compile should succeed"); - vm.aot_program + vm.engine + .aot_program .as_mut() .expect("compiled program") .interpreter_boundary_only = true; @@ -468,6 +474,7 @@ mod tests { .expect("boundary artifact should load"); assert!( standalone + .engine .aot_program .as_ref() .expect("loaded aot program") diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index c8896093..96718ba7 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -566,7 +566,7 @@ fn compile_ssa( let ctx_setup_elapsed = ctx_setup_started.elapsed(); let vm_ip_offset = - i32::try_from(std::mem::offset_of!(Vm, ip)).expect("Vm::ip offset must fit i32"); + i32::try_from(std::mem::offset_of!(Vm, instance.ip)).expect("Vm::ip offset must fit i32"); let code_len_i64 = i64::try_from(program.code.len()) .map_err(|_| AotCompileError::Codegen("program length does not fit i64".to_string()))?; diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index 13029006..d4e6fed5 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -8,32 +8,33 @@ use crate::vm::{ExecOutcome, Vm, VmError, VmResult}; impl Vm { pub fn compile_aot(&mut self) -> VmResult<()> { - self.aot_program = Some(compile_program(self.program())?); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compile_program(self.program())?); + self.engine.aot_exec_count = 0; Ok(()) } pub fn clear_aot(&mut self) { - self.aot_program = None; - self.aot_exec_count = 0; + self.engine.aot_program = None; + self.engine.aot_exec_count = 0; } pub fn has_aot_program(&self) -> bool { - self.aot_program.is_some() + self.engine.aot_program.is_some() } pub fn aot_exec_count(&self) -> u64 { - self.aot_exec_count + self.engine.aot_exec_count } pub fn aot_resume_ips(&self) -> Option<&[usize]> { - self.aot_program + self.engine + .aot_program .as_ref() .map(|program| program.resume_ips.as_ref()) } pub fn dump_aot_info(&self) -> String { - let Some(program) = self.aot_program.as_ref() else { + let Some(program) = self.engine.aot_program.as_ref() else { return "whole-program aot: disabled\n".to_string(); }; @@ -43,7 +44,10 @@ impl Vm { " native codegen backend: {}\n", selected_codegen_backend() )); - out.push_str(&format!(" aot executions: {}\n", self.aot_exec_count)); + out.push_str(&format!( + " aot executions: {}\n", + self.engine.aot_exec_count + )); out.push_str(&format!(" code_bytes={}\n", program.code.len())); out.push_str(&format!( " lowering={}\n", @@ -58,38 +62,47 @@ impl Vm { } pub(crate) fn execute_aot_entry(&mut self) -> VmResult { - let Some(entry) = self.aot_program.as_ref().map(|program| program.entry) else { + let Some(entry) = self + .engine + .aot_program + .as_ref() + .map(|program| program.entry) + else { return Ok(ExecOutcome::Continue); }; clear_bridge_error(); unsafe { crate::vm::native::prepare_for_execution() }; let status = unsafe { entry(self as *mut Vm) }; - self.aot_exec_count = self.aot_exec_count.saturating_add(1); + self.engine.aot_exec_count = self.engine.aot_exec_count.saturating_add(1); match status { STATUS_CONTINUE | STATUS_LINKED_CONTINUE => Ok(ExecOutcome::Continue), STATUS_HALTED => Ok(ExecOutcome::Halted), STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); Ok(ExecOutcome::Yielded) } STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "aot call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "aot call bridge reported waiting without a pending op".to_string(), + ) + })?; Ok(ExecOutcome::Waiting(op_id)) } - STATUS_OUT_OF_FUEL => match self.interrupt_mode { + STATUS_OUT_OF_FUEL => match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { needed: 1, - remaining: self.fuel_remaining, + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "aot interruption checkpoint fired while interruption was disabled".to_string(), @@ -99,18 +112,18 @@ impl Vm { if let Some(err) = take_bridge_error() { return Err(err); } - if self.ip == self.program.code.len() { + if self.instance.ip == self.program.code.len() { return Err(VmError::BytecodeBounds); } Err(VmError::JitNative(format!( "aot entry reported failure without VmError (ip={} stack_len={} aot={})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), self.has_aot_program() ))) } STATUS_TRACE_EXIT => { - self.aot_interpreter_boundary_hit = true; + self.engine.aot_interpreter_boundary_hit = true; Ok(ExecOutcome::Continue) } other => Err(VmError::JitNative(format!( diff --git a/src/vm/engine.rs b/src/vm/engine.rs new file mode 100644 index 00000000..33acefe9 --- /dev/null +++ b/src/vm/engine.rs @@ -0,0 +1,141 @@ +//! Backend engine state. +//! +//! [`Engine`] owns the code-generation backends and their caches: the trace +//! JIT engine, native traces and their counters, the optional AOT program, +//! the regex cache, program-derived decode caches, and code-generation +//! telemetry. It holds no per-run interpreter state and no host bindings, so +//! it can be shared across runs (and, by construction, reused by any number of +//! instances that never share stacks or resources). +//! +//! Native ABI note: the JIT/AOT code generators read a handful of fields by +//! machine offset through `std::mem::offset_of!(Vm, engine.)`. The +//! field set and the offsets are part of the native ABI; see +//! `crate::vm::native::layout`. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::builtins::runtime::regex::RegexCache; +use crate::bytecode::{DecodedInstructionData, Program}; +use crate::vm::aot; +use crate::vm::jit; +use crate::vm::native; + +/// Engine-owned backend configuration, caches, and code-generation telemetry. +/// +/// Thread safety: `Engine` is not shared between threads (`TraceJitEngine` is +/// not `Sync`); one VM facade owns one engine. Clone semantics: `Engine` is +/// intentionally not `Clone` — duplicating it would duplicate native traces +/// and JIT bookkeeping that are keyed to one execution identity. +pub(crate) struct Engine { + pub(crate) jit: jit::TraceJitEngine, + pub(crate) native_traces: Vec>, + pub(crate) native_trace_exec_count: u64, + pub(crate) aot_program: Option, + pub(crate) aot_exec_count: u64, + pub(crate) aot_interpreter_boundary_hit: bool, + pub(crate) jit_native_region_entry_count: u64, + pub(crate) jit_native_region_edge_count: u64, + pub(crate) jit_native_direct_link_count: u64, + pub(crate) jit_native_direct_links_enabled: bool, + pub(crate) jit_native_direct_cross_frame_enabled: bool, + pub(crate) jit_native_active_direct_trace_id: usize, + pub(crate) jit_native_direct_escape_streak: u16, + pub(crate) jit_native_direct_region_fallback: bool, + pub(crate) jit_native_compile_time_ns: u64, + pub(crate) jit_native_region_compile_time_ns: u64, + pub(crate) jit_trace_exit_count: u64, + pub(crate) jit_native_loop_back_count: u64, + pub(crate) jit_native_link_handoff_count: u64, + pub(crate) jit_native_link_dispatch_depth: u32, + pub(crate) jit_helper_fallback_count: u64, + pub(crate) jit_native_bridge_stats_enabled: bool, + pub(crate) jit_native_bridge_counts: HashMap<&'static str, u64>, + pub(crate) program_cache_key: u64, + pub(crate) program_cache_key_ready: bool, + pub(crate) regex_cache: RegexCache, + pub(crate) decoded_instruction_data: Arc, + pub(crate) operand_type_hints: Option>, + // Native ABI mirrors: the JIT/AOT code generators load these addresses by + // field offset from the `Vm` facade. They are derived from the program and + // from static helper entry points, and are documented as load-bearing for + // `crate::vm::native`. + pub(crate) program_constants_ptr: usize, + #[allow(dead_code)] + pub(crate) program_constants_len: usize, + #[allow(dead_code)] + pub(crate) native_helper_fn: usize, + #[allow(dead_code)] + pub(crate) native_interrupt_helper_fn: usize, +} + +impl Engine { + /// Builds an engine for one program and JIT configuration. + pub(crate) fn new(jit_config: jit::JitConfig, program: &Program) -> Self { + Self { + jit: jit::TraceJitEngine::new(jit_config), + native_traces: Vec::new(), + native_trace_exec_count: 0, + aot_program: None, + aot_exec_count: 0, + aot_interpreter_boundary_hit: false, + jit_native_region_entry_count: 0, + jit_native_region_edge_count: 0, + jit_native_direct_link_count: 0, + jit_native_direct_links_enabled: true, + jit_native_direct_cross_frame_enabled: false, + jit_native_active_direct_trace_id: usize::MAX, + jit_native_direct_escape_streak: 0, + jit_native_direct_region_fallback: false, + jit_native_compile_time_ns: 0, + jit_native_region_compile_time_ns: 0, + jit_trace_exit_count: 0, + jit_native_loop_back_count: 0, + jit_native_link_handoff_count: 0, + jit_native_link_dispatch_depth: 0, + jit_helper_fallback_count: 0, + jit_native_bridge_stats_enabled: false, + jit_native_bridge_counts: HashMap::new(), + program_cache_key: 0, + program_cache_key_ready: false, + regex_cache: RegexCache::default(), + decoded_instruction_data: program.shared_decoded_instruction_data(), + operand_type_hints: program.shared_operand_type_hints(), + program_constants_ptr: program.constants.as_ptr() as usize, + program_constants_len: program.constants.len(), + native_helper_fn: native::helper_entry_address(), + native_interrupt_helper_fn: native::interrupt_helper_entry_address(), + } + } + + /// Returns the program cache key, computing and caching it on first use. + /// The key identifies the program for backend cache lookups; it is stable + /// for the lifetime of the engine (the program is immutable). + pub(crate) fn ensure_program_cache_key(&mut self, program: &Program) -> u64 { + if !self.program_cache_key_ready { + self.program_cache_key = super::compute_program_cache_key(program); + self.program_cache_key_ready = true; + } + self.program_cache_key + } + + /// Rewinds run-scoped backend state between runs while retaining compiled + /// artifacts: hot-entry bookkeeping and call-site profiles are cleared, + /// and the AOT boundary flag is recomputed from the compiled program. + pub(crate) fn reset_runtime_state(&mut self, program: &Program) { + self.aot_interpreter_boundary_hit = self + .aot_program + .as_ref() + .is_some_and(|compiled| compiled.interpreter_boundary_only); + self.jit.reset_runtime_backoff(); + self.jit.clear_call_site_profiles(); + let _ = program; + } + + /// Invalidates code-generation caches that may reference run-scoped + /// behavior (used when drop-contract event accounting is toggled). + pub(crate) fn invalidate_codegen_caches(&mut self) { + self.native_traces.clear(); + self.native_trace_exec_count = 0; + } +} diff --git a/src/vm/epoch.rs b/src/vm/epoch.rs index 178a1202..4a5c9b67 100644 --- a/src/vm/epoch.rs +++ b/src/vm/epoch.rs @@ -57,57 +57,35 @@ impl EpochHandle { impl Vm { #[inline(always)] pub(in crate::vm) fn charge_epoch_tick(&mut self) -> VmResult<()> { - if !self.epoch_interruption_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let current = self.current_epoch(); - if current >= self.epoch_deadline { - return Err(VmError::EpochDeadlineReached { - current, - deadline: self.epoch_deadline, - }); - } - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_epoch_tick() } #[inline(always)] pub(super) fn mark_interrupt_yield(&mut self, reason: VmYieldReason) { - self.last_yield_reason = Some(reason); + self.instance.last_yield_reason = Some(reason); if matches!(reason, VmYieldReason::Epoch) { - self.epoch_rearm_pending = true; + self.run_ctx.epoch_rearm_pending = true; } } #[inline(always)] pub(super) fn rearm_epoch_after_yield_if_needed(&mut self) { - if !self.epoch_rearm_pending { + if !self.run_ctx.epoch_rearm_pending { return; } if !self.epoch_interruption_enabled() { - self.epoch_rearm_pending = false; + self.run_ctx.epoch_rearm_pending = false; return; } - self.epoch_deadline = self + self.run_ctx.epoch_deadline = self .current_epoch() - .saturating_add(self.epoch_deadline_delta); - self.epoch_rearm_pending = false; + .saturating_add(self.run_ctx.epoch_deadline_delta); + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); } pub(super) fn clear_epoch_deadline_internal(&mut self) { - if self.epoch_interruption_enabled() { - self.interrupt_mode = InterruptMode::None; - } - self.epoch_deadline = 0; - self.epoch_deadline_delta = 0; - self.epoch_rearm_pending = false; - self.reset_interrupt_countdown(); + self.run_ctx.clear_epoch_deadline_internal(); } pub fn consume_epoch_tick(&mut self) -> VmResult<()> { @@ -118,29 +96,29 @@ impl Vm { } pub fn epoch_handle(&self) -> EpochHandle { - self.epoch_handle.clone() + self.run_ctx.epoch_handle.clone() } pub fn current_epoch(&self) -> u64 { - self.epoch_handle.current() + self.run_ctx.epoch_handle.current() } pub fn increment_epoch(&self) -> u64 { - self.epoch_handle.increment() + self.run_ctx.epoch_handle.increment() } pub fn increment_epoch_by(&self, delta: u64) -> u64 { - self.epoch_handle.increment_by(delta) + self.run_ctx.epoch_handle.increment_by(delta) } pub fn set_epoch_deadline(&mut self, ticks_beyond_current: u64) -> VmResult<()> { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.interrupt_mode = InterruptMode::Epoch; - self.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); - self.epoch_deadline_delta = ticks_beyond_current; - self.epoch_rearm_pending = false; + self.run_ctx.interrupt_mode = InterruptMode::Epoch; + self.run_ctx.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); + self.run_ctx.epoch_deadline_delta = ticks_beyond_current; + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); Ok(()) } @@ -151,12 +129,12 @@ impl Vm { pub fn epoch_deadline(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline) + .then_some(self.run_ctx.epoch_deadline) } pub fn epoch_deadline_delta(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline_delta) + .then_some(self.run_ctx.epoch_deadline_delta) } pub fn set_epoch_check_interval(&mut self, interval: u32) -> VmResult<()> { @@ -166,7 +144,7 @@ impl Vm { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.fuel_check_interval = interval; + self.run_ctx.fuel_check_interval = interval; self.reset_interrupt_countdown(); Ok(()) } @@ -179,31 +157,31 @@ impl Vm { EpochCheckpoint { deadline: self .epoch_interruption_enabled() - .then_some(self.epoch_deadline), - deadline_delta: self.epoch_deadline_delta, - rearm_pending: self.epoch_rearm_pending, + .then_some(self.run_ctx.epoch_deadline), + deadline_delta: self.run_ctx.epoch_deadline_delta, + rearm_pending: self.run_ctx.epoch_rearm_pending, check_interval: self.epoch_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } pub fn restore_epoch(&mut self, checkpoint: EpochCheckpoint) { self.clear_fuel_internal(); - self.interrupt_mode = if checkpoint.deadline.is_some() { + self.run_ctx.interrupt_mode = if checkpoint.deadline.is_some() { InterruptMode::Epoch } else { InterruptMode::None }; - self.epoch_deadline = checkpoint.deadline.unwrap_or(0); - self.epoch_deadline_delta = checkpoint.deadline_delta; - self.epoch_rearm_pending = checkpoint.rearm_pending; - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.epoch_deadline = checkpoint.deadline.unwrap_or(0); + self.run_ctx.epoch_deadline_delta = checkpoint.deadline_delta; + self.run_ctx.epoch_rearm_pending = checkpoint.rearm_pending; + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn last_yield_reason(&self) -> Option { - self.last_yield_reason + self.instance.last_yield_reason } } diff --git a/src/vm/fuel.rs b/src/vm/fuel.rs index f7a3e9d1..7cf073d3 100644 --- a/src/vm/fuel.rs +++ b/src/vm/fuel.rs @@ -19,60 +19,27 @@ impl FuelCheckpoint { impl Vm { pub(super) fn pending_fuel_debt(&self) -> u64 { - if !self.fuel_metering_enabled() { - return 0; - } - let executed_since_last_check = self - .fuel_check_interval - .saturating_sub(self.fuel_ops_until_check); - u64::from(executed_since_last_check) + self.run_ctx.pending_fuel_debt() } #[inline(always)] pub(in crate::vm) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { - if amount == 0 || !self.fuel_metering_enabled() { - return Ok(()); - } - - let remaining = self.fuel_remaining; - if remaining < amount { - return Err(VmError::OutOfFuel { - needed: amount, - remaining, - }); - } - self.fuel_remaining = remaining - amount; - Ok(()) + self.run_ctx.charge_fuel(amount) } #[inline(always)] pub(in crate::vm) fn charge_fuel_tick(&mut self) -> VmResult<()> { - if !self.fuel_metering_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let amount = u64::from(self.fuel_check_interval); - self.charge_fuel(amount)?; - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_fuel_tick() } pub(super) fn clear_fuel_internal(&mut self) { - if self.fuel_metering_enabled() { - self.interrupt_mode = InterruptMode::None; - } - self.fuel_remaining = 0; - self.reset_interrupt_countdown(); + self.run_ctx.clear_fuel_internal(); } pub fn set_fuel(&mut self, fuel: u64) { self.clear_epoch_deadline_internal(); - self.interrupt_mode = InterruptMode::Fuel; - self.fuel_remaining = fuel; + self.run_ctx.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.fuel_remaining = fuel; self.reset_interrupt_countdown(); } @@ -87,18 +54,21 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_check_interval = interval; + self.run_ctx.fuel_check_interval = interval; self.reset_interrupt_countdown(); Ok(()) } pub fn fuel_check_interval(&self) -> u32 { - self.fuel_check_interval + self.run_ctx.fuel_check_interval } pub fn get_fuel(&self) -> Option { - self.fuel_metering_enabled() - .then_some(self.fuel_remaining.saturating_sub(self.pending_fuel_debt())) + self.fuel_metering_enabled().then_some( + self.run_ctx + .fuel_remaining + .saturating_sub(self.pending_fuel_debt()), + ) } pub fn add_fuel(&mut self, fuel: u64) -> VmResult<()> { @@ -108,12 +78,13 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_remaining = if self.fuel_metering_enabled() { - self.fuel_remaining + self.run_ctx.fuel_remaining = if self.fuel_metering_enabled() { + self.run_ctx + .fuel_remaining .checked_add(fuel) .ok_or(VmError::FuelOverflow)? } else { - self.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.interrupt_mode = InterruptMode::Fuel; self.reset_interrupt_countdown(); fuel }; @@ -140,9 +111,11 @@ impl Vm { pub fn fuel_checkpoint(&self) -> FuelCheckpoint { FuelCheckpoint { - remaining: self.fuel_metering_enabled().then_some(self.fuel_remaining), + remaining: self + .fuel_metering_enabled() + .then_some(self.run_ctx.fuel_remaining), check_interval: self.fuel_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } @@ -152,16 +125,16 @@ impl Vm { pub fn restore_fuel(&mut self, checkpoint: FuelCheckpoint) { self.clear_epoch_deadline_internal(); - self.interrupt_mode = if checkpoint.remaining.is_some() { + self.run_ctx.interrupt_mode = if checkpoint.remaining.is_some() { InterruptMode::Fuel } else { InterruptMode::None }; - self.fuel_remaining = checkpoint.remaining.unwrap_or(0); - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.fuel_remaining = checkpoint.remaining.unwrap_or(0); + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn restore_checkpoint(&mut self, checkpoint: FuelCheckpoint) { diff --git a/src/vm/host.rs b/src/vm/host.rs index ecca479a..f73d42d4 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -419,13 +419,13 @@ impl HostFunctionRegistry { "host binding plan does not match vm import signature".to_string(), )); } - if !vm.host_functions.is_empty() || !vm.host_function_symbols.is_empty() { + if !vm.host.host_functions.is_empty() || !vm.host.host_function_symbols.is_empty() { return Err(VmError::HostError( "host binding cache requires an unbound vm".to_string(), )); } - vm.host_functions.reserve(plan.registry_slots.len()); + vm.host.host_functions.reserve(plan.registry_slots.len()); for ®istry_slot in &plan.registry_slots { let entry = self .entries @@ -562,48 +562,56 @@ fn builtin_for_binding_name(name: &str) -> Option { impl Vm { pub fn register_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Dynamic(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Dynamic(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_static_function(&mut self, function: StaticHostFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Static(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Static(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_stack_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_stack_function(&mut self, function: StaticHostStackFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_args_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_args_function(&mut self, function: StaticHostArgsFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } @@ -617,10 +625,11 @@ impl Vm { &mut self, function: StaticHostArgsFunction, ) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStaticNonYielding(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } @@ -630,17 +639,17 @@ impl Vm { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Dynamic(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Dynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_function(&mut self, name: impl Into, function: StaticHostFunction) { @@ -649,17 +658,17 @@ impl Vm { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Static(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Static(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_stack_function( @@ -668,17 +677,17 @@ impl Vm { function: Box, ) { let name = name.into(); - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_stack_function( @@ -694,17 +703,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_args_function( @@ -720,17 +729,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_args_function( @@ -746,17 +755,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } /// Binds a static args-only host function that always returns one value synchronously. @@ -778,17 +787,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStaticNonYielding(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_non_yielding_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_builtin_override( @@ -818,41 +827,43 @@ impl Vm { } fn bind_builtin_overrideslot(&mut self, builtin_call_index: u16, function: VmHostFunction) { - if let Some(&host_slot) = self.builtin_overrides.get(&builtin_call_index) - && let Some(slot) = self.host_functions.get_mut(host_slot as usize) + if let Some(&host_slot) = self.host.builtin_overrides.get(&builtin_call_index) + && let Some(slot) = self.host.host_functions.get_mut(host_slot as usize) { *slot = function; return; } - let host_slot = self.host_functions.len() as u16; - self.host_functions.push(function); - self.builtin_overrides.insert(builtin_call_index, host_slot); + let host_slot = self.host.host_functions.len() as u16; + self.host.host_functions.push(function); + self.host + .builtin_overrides + .insert(builtin_call_index, host_slot); } pub fn set_async_bridge(&mut self, bridge: Box) { self.cancel_waiting_host_op(); - self.async_bridge = Some(bridge); + self.host.async_bridge = Some(bridge); } pub fn clear_async_bridge(&mut self) { self.cancel_waiting_host_op(); - self.async_bridge = None; + self.host.async_bridge = None; } pub fn set_runtime_print_sink(&mut self, sink: F) where F: FnMut(String) + Send + 'static, { - self.runtime_print_sink = Some(Box::new(sink)); + self.host.runtime_print_sink = Some(Box::new(sink)); } pub fn clear_runtime_print_sink(&mut self) { - self.runtime_print_sink = None; + self.host.runtime_print_sink = None; } pub(crate) fn write_runtime_print(&mut self, rendered: String) -> VmResult<()> { - let Some(sink) = self.runtime_print_sink.as_mut() else { + let Some(sink) = self.host.runtime_print_sink.as_mut() else { return Err(VmError::HostError( "runtime print sink is not configured".to_string(), )); @@ -862,22 +873,22 @@ impl Vm { } pub fn allocate_host_op_id(&mut self) -> HostOpId { - let op_id = self.next_host_op_id; - self.next_host_op_id = self.next_host_op_id.wrapping_add(1).max(1); + let op_id = self.host.next_host_op_id; + self.host.next_host_op_id = self.host.next_host_op_id.wrapping_add(1).max(1); op_id } pub fn waiting_host_op_id(&self) -> Option { - self.waiting_host_op.map(|op| op.op_id) + self.instance.waiting_host_op.map(|op| op.op_id) } pub(super) fn cancel_waiting_host_op(&mut self) { - let Some(waiting) = self.waiting_host_op.take() else { + let Some(waiting) = self.instance.waiting_host_op.take() else { return; }; match waiting.source { WaitingHostOpSource::HostBridge => { - if let Some(bridge) = self.async_bridge.as_mut() { + if let Some(bridge) = self.host.async_bridge.as_mut() { bridge.cancel_op(waiting.op_id); } } @@ -896,13 +907,13 @@ impl Vm { } pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { - let Some(waiting) = self.waiting_host_op else { + let Some(waiting) = self.instance.waiting_host_op else { return Poll::Ready(Ok(())); }; let poll_result = match waiting.source { WaitingHostOpSource::HostBridge => { - let bridge_ptr = match self.async_bridge.as_mut() { + let bridge_ptr = match self.host.async_bridge.as_mut() { Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, None => { return Poll::Ready(Err(VmError::HostError(format!( @@ -926,7 +937,7 @@ impl Vm { Poll::Ready(Ok(())) } Poll::Ready(Err(err)) => { - self.waiting_host_op = None; + self.instance.waiting_host_op = None; Poll::Ready(Err(err)) } } @@ -973,7 +984,7 @@ impl Vm { got: argc_u8, }); } - if self.builtin_overrides.contains_key(&index) { + if self.host.builtin_overrides.contains_key(&index) { return self.execute_builtin_override_call(index, argc_u8, call_ip); } if let Some(outcome) = @@ -994,13 +1005,14 @@ impl Vm { .get(usize::from(index)) .map(|import| import.return_type); let resolved_index = self.resolve_call_target(index, argc_u8)?; - if let Some(function) = - self.host_functions - .get(resolved_index as usize) - .and_then(|function| match function { - VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), - _ => None, - }) + if let Some(function) = self + .host + .host_functions + .get(resolved_index as usize) + .and_then(|function| match function { + VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), + _ => None, + }) { return self.execute_static_non_yielding_args_host_function( function, @@ -1029,6 +1041,7 @@ impl Vm { call_ip: usize, ) -> VmResult { let resolved_index = self + .host .builtin_overrides .get(&builtin_call_index) .copied() @@ -1054,32 +1067,36 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; // Builtin dispatch reads arguments from the current stack tail while mutating the VM. - // The builtin runtime must not mutate `self.stack` until this borrowed slice is consumed. + // The builtin runtime must not mutate `self.instance.stack` until this borrowed slice is consumed. let outcome = unsafe { - let args = std::slice::from_raw_parts_mut(self.stack.as_mut_ptr().add(arg_start), argc); + let args = std::slice::from_raw_parts_mut( + self.instance.stack.as_mut_ptr().add(arg_start), + argc, + ); crate::builtins::runtime::execute_builtin_call(self, builtin, args) }?; match outcome { crate::builtins::runtime::BuiltinCallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } crate::builtins::runtime::BuiltinCallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } crate::builtins::runtime::BuiltinCallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op(op_id, WaitingHostOpSource::BuiltinIo)?; - self.ip = resume_ip; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1092,13 +1109,14 @@ impl Vm { call_ip: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let (lhs, rhs) = self.operand_value_types(call_ip); let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match builtin { BuiltinFunction::Len => match (lhs, args) { ( @@ -1166,8 +1184,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_typed_builtin_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1178,12 +1196,13 @@ impl Vm { argc: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match (builtin, args) { (BuiltinFunction::Len, [value]) => Self::fast_path_len_result(value), (BuiltinFunction::Get, [container, key]) => { @@ -1198,8 +1217,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_projection_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1460,14 +1479,16 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - let mut saved_stack = std::mem::take(&mut self.stack); - self.call_depth += 1; + let mut saved_stack = std::mem::take(&mut self.instance.stack); + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; let outcome = unsafe { @@ -1482,15 +1503,15 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); - let mut host_stack = std::mem::take(&mut self.stack); + let mut host_stack = std::mem::take(&mut self.instance.stack); let outcome = match outcome { Ok(outcome) => outcome, Err(err) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; return Err(err); } }; @@ -1500,28 +1521,28 @@ impl Vm { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); values.push_onto_stack(&mut saved_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { saved_stack.append(&mut host_stack); - self.stack = saved_stack; - self.ip = call_ip; + self.instance.stack = saved_stack; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1529,6 +1550,7 @@ impl Vm { fn bound_host_function_uses_args_slice(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1542,6 +1564,7 @@ impl Vm { fn bound_host_function_uses_stack_borrow(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1559,17 +1582,18 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; - let outcome = function(&self.stack[arg_start..]); - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth += 1; + let outcome = function(&self.instance.stack[arg_start..]); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let value = require_non_yielding_host_value(outcome?)?; let value = validate_non_yielding_host_value(value, expected_return_type)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); Ok(HostCallExecOutcome::Returned) } @@ -1581,14 +1605,16 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let outcome = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; let function = self + .host .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1602,36 +1628,36 @@ impl Vm { | VmHostFunction::StackStatic(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let (outcome, non_yielding) = outcome; let outcome = outcome?; if non_yielding { let value = require_non_yielding_host_value(outcome)?; let value = validate_non_yielding_host_value(value, expected_return_type)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); return Ok(HostCallExecOutcome::Returned); } match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1644,20 +1670,23 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; // Stack-borrowed host functions opt into the same raw stack-tail borrowing model used - // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.stack` + // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.instance.stack` // while the borrowed slice is alive. let outcome = unsafe { - let args = std::slice::from_raw_parts(self.stack.as_ptr().add(arg_start), argc); + let args = + std::slice::from_raw_parts(self.instance.stack.as_ptr().add(arg_start), argc); match &mut *function_ptr { VmHostFunction::StackDynamic(function) => function.call(self, args), VmHostFunction::StackStatic(function) => function(self, args), @@ -1668,28 +1697,28 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let outcome = outcome?; match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1720,7 +1749,7 @@ impl Vm { op_id: HostOpId, source: WaitingHostOpSource, ) -> VmResult<()> { - if let Some(active) = self.waiting_host_op + if let Some(active) = self.instance.waiting_host_op && active.op_id != op_id { return Err(VmError::HostError(format!( @@ -1728,7 +1757,7 @@ impl Vm { active.op_id, op_id ))); } - self.waiting_host_op = Some(WaitingHostOp { op_id, source }); + self.instance.waiting_host_op = Some(WaitingHostOp { op_id, source }); Ok(()) } @@ -1737,7 +1766,7 @@ impl Vm { op_id: HostOpId, values: CallReturn, ) -> VmResult<()> { - let waiting = self.waiting_host_op.ok_or_else(|| { + let waiting = self.instance.waiting_host_op.ok_or_else(|| { VmError::HostError(format!( "host op {} completed but vm is not waiting on any op", op_id @@ -1749,8 +1778,8 @@ impl Vm { op_id, waiting.op_id ))); } - self.waiting_host_op = None; - values.push_onto_stack(&mut self.stack); + self.instance.waiting_host_op = None; + values.push_onto_stack(&mut self.instance.stack); Ok(()) } @@ -1763,21 +1792,21 @@ impl Vm { ))); } for &index in &resolved_calls { - if index as usize >= self.host_functions.len() { + if index as usize >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index)); } } - self.resolved_calls = resolved_calls; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved_calls; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn ensure_call_bindings(&mut self) -> VmResult<()> { - if self.program.imports.is_empty() || !self.resolved_calls_dirty { + if self.program.imports.is_empty() || !self.host.resolved_calls_dirty { return Ok(()); } - if self.host_function_symbols.is_empty() && self.host_functions.is_empty() { + if self.host.host_function_symbols.is_empty() && self.host.host_functions.is_empty() { let import_names = self .program .imports @@ -1789,49 +1818,52 @@ impl Vm { } } - let use_legacy_order = self.host_function_symbols.is_empty(); + let use_legacy_order = self.host.host_function_symbols.is_empty(); let mut resolved = Vec::with_capacity(self.program.imports.len()); let imports = self.program.imports.clone(); for (index, import) in imports.iter().enumerate() { if use_legacy_order { - if index >= self.host_functions.len() { + if index >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index as u16)); } resolved.push(index as u16); continue; } - let bound = if let Some(bound) = self.host_function_symbols.get(&import.name).copied() { - bound - } else if crate::builtins::runtime::bind_default_host_function(self, &import.name) { - self.host_function_symbols - .get(&import.name) - .copied() - .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? - } else { - return Err(VmError::UnboundImport(import.name.clone())); - }; + let bound = + if let Some(bound) = self.host.host_function_symbols.get(&import.name).copied() { + bound + } else if crate::builtins::runtime::bind_default_host_function(self, &import.name) { + self.host + .host_function_symbols + .get(&import.name) + .copied() + .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? + } else { + return Err(VmError::UnboundImport(import.name.clone())); + }; resolved.push(bound); } - self.resolved_calls = resolved; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn sync_jit_non_yielding_host_imports(&mut self) { let imports = self + .host .resolved_calls .iter() .map(|&slot| { matches!( - self.host_functions.get(usize::from(slot)), + self.host.host_functions.get(usize::from(slot)), Some(VmHostFunction::ArgsStaticNonYielding(_)) ) }) .collect(); - if self.jit.set_non_yielding_host_imports(imports) { - self.native_traces.clear(); + if self.engine.jit.set_non_yielding_host_imports(imports) { + self.engine.native_traces.clear(); } } @@ -1854,7 +1886,8 @@ impl Vm { }); } - self.resolved_calls + self.host + .resolved_calls .get(index as usize) .copied() .ok_or(VmError::InvalidCall(index)) diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs new file mode 100644 index 00000000..38f08c52 --- /dev/null +++ b/src/vm/host_runtime.rs @@ -0,0 +1,59 @@ +//! Host runtime shell. +//! +//! [`HostRuntime`] owns the host-facing capability surface: bound host +//! functions and their symbol table, builtin overrides, resolved call slots, +//! host operation id allocation, the async bridge, and the print sink. +//! Interpreter state and run budgets live outside this struct (see +//! [`Instance`](super::instance::Instance) and +//! [`RunContext`](super::run_context::RunContext)). +//! +//! This mechanical decomposition groups host-facing ownership and reset/drop +//! behavior. Concrete adapter runtime state (currently the legacy IO +//! completion mailbox) deliberately stays on the `Vm` facade in this commit; +//! it moves onto the generic execution-scope lifecycle in a later commit. + +use std::collections::HashMap; + +use crate::vm::host::{HostAsyncBridge, HostOpId, VmHostFunction}; + +/// Embedder-supplied print sink for `print`/`debug` output. +pub(crate) type RuntimePrintSink = dyn FnMut(String) + Send; + +/// Host-owned capabilities, resources, operations, and subsystem state. +/// +/// Thread safety: `HostRuntime` is `!Sync` (host functions are mutable and +/// not shareable) and not shared; one facade owns one host runtime. Clone +/// semantics: not `Clone` — host bindings must not be duplicated across VMs. +pub(crate) struct HostRuntime { + pub(super) host_functions: Vec, + pub(crate) host_function_symbols: HashMap, + pub(crate) builtin_overrides: HashMap, + pub(crate) resolved_calls: Vec, + pub(crate) resolved_calls_dirty: bool, + pub(crate) async_bridge: Option>, + pub(crate) runtime_print_sink: Option>, + pub(crate) next_host_op_id: HostOpId, +} + +impl HostRuntime { + /// Creates an empty host runtime with no bound functions, no async bridge + /// or print sink. + pub(crate) fn new() -> Self { + Self { + host_functions: Vec::new(), + host_function_symbols: HashMap::new(), + builtin_overrides: HashMap::new(), + resolved_calls: Vec::new(), + resolved_calls_dirty: true, + async_bridge: None, + runtime_print_sink: None, + next_host_op_id: 1, + } + } +} + +impl Default for HostRuntime { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/instance.rs b/src/vm/instance.rs new file mode 100644 index 00000000..baecdfb0 --- /dev/null +++ b/src/vm/instance.rs @@ -0,0 +1,270 @@ +//! Interpreter instance state. +//! +//! [`Instance`] owns everything that describes one execution position inside a +//! program: the instruction pointer, operand stack, locals, frames, capture +//! cells, callable ownership, queued callback traffic, waiting/yield state, +//! and instance-only counters. It has no program reference of its own; the +//! immutable [`Program`](crate::bytecode::Program) and the backend +//! [`Engine`](super::engine::Engine) live beside it, so one program can drive +//! many independent instances and a reset only touches this struct. +//! +//! Lifecycle: [`Instance::new`] starts a fresh halted instance; [`Instance::reset`] +//! rewinds run state while keeping configuration and host bindings (owned by +//! the facade); [`Instance::drop_cleanup`] releases interpreter-owned values +//! with drop-contract accounting. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Weak}; + +use crate::bytecode::{CallableValue, Program, SharedCaptureCell, Value}; +use crate::vm::host::WaitingHostOp; +use crate::vm::map_iter::MapIteratorState; +use crate::vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, VmYieldReason}; + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FrameContinuation { + Halt, + ResumeBytecode { return_ip: usize }, + ReturnToHost, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct ExecutionFrame { + pub(crate) continuation: FrameContinuation, + pub(crate) operand_stack_base: usize, + pub(crate) local_base: usize, + pub(crate) local_count: usize, + pub(crate) prototype_id: Option, +} + +impl ExecutionFrame { + pub(crate) fn root(local_count: usize) -> Self { + Self { + continuation: FrameContinuation::Halt, + operand_stack_base: 0, + local_base: 0, + local_count, + prototype_id: None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct QueuedCallable { + pub(crate) callable: Value, + pub(crate) args: Vec, + pub(crate) subscription: Option>, +} + +/// Interpreter-owned execution state. +/// +/// Thread safety: `Instance` is `!Sync` (it owns mutable interpreter state) +/// and is not shared; the VM facade owns exactly one instance. It is not +/// clonable: cloning would silently duplicate stack/frame/wait state. +pub(crate) struct Instance { + pub(crate) ip: usize, + pub(crate) stack: Vec, + pub(crate) locals: Vec, + pub(crate) capture_cells: HashMap, + pub(crate) shared_capture_slots: HashSet, + pub(crate) execution_frames: Vec, + pub(crate) active_local_base_cache: usize, + pub(crate) active_operand_stack_base_cache: usize, + pub(crate) call_depth: usize, + pub(crate) max_script_call_depth: usize, + pub(crate) host_return: Option, + pub(crate) queued_callables: VecDeque, + pub(crate) completed_callable_results: VecDeque, + pub(crate) owned_callables: Vec>, + pub(crate) callback_registry_flags: Vec>, + pub(crate) draining_queued_callables: bool, + pub(crate) shutdown: bool, + pub(super) waiting_host_op: Option, + pub(crate) last_yield_reason: Option, + pub(crate) map_iterators: Vec>>, + pub(crate) drop_contract_events_enabled: bool, + pub(crate) drop_contract_events: u64, + pub(crate) operand_hint_hit_count: u64, + pub(crate) operand_hint_miss_count: u64, + pub(crate) typed_builtin_fast_path_count: u64, + pub(crate) projection_fast_path_count: u64, + pub(crate) generic_builtin_call_count: u64, + pub(crate) scalar_superinstruction_count: u64, + pub(crate) local_type_hint_hit_count: u64, +} + +impl Instance { + /// Creates a halted instance positioned at program entry. + pub(crate) fn new(program: &Program) -> Self { + let local_count = program.local_count; + Self { + ip: 0, + stack: Vec::new(), + locals: vec![Value::Null; local_count], + capture_cells: HashMap::new(), + shared_capture_slots: HashSet::new(), + execution_frames: vec![ExecutionFrame::root(local_count)], + active_local_base_cache: 0, + active_operand_stack_base_cache: 0, + call_depth: 0, + max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, + host_return: None, + queued_callables: VecDeque::new(), + completed_callable_results: VecDeque::new(), + owned_callables: Vec::new(), + callback_registry_flags: Vec::new(), + draining_queued_callables: false, + shutdown: false, + waiting_host_op: None, + last_yield_reason: None, + map_iterators: Vec::new(), + drop_contract_events_enabled: false, + drop_contract_events: 0, + operand_hint_hit_count: 0, + operand_hint_miss_count: 0, + typed_builtin_fast_path_count: 0, + projection_fast_path_count: 0, + generic_builtin_call_count: 0, + scalar_superinstruction_count: 0, + local_type_hint_hit_count: 0, + } + } + + /// Rewinds run-scoped interpreter state for a fresh execution of the same + /// program. Host bindings, backend configuration, and compiled artifacts + /// (owned outside this struct) are preserved. + pub(crate) fn reset(&mut self, program: &Program) { + self.invalidate_callback_registries(); + self.ip = 0; + self.drop_contract_events = 0; + self.last_yield_reason = None; + self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + self.owned_callables.clear(); + self.locals.resize(program.local_count, Value::Null); + self.initialize_root_callable_bindings(program); + self.call_depth = 0; + self.execution_frames.clear(); + self.execution_frames + .push(ExecutionFrame::root(program.local_count)); + self.active_local_base_cache = 0; + self.active_operand_stack_base_cache = 0; + self.host_return = None; + self.queued_callables.clear(); + self.completed_callable_results.clear(); + self.owned_callables.clear(); + self.draining_queued_callables = false; + self.shutdown = false; + self.waiting_host_op = None; + self.map_iterators.clear(); + self.clear_interpreter_metrics(); + } + + /// Releases interpreter-owned values with drop-contract accounting. Used by + /// the facade's `Drop` (and by `shutdown`). + pub(crate) fn drop_cleanup(&mut self) { + self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + } + + pub(crate) fn invalidate_callback_registries(&mut self) { + for active in self + .callback_registry_flags + .drain(..) + .filter_map(|flag| flag.upgrade()) + { + active.store(false, std::sync::atomic::Ordering::Release); + } + } + + pub(crate) fn register_callback_registry(&mut self, active: &Arc) { + self.callback_registry_flags.push(Arc::downgrade(active)); + } + + pub(crate) fn initialize_root_callable_bindings(&mut self, program: &Program) { + let bindings = program.root_callable_bindings.clone(); + for binding in bindings { + let Some(kind) = program + .callable_prototypes + .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) + else { + continue; + }; + if binding.local_slot as usize >= self.locals.len() { + continue; + } + let callable = Arc::new(CallableValue { + prototype_id: binding.prototype_id, + kind, + env: None, + }); + self.owned_callables.push(Arc::downgrade(&callable)); + self.locals[binding.local_slot as usize] = Value::Callable(callable); + } + } + + pub(crate) fn clear_interpreter_metrics(&mut self) { + self.operand_hint_hit_count = 0; + self.operand_hint_miss_count = 0; + self.typed_builtin_fast_path_count = 0; + self.projection_fast_path_count = 0; + self.generic_builtin_call_count = 0; + self.scalar_superinstruction_count = 0; + self.local_type_hint_hit_count = 0; + } + + pub(crate) fn clear_stack_with_drop_contract(&mut self) { + let drained = self.stack.drain(..).collect::>(); + for value in drained { + self.drop_value_with_contract(value); + } + } + + pub(crate) fn clear_locals_with_drop_contract(&mut self) { + for slot in 0..self.locals.len() { + let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + self.drop_value_with_contract(previous); + } + } + + pub(crate) fn drop_value_with_contract(&mut self, value: Value) { + if self.drop_contract_events_enabled { + self.count_value_drop_contract(&value); + } + } + + pub(crate) fn count_value_drop_contract(&mut self, value: &Value) { + match value { + Value::Null => {} + Value::Array(values) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for item in values.iter() { + self.count_value_drop_contract(item); + } + } + Value::Map(entries) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for (key, value) in entries.iter() { + self.count_value_drop_contract(key); + self.count_value_drop_contract(value); + } + } + Value::Int(_) + | Value::Float(_) + | Value::Bool(_) + | Value::String(_) + | Value::Bytes(_) + | Value::Callable(_) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + } + } + } +} diff --git a/src/vm/jit/diagnostics.rs b/src/vm/jit/diagnostics.rs index 9fe0dafe..47eb71e0 100644 --- a/src/vm/jit/diagnostics.rs +++ b/src/vm/jit/diagnostics.rs @@ -3,11 +3,12 @@ use super::{JitMetrics, JitSnapshot, native}; impl Vm { pub(super) fn jit_diagnostics_snapshot(&self) -> JitSnapshot { - self.jit.snapshot(self.jit_diagnostics_metrics()) + self.engine.jit.snapshot(self.jit_diagnostics_metrics()) } pub(super) fn jit_diagnostics_dump(&self, include_machine_code: bool) -> String { let mut out = self + .engine .jit .dump_text(self.program.debug.as_ref(), self.jit_diagnostics_metrics()); out.push_str(&format!( @@ -16,35 +17,36 @@ impl Vm { )); out.push_str(&format!( " native trace executions: {}\n", - self.native_trace_exec_count + self.engine.native_trace_exec_count )); out.push_str(&format!( " native trace handoffs: {}\n", - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count )); out.push_str(&format!( " native region entries: {}\n", - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count )); out.push_str(&format!( " native internal region edges: {}\n", - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count )); out.push_str(&format!( " native direct side links: {}\n", - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count )); out.push_str(&format!( " native compile time: {} ns (regions={} ns)\n", - self.jit_native_compile_time_ns, self.jit_native_region_compile_time_ns + self.engine.jit_native_compile_time_ns, self.engine.jit_native_region_compile_time_ns )); out.push_str(&format!( " native code bytes: {} (regions={})\n", self.jit_native_code_bytes(), self.jit_native_region_code_bytes() )); - if self.jit_native_bridge_stats_enabled { + if self.engine.jit_native_bridge_stats_enabled { let mut bridge_entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -62,14 +64,14 @@ impl Vm { out.push_str(&format!(" bridge {}: {}\n", name, count)); } } - let native_trace_count = self.native_traces.iter().flatten().count(); + let native_trace_count = self.engine.native_traces.iter().flatten().count(); if native_trace_count == 0 { out.push_str(" native traces: 0\n"); return out; } out.push_str(&format!(" native traces: {}\n", native_trace_count)); - for (id, native) in self.native_traces.iter().enumerate() { + for (id, native) in self.engine.native_traces.iter().enumerate() { if let Some(native) = native { out.push_str(&format!( " native trace#{} entry=0x{:X} code_bytes={} lowering={}\n", @@ -109,10 +111,10 @@ impl Vm { JitMetrics { boxed_load_site_count: 0, boxed_store_site_count: 0, - trace_exit_count: self.jit_trace_exit_count, - native_loop_back_count: self.jit_native_loop_back_count, - helper_fallback_count: self.jit_helper_fallback_count, - native_trace_exec_count: self.native_trace_exec_count, + trace_exit_count: self.engine.jit_trace_exit_count, + native_loop_back_count: self.engine.jit_native_loop_back_count, + helper_fallback_count: self.engine.jit_helper_fallback_count, + native_trace_exec_count: self.engine.native_trace_exec_count, script_call_observations: 0, monomorphic_call_sites: 0, polymorphic_call_sites: 0, diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index 751ef231..e4d946a0 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -257,20 +257,27 @@ pub(crate) extern "C" fn pd_vm_native_resume_linked_trace(vm: *mut Vm) -> i32 { return native::STATUS_ERROR; }; - if vm_ref.jit_native_link_dispatch_depth > 0 { + if vm_ref.engine.jit_native_link_dispatch_depth > 0 { return native::STATUS_TRACE_EXIT; } - vm_ref.jit_native_link_dispatch_depth = vm_ref.jit_native_link_dispatch_depth.saturating_add(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_add(1); match vm_ref.continue_linked_native_trace_from_exit() { Ok(status) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); status } Err(err) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); native::store_bridge_error(err); native::STATUS_ERROR } @@ -283,9 +290,9 @@ impl Vm { return None; } let entry_callable_prototypes = self.active_local_callable_prototypes(); - self.jit.compiled_trace_for_entry_with_callables( + self.engine.jit.compiled_trace_for_entry_with_callables( self.active_frame_key(), - self.ip, + self.instance.ip, self.active_operand_stack_len(), entry_callable_prototypes.as_deref(), ) @@ -299,21 +306,21 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn continue_linked_native_trace_from_exit(&mut self) -> VmResult { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = self.engine.jit_trace_exit_count.saturating_add(1); let mut current_trace_id = { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); if next_trace_id.is_none() && !self.active_frame_has_shared_capture_cells() - && !self.jit.callable_frame_is_blocked(frame_key) + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -352,15 +359,16 @@ impl Vm { loop { native::clear_bridge_error(); - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -371,13 +379,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -393,8 +403,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -445,37 +456,39 @@ impl Vm { return Ok(native::STATUS_LINKED_CONTINUE); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -534,19 +547,19 @@ impl Vm { } fn active_native_interrupt_settings(&self) -> Option { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { super::super::InterruptMode::None => None, super::super::InterruptMode::Fuel => Some(native::NativeInterruptSettings::fuel( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), super::super::InterruptMode::Epoch => Some(native::NativeInterruptSettings::epoch( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), } } fn clear_native_direct_links(&self) { - for native in self.native_traces.iter().flatten() { + for native in self.engine.native_traces.iter().flatten() { for slot in native.direct_slots.values() { slot.clear(); } @@ -554,15 +567,16 @@ impl Vm { } fn record_native_direct_escape(&mut self, _status: i32, direct_links_before: u64) { - if !self.jit_native_direct_links_enabled - || self.jit_native_direct_link_count == direct_links_before + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_link_count == direct_links_before { return; } - self.jit_native_direct_escape_streak = 0; - if self.jit_native_active_direct_trace_id != usize::MAX { - self.jit - .record_native_loop_back(self.jit_native_active_direct_trace_id); + self.engine.jit_native_direct_escape_streak = 0; + if self.engine.jit_native_active_direct_trace_id != usize::MAX { + self.engine + .jit + .record_native_loop_back(self.engine.jit_native_active_direct_trace_id); } } @@ -571,7 +585,9 @@ impl Vm { key: TraceExitKey, child_trace_id: usize, ) -> VmResult<()> { - if !self.jit_native_direct_links_enabled || self.jit_native_direct_region_fallback { + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_region_fallback + { return Ok(()); } self.publish_native_direct_slot(key.parent_trace_id, key.exit_id.raw(), child_trace_id) @@ -590,15 +606,21 @@ impl Vm { slot_id: u32, child_trace_id: usize, ) -> VmResult<()> { - if self.jit.trace_has_entry_callable_guards(child_trace_id) { + if self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) + { return Ok(()); } - if !self.jit_native_direct_cross_frame_enabled { + if !self.engine.jit_native_direct_cross_frame_enabled { let parent_frame_key = self + .engine .jit .trace_clone(parent_trace_id) .map(|trace| trace.frame_key); let child_frame_key = self + .engine .jit .trace_clone(child_trace_id) .map(|trace| trace.frame_key); @@ -608,6 +630,7 @@ impl Vm { } self.ensure_native_trace(child_trace_id, native::NativeCompileProfile::Jit)?; let child_entry = self + .engine .native_traces .get(child_trace_id) .and_then(Option::as_ref) @@ -616,6 +639,7 @@ impl Vm { })? .tail_entry as *const u8; let Some(slot) = self + .engine .native_traces .get(parent_trace_id) .and_then(Option::as_ref) @@ -652,28 +676,34 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn maybe_publish_native_region(&mut self, key: TraceExitKey, child_trace_id: usize) { - if self.jit_native_direct_links_enabled && !self.jit_native_direct_region_fallback { + if self.engine.jit_native_direct_links_enabled + && !self.engine.jit_native_direct_region_fallback + { return; } if self + .engine .jit .trace_has_entry_callable_guards(key.parent_trace_id) - || self.jit.trace_has_entry_callable_guards(child_trace_id) + || self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) { return; } - let Some(candidate) = self.jit.region_candidate(key, child_trace_id) else { + let Some(candidate) = self.engine.jit.region_candidate(key, child_trace_id) else { return; }; - if candidate.generation != self.jit.region_generation() { + if candidate.generation != self.engine.jit.region_generation() { return; } - let Some(parent) = self.jit.trace_clone(key.parent_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(parent) = self.engine.jit.trace_clone(key.parent_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; - let Some(child) = self.jit.trace_clone(child_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(child) = self.engine.jit.trace_clone(child_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; let back_import = scalar_cycle_import(&candidate.import) @@ -684,7 +714,8 @@ impl Vm { .iter() .filter(|exit| exit.exit_ip == parent.root_ip) .find_map(|exit| { - self.jit + self.engine + .jit .side_trace_import(child.id, exit.id, parent.id) .ok() }) @@ -699,7 +730,7 @@ impl Vm { ) { Ok(fused) => fused, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -713,13 +744,14 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_region_compile_time_ns = self + self.engine.jit_native_region_compile_time_ns = self + .engine .jit_native_region_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = match compile_result { Ok(compiled) => compiled, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -741,37 +773,38 @@ impl Vm { exit_keys: Arc::new(fused.exit_keys), }; let Some(parent_native) = self + .engine .native_traces .get_mut(key.parent_trace_id) .and_then(Option::as_mut) else { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; }; - if !self.jit.publish_region(&candidate) { + if !self.engine.jit.publish_region(&candidate) { return; } parent_native.region = Some(region); } fn clear_native_region_owners(&mut self) { - for native in self.native_traces.iter_mut().flatten() { + for native in self.engine.native_traces.iter_mut().flatten() { native.region = None; } } pub(crate) fn disconnect_native_regions(&mut self) { - self.jit.invalidate_regions(); + self.engine.jit.invalidate_regions(); self.clear_native_region_owners(); } fn block_jit_trace(&mut self, trace_id: usize) { - self.jit.block_trace(trace_id); + self.engine.jit.block_trace(trace_id); self.clear_native_region_owners(); } fn block_jit_callable_frame(&mut self, trace_id: usize) { - self.jit.block_callable_frame(trace_id); + self.engine.jit.block_callable_frame(trace_id); self.clear_native_region_owners(); } @@ -780,26 +813,26 @@ impl Vm { self.ensure_program_cache_key(); } self.clear_native_direct_links(); - self.native_traces.clear(); - self.native_trace_exec_count = 0; - self.jit_native_region_entry_count = 0; - self.jit_native_region_edge_count = 0; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; - self.jit_native_compile_time_ns = 0; - self.jit_native_region_compile_time_ns = 0; - self.jit_trace_exit_count = 0; - self.jit_native_loop_back_count = 0; - self.jit_native_link_handoff_count = 0; - self.jit_native_link_dispatch_depth = 0; - self.jit_helper_fallback_count = 0; - self.jit.set_config(config); + self.engine.native_traces.clear(); + self.engine.native_trace_exec_count = 0; + self.engine.jit_native_region_entry_count = 0; + self.engine.jit_native_region_edge_count = 0; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; + self.engine.jit_native_compile_time_ns = 0; + self.engine.jit_native_region_compile_time_ns = 0; + self.engine.jit_trace_exit_count = 0; + self.engine.jit_native_loop_back_count = 0; + self.engine.jit_native_link_handoff_count = 0; + self.engine.jit_native_link_dispatch_depth = 0; + self.engine.jit_helper_fallback_count = 0; + self.engine.jit.set_config(config); } pub fn jit_config(&self) -> &super::JitConfig { - self.jit.config() + self.engine.jit.config() } pub fn jit_snapshot(&self) -> super::JitSnapshot { @@ -807,15 +840,16 @@ impl Vm { } pub fn jit_exit_profiles(&self) -> Vec { - self.jit.exit_profiles() + self.engine.jit.exit_profiles() } pub fn jit_call_site_profiles(&self) -> Vec { - self.jit.call_site_profiles() + self.engine.jit.call_site_profiles() } pub fn jit_native_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .map(|native| native.code.len()) @@ -823,7 +857,8 @@ impl Vm { } pub fn jit_native_region_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter_map(|native| native.region.as_ref()) @@ -832,11 +867,11 @@ impl Vm { } pub fn jit_native_compile_time_ns(&self) -> u64 { - self.jit_native_compile_time_ns + self.engine.jit_native_compile_time_ns } pub fn jit_native_region_compile_time_ns(&self) -> u64 { - self.jit_native_region_compile_time_ns + self.engine.jit_native_region_compile_time_ns } pub fn dump_jit_info(&self) -> String { @@ -909,15 +944,16 @@ impl Vm { ) = self.native_trace_state(current_trace_id)?; native::clear_bridge_error(); loop { - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -928,13 +964,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -950,8 +988,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -1017,13 +1056,19 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(ExecOutcome::Continue); } - if self.jit.trace_clone(current_trace_id).is_some_and(|trace| { - trace.op_names.last().map(String::as_str) == Some("callable_boundary") - }) { + if self + .engine + .jit + .trace_clone(current_trace_id) + .is_some_and(|trace| { + trace.op_names.last().map(String::as_str) == Some("callable_boundary") + }) + { self.block_jit_trace(current_trace_id); return Ok(ExecOutcome::Continue); } @@ -1031,25 +1076,26 @@ impl Vm { // calls, keep executing in native mode without bouncing through the interpreter. if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(ExecOutcome::Continue); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY) @@ -1057,7 +1103,7 @@ impl Vm { let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1121,17 +1167,19 @@ impl Vm { if self.active_frame_has_shared_capture_cells() { return Ok(ExecOutcome::Continue); } - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) { + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) + { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1186,26 +1234,31 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } native::STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "native call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "native call bridge reported waiting without a pending op" + .to_string(), + ) + })?; return Ok(ExecOutcome::Waiting(op_id)); } native::STATUS_OUT_OF_FUEL => { - return match self.interrupt_mode { + return match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { - needed: u64::from(self.fuel_check_interval), - remaining: self.fuel_remaining, + needed: u64::from(self.run_ctx.fuel_check_interval), + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "native interruption checkpoint fired while interruption was disabled" @@ -1215,19 +1268,20 @@ impl Vm { } native::STATUS_ERROR => { let err = native::take_bridge_error().unwrap_or_else(|| { - let trace_meta = self.jit.trace_clone(current_trace_id).map(|trace| { - format!( - "trace_id={} root_ip={} terminal={:?} ops={}", - trace.id, - trace.root_ip, - trace.terminal, - trace.op_names.len() - ) - }); + let trace_meta = + self.engine.jit.trace_clone(current_trace_id).map(|trace| { + format!( + "trace_id={} root_ip={} terminal={:?} ops={}", + trace.id, + trace.root_ip, + trace.terminal, + trace.op_names.len() + ) + }); VmError::JitNative(format!( "jit bridge reported failure without VmError (ip={} stack_len={} {})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), trace_meta.unwrap_or_else(|| "trace=".to_string()) )) }); @@ -1252,6 +1306,7 @@ impl Vm { ))] fn native_trace_state(&self, trace_id: usize) -> VmResult { let native = self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1259,7 +1314,7 @@ impl Vm { VmError::JitNative(format!("native trace entry for id {} missing", trace_id)) })?; if let Some(region) = native.region.as_ref().filter(|region| { - self.jit.published_region().is_some_and(|published| { + self.engine.jit.published_region().is_some_and(|published| { published.generation == region.generation && published.key == region.key && published.child_trace_id == region.child_trace_id @@ -1300,10 +1355,10 @@ impl Vm { trace_id: usize, compile_profile: native::NativeCompileProfile, ) -> Option { - let native = self.native_traces.get(trace_id)?.as_ref()?; + let native = self.engine.native_traces.get(trace_id)?.as_ref()?; (native.interrupt_settings == self.active_native_interrupt_settings() && compile_profile_satisfies(native.compile_profile, compile_profile) - && native.drop_contract_events_enabled == self.drop_contract_events_enabled) + && native.drop_contract_events_enabled == self.instance.drop_contract_events_enabled) .then(|| self.native_trace_state(trace_id).ok()) .flatten() } @@ -1337,7 +1392,11 @@ impl Vm { compile_profile: native::NativeCompileProfile, interrupt_settings: Option, ) -> VmResult<()> { - if let Some(native) = self.native_traces.get(trace_id).and_then(Option::as_ref) + if let Some(native) = self + .engine + .native_traces + .get(trace_id) + .and_then(Option::as_ref) && native.interrupt_settings == interrupt_settings && compile_profile_satisfies(native.compile_profile, compile_profile) && native.drop_contract_events_enabled == self.drop_contract_events_enabled() @@ -1345,6 +1404,7 @@ impl Vm { return Ok(()); } if self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1353,12 +1413,12 @@ impl Vm { self.disconnect_native_regions(); } self.clear_native_direct_links(); - if let Some(slot) = self.native_traces.get_mut(trace_id) { + if let Some(slot) = self.engine.native_traces.get_mut(trace_id) { *slot = None; } let program_cache_key = self.ensure_program_cache_key(); - let trace = self.jit.trace_clone(trace_id).ok_or_else(|| { + let trace = self.engine.jit.trace_clone(trace_id).ok_or_else(|| { VmError::JitNative(format!("trace {} missing for native compile", trace_id)) })?; let drop_contract_events_enabled = self.drop_contract_events_enabled(); @@ -1393,10 +1453,10 @@ impl Vm { .collect(); let mut code = cached.code.to_vec(); code.extend_from_slice(&dispatcher.code); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: cached.keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1423,7 +1483,8 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_compile_time_ns = self + self.engine.jit_native_compile_time_ns = self + .engine .jit_native_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = compile_result?; @@ -1463,10 +1524,10 @@ impl Vm { let mut code = compiled.code; code.extend_from_slice(&dispatcher.code); let code = Arc::<[u8]>::from(code.into_boxed_slice()); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1487,21 +1548,24 @@ impl Vm { } pub fn jit_native_trace_count(&self) -> usize { - self.native_traces.iter().flatten().count() + self.engine.native_traces.iter().flatten().count() } pub fn jit_native_exec_count(&self) -> u64 { - self.native_trace_exec_count + self.engine.native_trace_exec_count } pub(crate) fn jit_native_inherited_target(&self) -> usize { - if !self.jit_native_direct_links_enabled || self.active_frame_has_shared_capture_cells() { + if !self.engine.jit_native_direct_links_enabled + || self.active_frame_has_shared_capture_cells() + { return 0; } let Some(trace_id) = self.compiled_trace_for_active_entry() else { return 0; }; - self.native_traces + self.engine + .native_traces .get(trace_id) .and_then(Option::as_ref) .map(|native| native.tail_entry as usize) @@ -1510,24 +1574,25 @@ impl Vm { pub fn set_jit_native_direct_links_enabled(&mut self, enabled: bool) { let cross_frame_enabled = enabled; - if self.jit_native_direct_links_enabled == enabled - && self.jit_native_direct_cross_frame_enabled == cross_frame_enabled + if self.engine.jit_native_direct_links_enabled == enabled + && self.engine.jit_native_direct_cross_frame_enabled == cross_frame_enabled { return; } self.clear_native_direct_links(); self.disconnect_native_regions(); - self.native_traces.clear(); - self.jit_native_direct_links_enabled = enabled; - self.jit_native_direct_cross_frame_enabled = cross_frame_enabled; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; + self.engine.native_traces.clear(); + self.engine.jit_native_direct_links_enabled = enabled; + self.engine.jit_native_direct_cross_frame_enabled = cross_frame_enabled; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; } pub fn jit_native_region_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter(|native| native.region.is_some()) @@ -1535,19 +1600,20 @@ impl Vm { } pub fn jit_native_region_entry_count(&self) -> u64 { - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count } pub fn jit_native_internal_region_edge_count(&self) -> u64 { - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count } pub fn jit_native_direct_link_count(&self) -> u64 { - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count } pub fn jit_native_active_direct_link_slot_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .flat_map(|native| native.direct_slots.values()) @@ -1556,19 +1622,21 @@ impl Vm { } pub fn jit_helper_fallback_count(&self) -> u64 { - self.jit_helper_fallback_count + self.engine.jit_helper_fallback_count } pub fn jit_native_link_handoff_count(&self) -> u64 { - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count } fn record_jit_helper_fallback(&mut self) { - self.jit_helper_fallback_count = self.jit_helper_fallback_count.saturating_add(1); + self.engine.jit_helper_fallback_count = + self.engine.jit_helper_fallback_count.saturating_add(1); } fn record_jit_link_handoff(&mut self) { - self.jit_native_link_handoff_count = self.jit_native_link_handoff_count.saturating_add(1); + self.engine.jit_native_link_handoff_count = + self.engine.jit_native_link_handoff_count.saturating_add(1); } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index ba148aec..ed982c01 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,21 +1,27 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex}; pub(crate) mod aot; pub mod diagnostics; +mod engine; mod epoch; mod fuel; mod host; +mod host_runtime; +mod instance; pub(crate) mod jit; mod map_iter; pub(crate) mod native; +pub mod program; +mod run_context; mod store; mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; pub use self::fuel::FuelCheckpoint; pub use self::host::{ @@ -23,7 +29,10 @@ pub use self::host::{ HostFunctionRegistry, HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, }; -use self::host::{HostCallExecOutcome, VmHostFunction, WaitingHostOp}; +use self::host::{HostCallExecOutcome, VmHostFunction}; +use self::host_runtime::HostRuntime; +use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; +use self::run_context::{InterruptMode, RunContext}; pub use crate::bytecode::{ CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, }; @@ -228,25 +237,6 @@ pub struct InterpreterMetrics { pub local_type_hint_hit_count: u64, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -enum InterruptMode { - None = 0, - Fuel = 1, - Epoch = 2, -} - -impl InterruptMode { - fn label(self) -> &'static str { - match self { - Self::None => "none", - Self::Fuel => "fuel", - Self::Epoch => "epoch", - } - } -} -type RuntimePrintSink = dyn FnMut(String) + Send; - type PackedOperandTypes = u8; const NO_OPERAND_TYPE_HINT: PackedOperandTypes = 0; @@ -283,129 +273,18 @@ pub struct VmExecutionFrameSnapshot { pub prototype_id: Option, } -#[allow(dead_code)] -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum FrameContinuation { - Halt, - ResumeBytecode { return_ip: usize }, - ReturnToHost, -} - -#[allow(dead_code)] -#[derive(Clone, Debug)] -pub(crate) struct ExecutionFrame { - pub(crate) continuation: FrameContinuation, - pub(crate) operand_stack_base: usize, - pub(crate) local_base: usize, - pub(crate) local_count: usize, - pub(crate) prototype_id: Option, -} - -impl ExecutionFrame { - fn root(local_count: usize) -> Self { - Self { - continuation: FrameContinuation::Halt, - operand_stack_base: 0, - local_base: 0, - local_count, - prototype_id: None, - } - } -} - -#[derive(Clone, Debug)] -struct QueuedCallable { - callable: Value, - args: Vec, - subscription: Option>, -} - pub struct Vm { program: Arc, - #[allow(dead_code)] - program_constants_ptr: usize, - #[allow(dead_code)] - program_constants_len: usize, - #[allow(dead_code)] - native_helper_fn: usize, - #[allow(dead_code)] - native_interrupt_helper_fn: usize, - program_cache_key: u64, - program_cache_key_ready: bool, - ip: usize, - stack: Vec, - locals: Vec, - capture_cells: HashMap, - shared_capture_slots: HashSet, - operand_type_hints: Option>, - decoded_instruction_data: Arc, - host_functions: Vec, - host_function_symbols: HashMap, - builtin_overrides: HashMap, - resolved_calls: Vec, - resolved_calls_dirty: bool, - call_depth: usize, - max_script_call_depth: usize, - execution_frames: Vec, - active_local_base_cache: usize, - active_operand_stack_base_cache: usize, - host_return: Option, - queued_callables: VecDeque, - completed_callable_results: VecDeque, - owned_callables: Vec>, - callback_registry_flags: Vec>, - draining_queued_callables: bool, - shutdown: bool, - aot_program: Option, - aot_exec_count: u64, - aot_interpreter_boundary_hit: bool, - jit: jit::TraceJitEngine, - native_traces: Vec>, - native_trace_exec_count: u64, - jit_native_region_entry_count: u64, - jit_native_region_edge_count: u64, - jit_native_direct_link_count: u64, - jit_native_direct_links_enabled: bool, - jit_native_direct_cross_frame_enabled: bool, - jit_native_active_direct_trace_id: usize, - jit_native_direct_escape_streak: u16, - jit_native_direct_region_fallback: bool, - jit_native_compile_time_ns: u64, - jit_native_region_compile_time_ns: u64, - jit_trace_exit_count: u64, - jit_native_loop_back_count: u64, - jit_native_link_handoff_count: u64, - jit_native_link_dispatch_depth: u32, - jit_helper_fallback_count: u64, - jit_native_bridge_stats_enabled: bool, - jit_native_bridge_counts: HashMap<&'static str, u64>, - async_bridge: Option>, - runtime_print_sink: Option>, - waiting_host_op: Option, - next_host_op_id: HostOpId, + pub(crate) engine: Engine, + pub(crate) instance: Instance, + pub(crate) run_ctx: RunContext, + pub(crate) host: HostRuntime, + /// Legacy pre-scope IO runtime state. + /// + /// This commit keeps IO ownership on the `Vm` facade (it was not moved + /// into [`HostRuntime`]); the generic scope-lifecycle migration relocates + /// it in a later commit. pub(crate) io_state: crate::builtins::runtime::IoState, - regex_cache: crate::builtins::runtime::regex::RegexCache, - map_iterators: Vec>>, - epoch_handle: EpochHandle, - #[allow(dead_code)] - epoch_counter_ptr: usize, - interrupt_mode: InterruptMode, - fuel_remaining: u64, - fuel_check_interval: u32, - fuel_ops_until_check: u32, - epoch_deadline: u64, - epoch_deadline_delta: u64, - epoch_rearm_pending: bool, - last_yield_reason: Option, - drop_contract_events_enabled: bool, - drop_contract_events: u64, - operand_hint_hit_count: u64, - operand_hint_miss_count: u64, - typed_builtin_fast_path_count: u64, - projection_fast_path_count: u64, - generic_builtin_call_count: u64, - scalar_superinstruction_count: u64, - local_type_hint_hit_count: u64, } pub(crate) enum ExecOutcome { @@ -660,126 +539,22 @@ impl Vm { } pub fn new_shared_with_jit_config(program: Arc, jit_config: jit::JitConfig) -> Self { - let program_constants_ptr = program.constants.as_ptr(); - let program_constants_len = program.constants.len(); - let local_count = program.local_count; - let operand_type_hints = program.shared_operand_type_hints(); - let decoded_instruction_data = program.shared_decoded_instruction_data(); - let epoch_handle = EpochHandle::default(); - let epoch_counter_ptr = epoch_handle.as_ptr() as usize; - let mut vm = Self { + let engine = Engine::new(jit_config, &program); + let mut instance = Instance::new(&program); + instance.initialize_root_callable_bindings(&program); + Self { program, - program_constants_ptr: program_constants_ptr as usize, - program_constants_len, - native_helper_fn: native::helper_entry_address(), - native_interrupt_helper_fn: native::interrupt_helper_entry_address(), - program_cache_key: 0, - program_cache_key_ready: false, - ip: 0, - stack: Vec::new(), - locals: vec![Value::Null; local_count], - capture_cells: HashMap::new(), - shared_capture_slots: HashSet::new(), - operand_type_hints, - decoded_instruction_data, - host_functions: Vec::new(), - host_function_symbols: HashMap::new(), - builtin_overrides: HashMap::new(), - resolved_calls: Vec::new(), - resolved_calls_dirty: true, - call_depth: 0, - max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, - execution_frames: vec![ExecutionFrame::root(local_count)], - active_local_base_cache: 0, - active_operand_stack_base_cache: 0, - host_return: None, - queued_callables: VecDeque::new(), - completed_callable_results: VecDeque::new(), - owned_callables: Vec::new(), - callback_registry_flags: Vec::new(), - draining_queued_callables: false, - shutdown: false, - aot_program: None, - aot_exec_count: 0, - aot_interpreter_boundary_hit: false, - jit: jit::TraceJitEngine::new(jit_config), - native_traces: Vec::new(), - native_trace_exec_count: 0, - jit_native_region_entry_count: 0, - jit_native_region_edge_count: 0, - jit_native_direct_link_count: 0, - jit_native_direct_links_enabled: true, - jit_native_direct_cross_frame_enabled: false, - jit_native_active_direct_trace_id: usize::MAX, - jit_native_direct_escape_streak: 0, - jit_native_direct_region_fallback: false, - jit_native_compile_time_ns: 0, - jit_native_region_compile_time_ns: 0, - jit_trace_exit_count: 0, - jit_native_loop_back_count: 0, - jit_native_link_handoff_count: 0, - jit_native_link_dispatch_depth: 0, - jit_helper_fallback_count: 0, - jit_native_bridge_stats_enabled: false, - jit_native_bridge_counts: HashMap::new(), - async_bridge: None, - runtime_print_sink: None, - waiting_host_op: None, - next_host_op_id: 1, + engine, + instance, + run_ctx: RunContext::default(), + host: HostRuntime::default(), io_state: crate::builtins::runtime::IoState::default(), - regex_cache: crate::builtins::runtime::regex::RegexCache::default(), - map_iterators: Vec::new(), - epoch_handle, - epoch_counter_ptr, - interrupt_mode: InterruptMode::None, - fuel_remaining: 0, - fuel_check_interval: 1, - fuel_ops_until_check: 1, - epoch_deadline: 0, - epoch_deadline_delta: 0, - epoch_rearm_pending: false, - last_yield_reason: None, - drop_contract_events_enabled: false, - drop_contract_events: 0, - operand_hint_hit_count: 0, - operand_hint_miss_count: 0, - typed_builtin_fast_path_count: 0, - projection_fast_path_count: 0, - generic_builtin_call_count: 0, - scalar_superinstruction_count: 0, - local_type_hint_hit_count: 0, - }; - vm.initialize_root_callable_bindings(); - vm - } - - fn initialize_root_callable_bindings(&mut self) { - let bindings = self.program.root_callable_bindings.clone(); - for binding in bindings { - let Some(kind) = self - .program - .callable_prototypes - .get(binding.prototype_id as usize) - .map(|prototype| prototype.kind) - else { - continue; - }; - if binding.local_slot as usize >= self.locals.len() { - continue; - } - let callable = Arc::new(CallableValue { - prototype_id: binding.prototype_id, - kind, - env: None, - }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[binding.local_slot as usize] = Value::Callable(callable); } } /// Returns the maximum number of simultaneously active script call frames. pub fn max_script_call_depth(&self) -> usize { - self.max_script_call_depth + self.instance.max_script_call_depth } /// Sets the maximum number of simultaneously active script call frames. @@ -790,38 +565,34 @@ impl Vm { if limit == 0 { return Err(VmError::InvalidCallStackLimit(limit)); } - self.max_script_call_depth = limit; + self.instance.max_script_call_depth = limit; Ok(()) } fn ensure_program_cache_key(&mut self) -> u64 { - if !self.program_cache_key_ready { - self.program_cache_key = compute_program_cache_key(&self.program); - self.program_cache_key_ready = true; - } - self.program_cache_key + self.engine.ensure_program_cache_key(&self.program) } #[inline(always)] fn fuel_metering_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Fuel + self.run_ctx.interrupt_mode == InterruptMode::Fuel } #[inline(always)] fn epoch_interruption_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Epoch + self.run_ctx.interrupt_mode == InterruptMode::Epoch } #[inline(always)] fn interruption_enabled(&self) -> bool { - self.interrupt_mode != InterruptMode::None + self.run_ctx.interrupt_mode != InterruptMode::None } /// Returns the maximum number of compiled regular expressions retained by this VM. /// /// New VMs default to 512 entries. A capacity of zero disables caching. pub fn regex_cache_capacity(&self) -> usize { - self.regex_cache.capacity() + self.engine.regex_cache.capacity() } /// Changes this VM's compiled regular-expression cache capacity. @@ -829,67 +600,68 @@ impl Vm { /// Shrinking evicts least-recently-used entries immediately. Setting zero clears /// all entries and disables caching until a positive capacity is configured. pub fn set_regex_cache_capacity(&mut self, capacity: usize) { - self.regex_cache.set_capacity(capacity); + self.engine.regex_cache.set_capacity(capacity); } pub fn regex_cache_entry_count(&self) -> usize { - self.regex_cache.len() + self.engine.regex_cache.len() } pub fn regex_cache_compile_count(&self) -> u64 { - self.regex_cache.compile_count() + self.engine.regex_cache.compile_count() } pub fn regex_cache_hit_count(&self) -> u64 { - self.regex_cache.hit_count() + self.engine.regex_cache.hit_count() } pub(crate) fn cached_regex( &mut self, pattern: &str, ) -> Result, regex::Error> { - self.regex_cache.get_or_compile(pattern) + self.engine.regex_cache.get_or_compile(pattern) } pub fn set_jit_native_bridge_stats_enabled(&mut self, enabled: bool) { - self.jit_native_bridge_stats_enabled = enabled; + self.engine.jit_native_bridge_stats_enabled = enabled; if !enabled { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } } pub fn jit_native_bridge_stats_enabled(&self) -> bool { - self.jit_native_bridge_stats_enabled + self.engine.jit_native_bridge_stats_enabled } pub fn clear_jit_native_bridge_stats(&mut self) { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } pub fn interpreter_metrics_snapshot(&self) -> InterpreterMetrics { InterpreterMetrics { - operand_hint_hit_count: self.operand_hint_hit_count, - operand_hint_miss_count: self.operand_hint_miss_count, - typed_builtin_fast_path_count: self.typed_builtin_fast_path_count, - projection_fast_path_count: self.projection_fast_path_count, - generic_builtin_call_count: self.generic_builtin_call_count, - scalar_superinstruction_count: self.scalar_superinstruction_count, - local_type_hint_hit_count: self.local_type_hint_hit_count, + operand_hint_hit_count: self.instance.operand_hint_hit_count, + operand_hint_miss_count: self.instance.operand_hint_miss_count, + typed_builtin_fast_path_count: self.instance.typed_builtin_fast_path_count, + projection_fast_path_count: self.instance.projection_fast_path_count, + generic_builtin_call_count: self.instance.generic_builtin_call_count, + scalar_superinstruction_count: self.instance.scalar_superinstruction_count, + local_type_hint_hit_count: self.instance.local_type_hint_hit_count, } } pub fn clear_interpreter_metrics(&mut self) { - self.operand_hint_hit_count = 0; - self.operand_hint_miss_count = 0; - self.typed_builtin_fast_path_count = 0; - self.projection_fast_path_count = 0; - self.generic_builtin_call_count = 0; - self.scalar_superinstruction_count = 0; - self.local_type_hint_hit_count = 0; + self.instance.operand_hint_hit_count = 0; + self.instance.operand_hint_miss_count = 0; + self.instance.typed_builtin_fast_path_count = 0; + self.instance.projection_fast_path_count = 0; + self.instance.generic_builtin_call_count = 0; + self.instance.scalar_superinstruction_count = 0; + self.instance.local_type_hint_hit_count = 0; } pub fn jit_native_bridge_stats_snapshot(&self) -> Vec<(&'static str, u64)> { let mut entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -900,10 +672,11 @@ impl Vm { #[allow(dead_code)] pub(in crate::vm) fn record_native_bridge_hit(&mut self, bridge_name: &'static str) { - if !self.jit_native_bridge_stats_enabled { + if !self.engine.jit_native_bridge_stats_enabled { return; } let entry = self + .engine .jit_native_bridge_counts .entry(bridge_name) .or_insert(0); @@ -916,44 +689,12 @@ impl Vm { /// Locals are reset to `Null`, stack is cleared, and instruction pointer is /// rewound to the program entry. pub fn reset_for_reuse(&mut self) { - self.invalidate_callback_registries(); self.cancel_waiting_host_op(); - self.ip = 0; - self.drop_contract_events = 0; - self.last_yield_reason = None; - self.epoch_rearm_pending = false; - self.clear_fuel(); - self.clear_epoch_deadline(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); - self.owned_callables.clear(); - self.locals.resize(self.program.local_count, Value::Null); - self.initialize_root_callable_bindings(); crate::builtins::runtime::close_all_handles(self); - self.call_depth = 0; - self.execution_frames.clear(); - self.execution_frames - .push(ExecutionFrame::root(self.program.local_count)); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - self.host_return = None; - self.queued_callables.clear(); - self.completed_callable_results.clear(); - self.owned_callables.clear(); - self.draining_queued_callables = false; - self.shutdown = false; - self.aot_interpreter_boundary_hit = self - .aot_program - .as_ref() - .is_some_and(|program| program.interpreter_boundary_only); - self.waiting_host_op = None; self.io_state = crate::builtins::runtime::IoState::default(); - self.map_iterators.clear(); - self.jit.reset_runtime_backoff(); - self.jit.clear_call_site_profiles(); - self.clear_interpreter_metrics(); + self.run_ctx.reset_for_reuse(); + self.instance.reset(&self.program); + self.engine.reset_runtime_state(&self.program); } fn validate_map_iterator_slot(&self, slot: usize) -> VmResult<()> { @@ -972,11 +713,11 @@ impl Vm { map: crate::bytecode::SharedMap, ) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; - let depth = self.call_depth; - if self.map_iterators.len() <= depth { - self.map_iterators.resize_with(depth + 1, Vec::new); + let depth = self.instance.call_depth; + if self.instance.map_iterators.len() <= depth { + self.instance.map_iterators.resize_with(depth + 1, Vec::new); } - let frame = &mut self.map_iterators[depth]; + let frame = &mut self.instance.map_iterators[depth]; if frame.len() <= slot { frame.resize_with(slot + 1, || None); } @@ -986,9 +727,13 @@ impl Vm { pub(crate) fn advance_map_iterator(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - let frame = self.map_iterators.get_mut(self.call_depth).ok_or_else(|| { - VmError::HostError("map iterator frame is not initialized".to_string()) - })?; + let frame = self + .instance + .map_iterators + .get_mut(self.instance.call_depth) + .ok_or_else(|| { + VmError::HostError("map iterator frame is not initialized".to_string()) + })?; let state = frame .get_mut(slot) .and_then(Option::as_mut) @@ -1002,8 +747,9 @@ impl Vm { pub(crate) fn take_map_iterator_key(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_key) @@ -1012,8 +758,9 @@ impl Vm { pub(crate) fn take_map_iterator_value(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_value) @@ -1023,8 +770,9 @@ impl Vm { pub(crate) fn close_map_iterator(&mut self, slot: usize) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; if let Some(state) = self + .instance .map_iterators - .get_mut(self.call_depth) + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) { *state = None; @@ -1033,7 +781,7 @@ impl Vm { } fn close_all_map_iterators(&mut self) { - for frame in &mut self.map_iterators { + for frame in &mut self.instance.map_iterators { for state in frame { state.take(); } @@ -1042,19 +790,21 @@ impl Vm { #[inline(always)] pub(super) fn active_operand_stack_base(&self) -> usize { - self.active_operand_stack_base_cache + self.instance.active_operand_stack_base_cache } #[inline(always)] pub(super) fn active_operand_stack_len(&self) -> usize { - self.stack + self.instance + .stack .len() .saturating_sub(self.active_operand_stack_base()) } #[inline(always)] pub(super) fn active_frame_key(&self) -> u64 { - self.execution_frames + self.instance + .execution_frames .last() .and_then(|frame| frame.prototype_id) .map(u64::from) @@ -1063,11 +813,11 @@ impl Vm { #[inline(always)] pub(super) fn active_local_base(&self) -> usize { - self.active_local_base_cache + self.instance.active_local_base_cache } pub(super) fn active_local_types(&self) -> Vec { - self.locals[self.active_local_base()..] + self.instance.locals[self.active_local_base()..] .iter() .map(|value| match value { Value::Null => ValueType::Null, @@ -1085,9 +835,10 @@ impl Vm { pub(super) fn active_local_callable_prototypes(&self) -> Option>> { let base = self.active_local_base(); - let mut prototypes = Vec::with_capacity(self.locals.len().saturating_sub(base)); - for (offset, value) in self.locals[base..].iter().enumerate() { - let prototype_id = if let Some(cell) = self.capture_cells.get(&(base + offset)) { + let mut prototypes = Vec::with_capacity(self.instance.locals.len().saturating_sub(base)); + for (offset, value) in self.instance.locals[base..].iter().enumerate() { + let prototype_id = if let Some(cell) = self.instance.capture_cells.get(&(base + offset)) + { let value = cell.lock().ok()?; inline_compatible_callable_prototype(&value) } else { @@ -1099,21 +850,23 @@ impl Vm { } pub(super) fn active_frame_has_shared_capture_cells(&self) -> bool { - if self.shared_capture_slots.is_empty() { + if self.instance.shared_capture_slots.is_empty() { return false; } - let Some(frame) = self.execution_frames.last() else { + let Some(frame) = self.instance.execution_frames.last() else { return false; }; let base = frame.local_base; let end = base.saturating_add(frame.local_count); - self.shared_capture_slots + self.instance + .shared_capture_slots .iter() .any(|absolute| base <= *absolute && *absolute < end) } fn script_frame_depth(&self) -> usize { - self.execution_frames + self.instance + .execution_frames .iter() .filter(|frame| frame.prototype_id.is_some()) .count() @@ -1125,7 +878,8 @@ impl Vm { .active_local_base() .checked_add(index as usize) .ok_or(VmError::InvalidLocal(index))?; - self.locals + self.instance + .locals .get(absolute) .map(|_| absolute) .ok_or(VmError::InvalidLocal(index)) @@ -1134,8 +888,8 @@ impl Vm { #[inline(always)] fn load_local_value(&self, index: u8) -> VmResult { let absolute = self.absolute_local_index(index)?; - if self.capture_cells.is_empty() { - return Ok(self.locals[absolute].clone()); + if self.instance.capture_cells.is_empty() { + return Ok(self.instance.locals[absolute].clone()); } self.load_local_value_with_captures(absolute, index) } @@ -1143,13 +897,14 @@ impl Vm { #[cold] #[inline(never)] fn load_local_value_with_captures(&self, absolute: usize, index: u8) -> VmResult { - if let Some(cell) = self.capture_cells.get(&absolute) { + if let Some(cell) = self.instance.capture_cells.get(&absolute) { return cell .lock() .map(|value| value.clone()) .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned")); } - self.locals + self.instance + .locals .get(absolute) .cloned() .ok_or(VmError::InvalidLocal(index)) @@ -1158,8 +913,8 @@ impl Vm { #[inline(always)] pub(super) fn local_numeric_value(&self, index: u8) -> Option { let absolute = self.absolute_local_index(index).ok()?; - if self.capture_cells.is_empty() { - return match self.locals.get(absolute)? { + if self.instance.capture_cells.is_empty() { + return match self.instance.locals.get(absolute)? { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1172,10 +927,14 @@ impl Vm { #[inline(never)] fn local_numeric_value_with_captures(&self, absolute: usize) -> Option { let captured = self + .instance .capture_cells .get(&absolute) .and_then(|cell| cell.lock().ok().map(|value| value.clone())); - match captured.as_ref().or_else(|| self.locals.get(absolute))? { + match captured + .as_ref() + .or_else(|| self.instance.locals.get(absolute))? + { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1183,33 +942,33 @@ impl Vm { } pub fn drop_contract_event_count(&self) -> u64 { - self.drop_contract_events + self.instance.drop_contract_events } pub fn set_drop_contract_events_enabled(&mut self, enabled: bool) { - if self.drop_contract_events_enabled != enabled { + if self.instance.drop_contract_events_enabled != enabled { self.disconnect_native_regions(); - self.native_traces.clear(); + self.engine.invalidate_codegen_caches(); } - self.drop_contract_events_enabled = enabled; + self.instance.drop_contract_events_enabled = enabled; if !enabled { - self.drop_contract_events = 0; + self.instance.drop_contract_events = 0; } } pub fn drop_contract_events_enabled(&self) -> bool { - self.drop_contract_events_enabled + self.instance.drop_contract_events_enabled } fn interruption_mode_conflict(&self, requested: InterruptMode) -> VmError { VmError::InterruptionModeConflict { - active: self.interrupt_mode.label(), + active: self.run_ctx.interrupt_mode.label(), requested: requested.label(), } } fn reset_interrupt_countdown(&mut self) { - self.fuel_ops_until_check = self.fuel_check_interval.max(1); + self.run_ctx.reset_interrupt_countdown(); } pub fn run(&mut self) -> VmResult { @@ -1227,17 +986,14 @@ impl Vm { impl Drop for Vm { fn drop(&mut self) { self.cancel_waiting_host_op(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); + self.instance.drop_cleanup(); crate::builtins::runtime::close_all_handles(self); } } impl Vm { pub(super) fn pop_value(&mut self) -> VmResult { - self.stack.pop().ok_or(VmError::StackUnderflow) + self.instance.stack.pop().ok_or(VmError::StackUnderflow) } pub(crate) fn bind_callable_value( @@ -1276,18 +1032,19 @@ impl Vm { let absolute = active_base .checked_add(usize::from(*source)) .ok_or(VmError::InvalidFrameState("capture source slot overflow"))?; - if absolute >= self.locals.len() { + if absolute >= self.instance.locals.len() { return Err(VmError::InvalidFrameState( "capture source exceeds active frame locals", )); } let cell = self + .instance .capture_cells .entry(absolute) .or_insert_with(|| Arc::new(Mutex::new(value))) .clone(); - self.shared_capture_slots.insert(absolute); - self.locals[absolute] = cell + self.instance.shared_capture_slots.insert(absolute); + self.instance.locals[absolute] = cell .lock() .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))? .clone(); @@ -1309,7 +1066,9 @@ impl Vm { kind: prototype.kind, env, }); - self.owned_callables.push(Arc::downgrade(&callable)); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); Ok(Value::Callable(callable)) } @@ -1319,11 +1078,11 @@ impl Vm { call_site_ip: Option, ) -> VmResult { let operand_count = argc as usize + 1; - if self.stack.len() < operand_count { + if self.instance.stack.len() < operand_count { return Err(VmError::StackUnderflow); } - let operand_stack_base = self.stack.len() - operand_count; - let mut operands = self.stack.split_off(operand_stack_base); + let operand_stack_base = self.instance.stack.len() - operand_count; + let mut operands = self.instance.stack.split_off(operand_stack_base); let callee = operands.remove(0); let Value::Callable(callable) = callee else { return Err(VmError::InvalidCallable); @@ -1354,15 +1113,15 @@ impl Vm { match prototype.target { CallableTarget::ScriptFunction(function_id) => { if let Some(call_ip) = call_site_ip { - self.jit.observe_script_call_target( + self.engine.jit.observe_script_call_target( self.active_frame_key(), call_ip, callable.prototype_id, ); } - if self.call_depth >= self.max_script_call_depth { + if self.instance.call_depth >= self.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: self.max_script_call_depth, + limit: self.instance.max_script_call_depth, }); } let function = self @@ -1379,10 +1138,11 @@ impl Vm { }); } let inherited_callables = self + .instance .execution_frames .last() .map(|frame| { - self.locals[frame.local_base..frame.local_base + frame.local_count] + self.instance.locals[frame.local_base..frame.local_base + frame.local_count] .iter() .enumerate() .filter(|(_, value)| matches!(value, Value::Callable(_))) @@ -1390,9 +1150,10 @@ impl Vm { .collect::>() }) .unwrap_or_default(); - let local_base = self.locals.len(); + let local_base = self.instance.locals.len(); let local_count = prototype.frame_local_count; - self.locals + self.instance + .locals .resize(local_base.saturating_add(local_count), Value::Null); for binding in &self.program.root_callable_bindings { let relative = binding.local_slot as usize; @@ -1412,12 +1173,14 @@ impl Vm { kind, env: None, }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[local_base + relative] = Value::Callable(callable); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); + self.instance.locals[local_base + relative] = Value::Callable(callable); } for (slot, value) in inherited_callables { if slot < local_count { - self.locals[local_base + slot] = value; + self.instance.locals[local_base + slot] = value; } } for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { @@ -1427,7 +1190,7 @@ impl Vm { "parameter slot is outside the script frame", )); } - self.locals[local_base + relative] = argument; + self.instance.locals[local_base + relative] = argument; } if let Some(environment) = &callable.env { let cells = environment @@ -1452,20 +1215,20 @@ impl Vm { )); } let absolute = local_base + relative; - self.locals[absolute] = cell + self.instance.locals[absolute] = cell .lock() .map_err(|_| { VmError::InvalidFrameState("capture cell lock is poisoned") })? .clone(); if prototype.self_slot != Some(*slot) { - self.capture_cells.insert(absolute, cell.clone()); + self.instance.capture_cells.insert(absolute, cell.clone()); if matches!( mode, crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut ) { - self.shared_capture_slots.insert(absolute); + self.instance.shared_capture_slots.insert(absolute); } } } @@ -1477,31 +1240,32 @@ impl Vm { "self slot is outside the script frame", )); } - self.locals[local_base + relative] = Value::Callable(callable.clone()); + self.instance.locals[local_base + relative] = Value::Callable(callable.clone()); } - let return_ip = self.ip; - self.execution_frames.push(ExecutionFrame { + let return_ip = self.instance.ip; + self.instance.execution_frames.push(ExecutionFrame { continuation: FrameContinuation::ResumeBytecode { return_ip }, operand_stack_base, local_base, local_count, prototype_id: Some(callable.prototype_id), }); - self.active_local_base_cache = local_base; - self.active_operand_stack_base_cache = operand_stack_base; - self.call_depth = self.script_frame_depth(); - self.ip = function.entry_ip as usize; + self.instance.active_local_base_cache = local_base; + self.instance.active_operand_stack_base_cache = operand_stack_base; + self.instance.call_depth = self.script_frame_depth(); + self.instance.ip = function.entry_ip as usize; self.charge_interrupt_tick()?; Ok(ExecOutcome::Continue) } CallableTarget::HostImport(import_index) => { - self.stack.extend(operands); - let call_ip = self.ip.saturating_sub(2); + self.instance.stack.extend(operands); + let call_ip = self.instance.ip.saturating_sub(2); match self.execute_host_call(import_index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(ExecOutcome::Continue), HostCallExecOutcome::Halted => Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.stack + self.instance + .stack .insert(operand_stack_base, Value::Callable(callable)); Ok(ExecOutcome::Yielded) } @@ -1513,45 +1277,57 @@ impl Vm { fn complete_active_frame(&mut self) -> VmResult { let frame = self + .instance .execution_frames .pop() .ok_or(VmError::InvalidFrameState("missing active frame"))?; - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - if self.stack.len() < frame.operand_stack_base { + if self.instance.stack.len() < frame.operand_stack_base { return Err(VmError::InvalidFrameState( "operand stack is below the active frame base", )); } if matches!(frame.continuation, FrameContinuation::Halt) { - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); return Ok(ExecOutcome::Halted); } - let result = if self.stack.len() > frame.operand_stack_base { - self.stack.pop().expect("stack length checked above") + let result = if self.instance.stack.len() > frame.operand_stack_base { + self.instance + .stack + .pop() + .expect("stack length checked above") } else { Value::Null }; - while self.stack.len() > frame.operand_stack_base { - let value = self.stack.pop().expect("stack length checked above"); + while self.instance.stack.len() > frame.operand_stack_base { + let value = self + .instance + .stack + .pop() + .expect("stack length checked above"); self.drop_value_with_contract(value); } - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); if frame.prototype_id.is_some() { let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); } @@ -1560,12 +1336,16 @@ impl Vm { .local_base .checked_add(frame.local_count) .ok_or(VmError::InvalidFrameState("local frame range overflow"))?; - if frame_end != self.locals.len() { + if frame_end != self.instance.locals.len() { return Err(VmError::InvalidFrameState( "active local frame does not end at the local stack tail", )); } - let drained = self.locals.drain(frame.local_base..).collect::>(); + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } @@ -1585,16 +1365,16 @@ impl Vm { match frame.continuation { FrameContinuation::Halt => { - self.stack.push(result); + self.instance.stack.push(result); Ok(ExecOutcome::Halted) } FrameContinuation::ResumeBytecode { return_ip } => { - self.ip = return_ip; - self.stack.push(result); + self.instance.ip = return_ip; + self.instance.stack.push(result); Ok(ExecOutcome::Continue) } FrameContinuation::ReturnToHost => { - self.host_return = Some(result); + self.instance.host_return = Some(result); Ok(ExecOutcome::Halted) } } @@ -1602,25 +1382,25 @@ impl Vm { pub(super) fn can_fuse_call_ret_pattern(&self) -> bool { let code = &self.program.code; - self.ip < code.len() && code[self.ip] == OpCode::Ret as u8 + self.instance.ip < code.len() && code[self.instance.ip] == OpCode::Ret as u8 } pub(super) fn clear_stack_with_drop_contract(&mut self) { - let drained = self.stack.drain(..).collect::>(); + let drained = self.instance.stack.drain(..).collect::>(); for value in drained { self.drop_value_with_contract(value); } } pub(super) fn clear_locals_with_drop_contract(&mut self) { - for slot in 0..self.locals.len() { - let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + for slot in 0..self.instance.locals.len() { + let previous = std::mem::replace(&mut self.instance.locals[slot], Value::Null); self.drop_value_with_contract(previous); } } pub(super) fn drop_value_with_contract(&mut self, value: Value) { - if self.drop_contract_events_enabled { + if self.instance.drop_contract_events_enabled { self.count_value_drop_contract(&value); } } @@ -1629,13 +1409,15 @@ impl Vm { match value { Value::Null => {} Value::Array(values) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for item in values.iter() { self.count_value_drop_contract(item); } } Value::Map(entries) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for (key, value) in entries.iter() { self.count_value_drop_contract(key); self.count_value_drop_contract(value); @@ -1647,14 +1429,15 @@ impl Vm { | Value::String(_) | Value::Bytes(_) | Value::Callable(_) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); } } } #[inline(always)] pub(in crate::vm) fn charge_interrupt_tick(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel_tick(), InterruptMode::Epoch => self.charge_epoch_tick(), @@ -1664,15 +1447,15 @@ impl Vm { #[inline(always)] #[allow(dead_code)] pub(in crate::vm) fn charge_aot_call_boundary_interrupt(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel(1), InterruptMode::Epoch => { let current = self.current_epoch(); - if current >= self.epoch_deadline { + if current >= self.run_ctx.epoch_deadline { return Err(VmError::EpochDeadlineReached { current, - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }); } Ok(()) @@ -1681,7 +1464,7 @@ impl Vm { } pub(super) fn peek_value(&self) -> VmResult<&Value> { - self.stack.last().ok_or(VmError::StackUnderflow) + self.instance.stack.last().ok_or(VmError::StackUnderflow) } pub(super) fn pop_int(&mut self) -> VmResult { @@ -1705,7 +1488,8 @@ impl Vm { #[inline(always)] pub(super) fn operand_type_hint(&self, ip: usize) -> PackedOperandTypes { - self.operand_type_hints + self.engine + .operand_type_hints .as_deref() .map_or(NO_OPERAND_TYPE_HINT, |hints| hints[ip]) } @@ -1727,57 +1511,68 @@ impl Vm { #[inline(always)] pub(super) fn record_local_type_hint_hit(&mut self) { - self.local_type_hint_hit_count = self.local_type_hint_hit_count.saturating_add(1); + self.instance.local_type_hint_hit_count = + self.instance.local_type_hint_hit_count.saturating_add(1); } #[inline(always)] pub(super) fn record_scalar_superinstruction(&mut self) { - self.scalar_superinstruction_count = self.scalar_superinstruction_count.saturating_add(1); + self.instance.scalar_superinstruction_count = self + .instance + .scalar_superinstruction_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_typed_builtin_fast_path(&mut self) { - self.typed_builtin_fast_path_count = self.typed_builtin_fast_path_count.saturating_add(1); + self.instance.typed_builtin_fast_path_count = self + .instance + .typed_builtin_fast_path_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_projection_fast_path(&mut self) { - self.projection_fast_path_count = self.projection_fast_path_count.saturating_add(1); + self.instance.projection_fast_path_count = + self.instance.projection_fast_path_count.saturating_add(1); } #[inline(always)] pub(super) fn record_generic_builtin_call(&mut self) { - self.generic_builtin_call_count = self.generic_builtin_call_count.saturating_add(1); + self.instance.generic_builtin_call_count = + self.instance.generic_builtin_call_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_hit(&mut self) { - self.operand_hint_hit_count = self.operand_hint_hit_count.saturating_add(1); + self.instance.operand_hint_hit_count = + self.instance.operand_hint_hit_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_miss(&mut self) { - self.operand_hint_miss_count = self.operand_hint_miss_count.saturating_add(1); + self.instance.operand_hint_miss_count = + self.instance.operand_hint_miss_count.saturating_add(1); } #[inline(always)] pub(super) fn unary_not_op(&mut self) -> VmResult<()> { let value = self.pop_bool()?; - self.stack.push(Value::Bool(!value)); + self.instance.stack.push(Value::Bool(!value)); Ok(()) } pub(super) fn int_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_add(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))); Ok(()) } pub(super) fn float_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(lhs + rhs)); + self.instance.stack.push(Value::Float(lhs + rhs)); Ok(()) } @@ -1793,7 +1588,7 @@ impl Vm { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); Ok(()) } @@ -1808,7 +1603,7 @@ impl Vm { }; let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); Ok(()) } @@ -1818,7 +1613,7 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(op(lhs, rhs)?)); Ok(()) } @@ -1828,40 +1623,40 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(op(lhs, rhs)?)); Ok(()) } pub(super) fn int_neg_op(&mut self) -> VmResult<()> { let value = self.pop_int()?; - self.stack.push(Value::Int(value.wrapping_neg())); + self.instance.stack.push(Value::Int(value.wrapping_neg())); Ok(()) } pub(super) fn float_neg_op(&mut self) -> VmResult<()> { let value = self.pop_float_exact()?; - self.stack.push(Value::Float(-value)); + self.instance.stack.push(Value::Float(-value)); Ok(()) } pub(super) fn int_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn float_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn bool_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1874,7 +1669,7 @@ impl Vm { Value::String(value) => value, _ => return Err(VmError::TypeMismatch("string")), }; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1883,7 +1678,7 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Null, Value::Null) => { - self.stack.push(Value::Bool(true)); + self.instance.stack.push(Value::Bool(true)); Ok(()) } _ => Err(VmError::TypeMismatch("null")), @@ -1893,14 +1688,14 @@ impl Vm { pub(super) fn int_compare_op(&mut self, op: impl FnOnce(i64, i64) -> bool) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } pub(super) fn float_compare_op(&mut self, op: impl FnOnce(f64, f64) -> bool) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } @@ -1909,26 +1704,32 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Int(lhs), Value::Int(rhs)) => { - self.stack.push(Value::Int(lhs.wrapping_add(rhs))) + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))) + } + (Value::Int(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs as f64 + rhs)) + } + (Value::Float(lhs), Value::Int(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs as f64)) + } + (Value::Float(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs)) } - (Value::Int(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs as f64 + rhs)), - (Value::Float(lhs), Value::Int(rhs)) => self.stack.push(Value::Float(lhs + rhs as f64)), - (Value::Float(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs + rhs)), (Value::String(lhs), Value::String(rhs)) => { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); } (Value::Bytes(lhs), Value::Bytes(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); } (Value::Array(lhs), Value::Array(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::array(out)); + self.instance.stack.push(Value::array(out)); } _ => { return Err(VmError::TypeMismatch( @@ -1948,7 +1749,7 @@ impl Vm { let lhs = self.pop_numeric()?; match (lhs, rhs) { (NumericValue::Int(lhs), NumericValue::Int(rhs)) => { - self.stack.push(Value::Int(int_op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(int_op(lhs, rhs)?)); } (lhs, rhs) => { let lhs = match lhs { @@ -1959,7 +1760,7 @@ impl Vm { NumericValue::Int(v) => v as f64, NumericValue::Float(v) => v, }; - self.stack.push(Value::Float(float_op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(float_op(lhs, rhs)?)); } } Ok(()) @@ -1986,7 +1787,7 @@ impl Vm { float_op(lhs, rhs) } }; - self.stack.push(Value::Bool(result)); + self.instance.stack.push(Value::Bool(result)); Ok(()) } @@ -2015,8 +1816,9 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if self.capture_cells.is_empty() { + if self.instance.capture_cells.is_empty() { let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2035,7 +1837,7 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { if Self::value_references_capture_cell(&value, &cell, &mut HashSet::new())? { return Err(VmError::InvalidFrameState( "callable capture ownership cycle is unsupported", @@ -2047,11 +1849,12 @@ impl Vm { .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; std::mem::replace(&mut *captured, value.clone()) }; - self.locals[absolute] = value; + self.instance.locals[absolute] = value; self.drop_value_with_contract(previous); return Ok(()); } let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2113,8 +1916,9 @@ impl Vm { pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; - self.capture_cells.remove(&absolute); + self.instance.capture_cells.remove(&absolute); let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2124,11 +1928,11 @@ impl Vm { } pub(super) fn read_u8(&mut self) -> VmResult { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } - let value = self.program.code[self.ip]; - self.ip += 1; + let value = self.program.code[self.instance.ip]; + self.instance.ip += 1; Ok(value) } @@ -2143,12 +1947,13 @@ impl Vm { } pub(super) fn read_bytes(&mut self, count: usize) -> VmResult<[u8; 4]> { - if self.ip + count > self.program.code.len() { + if self.instance.ip + count > self.program.code.len() { return Err(VmError::BytecodeBounds); } let mut buf = [0u8; 4]; - buf[..count].copy_from_slice(&self.program.code[self.ip..self.ip + count]); - self.ip += count; + buf[..count] + .copy_from_slice(&self.program.code[self.instance.ip..self.instance.ip + count]); + self.instance.ip += count; Ok(buf) } @@ -2158,6 +1963,7 @@ impl Vm { } if !self.program.function_regions.is_empty() { let active_prototype = self + .instance .execution_frames .last() .and_then(|frame| frame.prototype_id); @@ -2191,7 +1997,7 @@ impl Vm { return Err(VmError::InvalidBranchTarget { target }); } } - self.ip = target; + self.instance.ip = target; Ok(()) } } @@ -2248,10 +2054,10 @@ impl Vm { ) -> Option { match outcome { ExecOutcome::Continue => {} - ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.last_yield_reason = None, + ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.instance.last_yield_reason = None, ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } } } @@ -2274,7 +2080,7 @@ impl Vm { fn run_fast_interpreter(&mut self, allow_jit: bool) -> VmResult> { loop { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } let opcode = self.read_u8()?; @@ -2282,17 +2088,17 @@ impl Vm { match outcome { ExecOutcome::Continue => {} ExecOutcome::Halted => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Halted)); } ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } return Ok(Some(VmStatus::Yielded)); } ExecOutcome::Waiting(op_id) => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Waiting(op_id))); } } @@ -2312,28 +2118,28 @@ impl Vm { ) -> VmResult { self.ensure_call_bindings()?; self.sync_jit_non_yielding_host_imports(); - if let Some(waiting) = self.waiting_host_op { - self.last_yield_reason = None; + if let Some(waiting) = self.instance.waiting_host_op { + self.instance.last_yield_reason = None; let status = VmStatus::Waiting(waiting.op_id); self.notify_debugger_status(&mut debugger, status); return Ok(status); } - self.last_yield_reason = None; - if self.epoch_rearm_pending { + self.instance.last_yield_reason = None; + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if debugger.is_none() && !self.interruption_enabled() && (!allow_jit || (!self.jit_config().enabled - && (!self.has_aot_program() || self.aot_interpreter_boundary_hit))) + && (!self.has_aot_program() || self.engine.aot_interpreter_boundary_hit))) && let Some(status) = self.run_fast_interpreter(allow_jit)? { return Ok(status); } loop { - if self.epoch_rearm_pending { + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if let Some(active_debugger) = debugger.as_deref_mut() { @@ -2342,7 +2148,7 @@ impl Vm { if allow_jit && self.has_aot_program() - && !self.aot_interpreter_boundary_hit + && !self.engine.aot_interpreter_boundary_hit && !self.drop_contract_events_enabled() { let outcome = match self.execute_aot_entry() { @@ -2369,7 +2175,7 @@ impl Vm { continue; } - if self.aot_interpreter_boundary_hit + if self.engine.aot_interpreter_boundary_hit && debugger.is_none() && !self.interruption_enabled() && !self.jit_config().enabled @@ -2380,12 +2186,12 @@ impl Vm { if allow_jit && self.jit_config().enabled - && self.builtin_overrides.is_empty() + && self.host.builtin_overrides.is_empty() && !self.drop_contract_events_enabled() && !self.active_frame_has_shared_capture_cells() { let frame_key = self.active_frame_key(); - let trace_id = if self.jit.callable_frame_is_blocked(frame_key) { + let trace_id = if self.engine.jit.callable_frame_is_blocked(frame_key) { None } else { let stack_depth = self.active_operand_stack_len(); @@ -2393,9 +2199,9 @@ impl Vm { .then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_hot_entry_with_local_types( + self.engine.jit.observe_hot_entry_with_local_types( frame_key, - self.ip, + self.instance.ip, stack_depth, entry_local_types.as_deref(), entry_callable_prototypes.as_deref(), @@ -2428,7 +2234,7 @@ impl Vm { } } - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } @@ -2487,9 +2293,9 @@ impl Vm { x if x == OpCode::Nop as u8 => {} x if x == OpCode::Ret as u8 => return self.complete_active_frame(), x if x == OpCode::Ldc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let value = if let Some(value) = self.decoded_ldc_value_at(opcode_ip).cloned() { - self.ip += 4; + self.instance.ip += 4; value } else { let index = self.read_u32()?; @@ -2499,10 +2305,10 @@ impl Vm { .cloned() .ok_or(VmError::InvalidConstant(index))? }; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Add as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2527,7 +2333,7 @@ impl Vm { } } x if x == OpCode::Sub as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2547,7 +2353,7 @@ impl Vm { } } x if x == OpCode::Mul as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2567,7 +2373,7 @@ impl Vm { } } x if x == OpCode::Div as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2586,20 +2392,22 @@ impl Vm { x if x == OpCode::Shl as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shl(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shl(rhs))); } x if x == OpCode::Shr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shr(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shr(rhs))); } x if x == OpCode::Lshr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(logical_shr_i64(lhs, rhs))); + self.instance + .stack + .push(Value::Int(logical_shr_i64(lhs, rhs))); } x if x == OpCode::Mod as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2618,16 +2426,16 @@ impl Vm { x if x == OpCode::And as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs && rhs)); + self.instance.stack.push(Value::Bool(lhs && rhs)); } x if x == OpCode::Or as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs || rhs)); + self.instance.stack.push(Value::Bool(lhs || rhs)); } x if x == OpCode::Not as u8 => self.unary_not_op()?, x if x == OpCode::Neg as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_UNARY_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2641,15 +2449,17 @@ impl Vm { self.record_operand_hint_miss(); match self.pop_numeric()? { NumericValue::Int(value) => { - self.stack.push(Value::Int(value.wrapping_neg())) + self.instance.stack.push(Value::Int(value.wrapping_neg())) + } + NumericValue::Float(value) => { + self.instance.stack.push(Value::Float(-value)) } - NumericValue::Float(value) => self.stack.push(Value::Float(-value)), } } } } x if x == OpCode::Ceq as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2675,12 +2485,12 @@ impl Vm { self.record_operand_hint_miss(); let rhs = self.pop_value()?; let lhs = self.pop_value()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); } } } x if x == OpCode::Clt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2697,7 +2507,7 @@ impl Vm { } } x if x == OpCode::Cgt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2714,23 +2524,23 @@ impl Vm { } } x if x == OpCode::Br as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize }; if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } } x if x == OpCode::Brfalse as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize @@ -2738,7 +2548,7 @@ impl Vm { let condition = self.pop_bool()?; if !condition { if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } @@ -2749,12 +2559,12 @@ impl Vm { } x if x == OpCode::Dup as u8 => { let value = self.peek_value()?.clone(); - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Ldloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2763,12 +2573,12 @@ impl Vm { return Ok(ExecOutcome::Continue); } let value = self.load_local_value(index)?; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Stloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2777,7 +2587,7 @@ impl Vm { self.store_local_with_drop_contract(index, value)?; } x if x == OpCode::Call as u8 => { - let call_ip = self.ip - 1; + let call_ip = self.instance.ip - 1; let index = self.read_u16()?; let argc_u8 = self.read_u8()?; let can_fuse_tail_halt = self.can_fuse_call_ret_pattern(); @@ -2787,13 +2597,13 @@ impl Vm { if self.interruption_enabled() { self.charge_interrupt_tick()?; } - self.ip = self.ip.saturating_add(1); + self.instance.ip = self.instance.ip.saturating_add(1); return self.complete_active_frame(); } } HostCallExecOutcome::Halted => return Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.last_yield_reason = Some(VmYieldReason::Host); + self.instance.last_yield_reason = Some(VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } HostCallExecOutcome::Pending(op_id) => return Ok(ExecOutcome::Waiting(op_id)), @@ -2801,7 +2611,7 @@ impl Vm { } x if x == OpCode::CallValue as u8 => { - let call_ip = self.ip.saturating_sub(1); + let call_ip = self.instance.ip.saturating_sub(1); let argc = self.read_u8()?; return self.execute_call_value(argc, Some(call_ip)); } @@ -2812,7 +2622,8 @@ impl Vm { pub fn resume(&mut self) -> VmResult { let allow_jit = !matches!( - self.execution_frames + self.instance + .execution_frames .last() .map(|frame| &frame.continuation), Some(FrameContinuation::ReturnToHost) @@ -2821,16 +2632,16 @@ impl Vm { } pub fn stack(&self) -> &[Value] { - &self.stack + &self.instance.stack } pub fn locals(&self) -> &[Value] { - &self.locals + &self.instance.locals } pub fn set_local(&mut self, index: u8, value: Value) -> VmResult<()> { self.store_local_with_drop_contract(index, value)?; - let config = *self.jit.config(); + let config = *self.engine.jit.config(); self.set_jit_config(config); Ok(()) } @@ -2840,22 +2651,22 @@ impl Vm { } pub fn bound_function_count(&self) -> usize { - self.host_functions.len() + self.host.host_functions.len() } pub fn has_bound_function(&self, name: &str) -> bool { - self.host_function_symbols.contains_key(name) + self.host.host_function_symbols.contains_key(name) } pub fn ip(&self) -> usize { - self.ip + self.instance.ip } pub(super) fn owns_callable(&self, value: &Value) -> bool { let Value::Callable(target) = value else { return false; }; - self.owned_callables.iter().any(|owned| { + self.instance.owned_callables.iter().any(|owned| { owned .upgrade() .is_some_and(|owned| Arc::ptr_eq(&owned, target)) @@ -2872,6 +2683,7 @@ impl Vm { VmError::HostError(format!("unknown exported script function '{name}'")) })?; let value = self + .instance .locals .get(exported.local_slot as usize) .cloned() @@ -2892,7 +2704,7 @@ impl Vm { } pub fn call_depth(&self) -> usize { - self.call_depth + self.instance.call_depth } pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { @@ -2905,13 +2717,13 @@ impl Vm { args: Vec, subscription: Option>, ) -> VmResult<()> { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - self.queued_callables.push_back(QueuedCallable { + self.instance.queued_callables.push_back(QueuedCallable { callable, args, subscription, @@ -2920,23 +2732,23 @@ impl Vm { } pub fn queued_callable_count(&self) -> usize { - self.queued_callables.len() + self.instance.queued_callables.len() } pub fn drain_callable_queue(&mut self) -> VmResult> { - if self.draining_queued_callables { + if self.instance.draining_queued_callables { return Err(VmError::InvalidFrameState( "callable queue is already being drained", )); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "queued callables can only run after the root frame halts", )); } - self.draining_queued_callables = true; - let mut results = Vec::with_capacity(self.queued_callables.len()); - while let Some(queued) = self.queued_callables.pop_front() { + self.instance.draining_queued_callables = true; + let mut results = Vec::with_capacity(self.instance.queued_callables.len()); + while let Some(queued) = self.instance.queued_callables.pop_front() { if queued .subscription .as_ref() @@ -2946,9 +2758,9 @@ impl Vm { } match self.start_callable(queued.callable, &queued.args) { Ok(VmStatus::Halted) => { - let Some(result) = self.host_return.take() else { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + let Some(result) = self.instance.host_return.take() else { + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation completed without a result", )); @@ -2956,84 +2768,78 @@ impl Vm { results.push(result); } Ok(VmStatus::Yielded) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation yielded; resume it before draining again", )); } Ok(VmStatus::Waiting(_)) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation is waiting; resume it before draining again", )); } Err(err) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(err); } } } - self.draining_queued_callables = false; + self.instance.draining_queued_callables = false; Ok(results) } pub fn shutdown(&mut self) { self.invalidate_callback_registries(); self.cancel_waiting_host_op(); - self.queued_callables.clear(); - self.completed_callable_results.clear(); - self.owned_callables.clear(); - self.draining_queued_callables = false; + self.instance.queued_callables.clear(); + self.instance.completed_callable_results.clear(); + self.instance.owned_callables.clear(); + self.instance.draining_queued_callables = false; self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); + self.instance.capture_cells.clear(); + self.instance.shared_capture_slots.clear(); self.clear_locals_with_drop_contract(); - self.execution_frames.clear(); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - self.call_depth = 0; - self.host_return = None; - self.waiting_host_op = None; + self.instance.execution_frames.clear(); + self.instance.active_local_base_cache = 0; + self.instance.active_operand_stack_base_cache = 0; + self.instance.call_depth = 0; + self.instance.host_return = None; + self.instance.waiting_host_op = None; crate::builtins::runtime::close_all_handles(self); - self.shutdown = true; + self.instance.shutdown = true; } pub(super) fn register_callback_registry(&mut self, active: &Arc) { - self.callback_registry_flags.push(Arc::downgrade(active)); + self.instance.register_callback_registry(active); } fn invalidate_callback_registries(&mut self) { - for active in self - .callback_registry_flags - .drain(..) - .filter_map(|flag| flag.upgrade()) - { - active.store(false, Ordering::Release); - } + self.instance.invalidate_callback_registries(); } pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "host invocation requires a halted VM", )); } let argc = u8::try_from(args.len()) .map_err(|_| VmError::InvalidFrameState("too many arguments"))?; - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); - self.stack.push(callable); - self.stack.extend_from_slice(args); - self.host_return = None; + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); + self.instance.stack.push(callable); + self.instance.stack.extend_from_slice(args); + self.instance.host_return = None; let outcome = match self.execute_call_value(argc, None) { Ok(outcome) => outcome, Err(error) => { @@ -3041,10 +2847,10 @@ impl Vm { return Err(error); } }; - if self.execution_frames.len() == frame_count { + if self.instance.execution_frames.len() == frame_count { let result = match outcome { ExecOutcome::Continue | ExecOutcome::Halted => { - self.stack.pop().unwrap_or(Value::Null) + self.instance.stack.pop().unwrap_or(Value::Null) } ExecOutcome::Yielded => { self.abort_host_invocation(stack_base, frame_count); @@ -3059,11 +2865,11 @@ impl Vm { )); } }; - self.stack.truncate(stack_base); - self.host_return = Some(result); + self.instance.stack.truncate(stack_base); + self.instance.host_return = Some(result); return Ok(VmStatus::Halted); } - if let Some(frame) = self.execution_frames.last_mut() { + if let Some(frame) = self.instance.execution_frames.last_mut() { frame.continuation = FrameContinuation::ReturnToHost; } match self.run_internal(None, false) { @@ -3076,12 +2882,16 @@ impl Vm { } pub fn invoke_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); match self.start_callable(callable, args)? { - VmStatus::Halted => self.host_return.take().ok_or(VmError::InvalidFrameState( - "host invocation completed without a result", - )), + VmStatus::Halted => self + .instance + .host_return + .take() + .ok_or(VmError::InvalidFrameState( + "host invocation completed without a result", + )), VmStatus::Yielded => { self.abort_host_invocation(stack_base, frame_count); Err(VmError::InvalidFrameState("host invocation yielded")) @@ -3094,53 +2904,64 @@ impl Vm { } fn abort_host_invocation(&mut self, stack_base: usize, frame_count: usize) { - while self.execution_frames.len() > frame_count { - let Some(frame) = self.execution_frames.pop() else { + while self.instance.execution_frames.len() > frame_count { + let Some(frame) = self.instance.execution_frames.pop() else { break; }; let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); - if frame.local_base <= self.locals.len() { - let drained = self.locals.drain(frame.local_base..).collect::>(); + if frame.local_base <= self.instance.locals.len() { + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } } } - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - while self.stack.len() > stack_base { - if let Some(value) = self.stack.pop() { + while self.instance.stack.len() > stack_base { + if let Some(value) = self.instance.stack.pop() { self.drop_value_with_contract(value); } } - self.call_depth = self.script_frame_depth(); - self.host_return = None; + self.instance.call_depth = self.script_frame_depth(); + self.instance.host_return = None; self.cancel_waiting_host_op(); - self.last_yield_reason = None; - self.map_iterators - .truncate(self.call_depth.saturating_add(1)); + self.instance.last_yield_reason = None; + self.instance + .map_iterators + .truncate(self.instance.call_depth.saturating_add(1)); } pub fn take_callable_result(&mut self) -> Option { - self.completed_callable_results + self.instance + .completed_callable_results .pop_front() - .or_else(|| self.host_return.take()) + .or_else(|| self.instance.host_return.take()) } pub fn execution_frames(&self) -> Vec { - self.execution_frames + self.instance + .execution_frames .iter() .map(|frame| VmExecutionFrameSnapshot { continuation: match frame.continuation { diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index ff692ba3..357f6b21 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -393,12 +393,12 @@ pub(crate) fn non_yielding_i64_host_call_entry_address() -> usize { } pub(crate) fn helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_helper_fn)) .expect("Vm::native_helper_fn offset must fit i32") } pub(crate) fn interrupt_helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_interrupt_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_interrupt_helper_fn)) .expect("Vm::native_interrupt_helper_fn offset must fit i32") } @@ -780,10 +780,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( ip: usize, ) -> i32 { run_step(vm, "restore_exit_state", |vm| { - if locals_len != vm.locals.len() { + if locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), locals_len ))); } @@ -799,10 +799,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -819,13 +819,14 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } fn native_frame_state(vm: &Vm) -> VmResult { - let frame = vm.execution_frames.last(); + let frame = vm.instance.execution_frames.last(); let operand_stack_base = frame.map(|frame| frame.operand_stack_base).unwrap_or(0); let local_base = frame.map(|frame| frame.local_base).unwrap_or(0); let local_count = frame .map(|frame| frame.local_count) - .unwrap_or(vm.locals.len()); + .unwrap_or(vm.instance.locals.len()); let active_stack_len = vm + .instance .stack .len() .checked_sub(operand_stack_base) @@ -847,7 +848,7 @@ fn native_frame_state(vm: &Vm) -> VmResult { active_stack_len, local_base, local_count, - frame_depth: vm.call_depth, + frame_depth: vm.instance.call_depth, continuation_kind, }) } @@ -900,7 +901,7 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { packet .add(INHERITED_STATE_TARGET_IP_OFFSET as usize) .cast::() - .write(vm.ip); + .write(vm.instance.ip); packet .add(INHERITED_STATE_VALUE_COUNT_OFFSET as usize) .cast::() @@ -908,11 +909,11 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { let values = packet .add(INHERITED_STATE_VALUES_OFFSET as usize) .cast::<*const Value>(); - let stack = vm.stack.as_ptr().add(state.operand_stack_base); + let stack = vm.instance.stack.as_ptr().add(state.operand_stack_base); for index in 0..state.active_stack_len { values.add(index).write(stack.add(index)); } - let locals = vm.locals.as_ptr().add(state.local_base); + let locals = vm.instance.locals.as_ptr().add(state.local_base); for index in 0..state.local_count { values .add(state.active_stack_len + index) @@ -957,13 +958,13 @@ fn native_enter_call_value( .map_err(|_| VmError::InvalidFrameState("native call ip out of range"))?; let resume_ip = usize::try_from(resume_ip) .map_err(|_| VmError::InvalidFrameState("native resume ip out of range"))?; - if vm.ip != call_ip { + if vm.instance.ip != call_ip { vm.jump_to(call_ip)?; } if resume_ip > vm.program.code.len() { return Err(VmError::BytecodeBounds); } - vm.ip = resume_ip; + vm.instance.ip = resume_ip; let status = match vm.execute_call_value(argc, Some(call_ip))? { ExecOutcome::Continue => STATUS_LINKED_CONTINUE, ExecOutcome::Halted => STATUS_HALTED, @@ -1067,17 +1068,17 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( let expected_locals_len = local_base .checked_add(locals_len) .ok_or_else(|| VmError::JitNative("native active local length overflow".to_string()))?; - if expected_locals_len != vm.locals.len() { + if expected_locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native active exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), expected_locals_len ))); } - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } if stack_len != 0 && stack_src.is_null() { @@ -1091,11 +1092,11 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( )); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -1145,10 +1146,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( "native sparse exit restore local index out of range".to_string(), ) })?; - if local_index_usize >= vm.locals.len() { + if local_index_usize >= vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native sparse exit restore local index {local_index} out of range for {} locals", - vm.locals.len() + vm.instance.locals.len() ))); } let local_index = u8::try_from(local_index).map_err(|_| { @@ -1165,10 +1166,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for (compact_index, local_index) in validated_indices.into_iter().enumerate() { @@ -1212,29 +1213,29 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( // while the sparse exit metadata is built. let stack_base = vm.active_operand_stack_base(); - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active sparse stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } - if vm.capture_cells.is_empty() { + if vm.instance.capture_cells.is_empty() { let local_base = vm.active_local_base(); - let count_drop_events = vm.drop_contract_events_enabled; + let count_drop_events = vm.instance.drop_contract_events_enabled; for compact_index in 0..dirty_local_count { let local_index = unsafe { *dirty_local_indices.add(compact_index) } as usize; debug_assert!(local_index < 256); let absolute = local_base + local_index; - debug_assert!(absolute < vm.locals.len()); + debug_assert!(absolute < vm.instance.locals.len()); let value = unsafe { std::ptr::read(dirty_local_values.add(compact_index)) }; - let slot = unsafe { vm.locals.get_unchecked_mut(absolute) }; + let slot = unsafe { vm.instance.locals.get_unchecked_mut(absolute) }; let previous = std::mem::replace(slot, value); if count_drop_events { vm.count_value_drop_contract(&previous); @@ -1253,7 +1254,7 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( if ip >= vm.program.code.len() { return Err(VmError::InvalidBranchTarget { target: ip }); } - vm.ip = ip; + vm.instance.ip = ip; Ok(STATUS_CONTINUE) }) } @@ -1281,9 +1282,9 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( "virtual frame restore received null locals buffer".to_string(), )); } - if vm.call_depth >= vm.max_script_call_depth { + if vm.instance.call_depth >= vm.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: vm.max_script_call_depth, + limit: vm.instance.max_script_call_depth, }); } let prototype = vm @@ -1326,29 +1327,31 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( )); } - let operand_stack_base = vm.stack.len(); - let local_base = vm.locals.len(); - vm.stack.reserve(stack_len); - vm.locals.reserve(locals_len); + let operand_stack_base = vm.instance.stack.len(); + let local_base = vm.instance.locals.len(); + vm.instance.stack.reserve(stack_len); + vm.instance.locals.reserve(locals_len); for index in 0..stack_len { - vm.stack + vm.instance + .stack .push(unsafe { std::ptr::read(stack_src.add(index)) }); } for index in 0..locals_len { - vm.locals + vm.instance + .locals .push(unsafe { std::ptr::read(locals_src.add(index)) }); } - vm.execution_frames.push(ExecutionFrame { + vm.instance.execution_frames.push(ExecutionFrame { continuation: FrameContinuation::ResumeBytecode { return_ip }, operand_stack_base, local_base, local_count: locals_len, prototype_id: Some(prototype_id), }); - vm.active_local_base_cache = local_base; - vm.active_operand_stack_base_cache = operand_stack_base; - vm.call_depth = vm.script_frame_depth(); - vm.ip = resume_ip; + vm.instance.active_local_base_cache = local_base; + vm.instance.active_operand_stack_base_cache = operand_stack_base; + vm.instance.call_depth = vm.script_frame_depth(); + vm.instance.ip = resume_ip; Ok(STATUS_CONTINUE) }) } @@ -1662,10 +1665,11 @@ fn call_non_yielding_host_value( expected_return_type: Option, ) -> VmResult { let resolved = *vm + .host .resolved_calls .get(import) .ok_or(VmError::InvalidCall(import as u16))?; - let function = match vm.host_functions.get(usize::from(resolved)) { + let function = match vm.host.host_functions.get(usize::from(resolved)) { Some(VmHostFunction::ArgsStaticNonYielding(function)) => *function, _ => { return Err(VmError::JitNative( @@ -1673,9 +1677,9 @@ fn call_non_yielding_host_value( )); } }; - vm.call_depth = vm.call_depth.saturating_add(1); + vm.instance.call_depth = vm.instance.call_depth.saturating_add(1); let outcome = function(args); - vm.call_depth = vm.call_depth.saturating_sub(1); + vm.instance.call_depth = vm.instance.call_depth.saturating_sub(1); outcome .and_then(crate::vm::host::require_non_yielding_host_value) .and_then(|value| { @@ -1809,7 +1813,7 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, .get(index as usize) .cloned() .ok_or(VmError::InvalidConstant(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_ADD => { @@ -1841,34 +1845,41 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, OP_SHL => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shl(rhs))); Ok(STATUS_CONTINUE) } OP_SHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shr(rhs))); Ok(STATUS_CONTINUE) } OP_LSHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(logical_shr_i64(lhs, rhs))); Ok(STATUS_CONTINUE) } OP_AND => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs && rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs && rhs)); Ok(STATUS_CONTINUE) } OP_OR => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs || rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs || rhs)); Ok(STATUS_CONTINUE) } OP_NOT => { @@ -1879,18 +1890,22 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, let value = vm.pop_numeric()?; match value { NumericValue::Int(value) => vm + .instance .stack .push(crate::bytecode::Value::Int(value.wrapping_neg())), - NumericValue::Float(value) => { - vm.stack.push(crate::bytecode::Value::Float(-value)) - } + NumericValue::Float(value) => vm + .instance + .stack + .push(crate::bytecode::Value::Float(-value)), } Ok(STATUS_CONTINUE) } OP_CEQ => { let rhs = vm.pop_value()?; let lhs = vm.pop_value()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs == rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs == rhs)); Ok(STATUS_CONTINUE) } OP_CLT => { @@ -1907,18 +1922,19 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, } OP_DUP => { let value = vm.peek_value()?.clone(); - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_LDLOC => { let index = u8::try_from(a) .map_err(|_| VmError::JitNative("ldloc index out of range".to_string()))?; let value = vm + .instance .locals .get(index as usize) .cloned() .ok_or(VmError::InvalidLocal(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_STLOC => { @@ -2046,11 +2062,11 @@ mod tests { let mut vm = Vm::new(virtual_frame_program()); let locals = [Value::Int(7)]; let before = ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ); let status = pd_vm_native_restore_virtual_frame( &mut vm, @@ -2067,11 +2083,11 @@ mod tests { assert_eq!( before, ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ) ); let _ = take_bridge_error(); @@ -2093,11 +2109,11 @@ mod tests { locals.len(), ); assert_eq!(status, STATUS_CONTINUE); - assert_eq!(vm.ip, 2); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.execution_frames.len(), 2); - assert_eq!(vm.locals.last(), Some(&Value::Int(7))); - let frame = vm.execution_frames.last().unwrap(); + assert_eq!(vm.instance.ip, 2); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.execution_frames.len(), 2); + assert_eq!(vm.instance.locals.last(), Some(&Value::Int(7))); + let frame = vm.instance.execution_frames.last().unwrap(); assert_eq!(frame.prototype_id, Some(0)); assert_eq!(frame.local_count, 1); assert_eq!( @@ -2135,24 +2151,26 @@ mod tests { let program = crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(2); let mut vm = Vm::new(program); - vm.stack = vec![Value::Int(10), Value::Int(20)]; - vm.locals = vec![ + vm.instance.stack = vec![Value::Int(10), Value::Int(20)]; + vm.instance.locals = vec![ Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4), Value::Int(5), ]; - vm.execution_frames.push(crate::vm::ExecutionFrame { - continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, - operand_stack_base: 1, - local_base: 2, - local_count: 3, - prototype_id: Some(7), - }); - vm.active_local_base_cache = 2; - vm.active_operand_stack_base_cache = 1; - vm.call_depth = 1; + vm.instance + .execution_frames + .push(crate::vm::ExecutionFrame { + continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, + operand_stack_base: 1, + local_base: 2, + local_count: 3, + prototype_id: Some(7), + }); + vm.instance.active_local_base_cache = 2; + vm.instance.active_operand_stack_base_cache = 1; + vm.instance.call_depth = 1; let mut state = MaybeUninit::::uninit(); assert_eq!( @@ -2188,9 +2206,9 @@ mod tests { ); std::mem::forget(stack); std::mem::forget(locals); - assert_eq!(vm.stack, vec![Value::Int(10), Value::Int(99)]); + assert_eq!(vm.instance.stack, vec![Value::Int(10), Value::Int(99)]); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2218,11 +2236,11 @@ mod tests { std::mem::forget(sparse_stack); std::mem::forget(dirty_values); assert_eq!( - vm.stack, + vm.instance.stack, vec![Value::Int(10), Value::Int(77), Value::Int(88)] ); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2265,22 +2283,22 @@ mod tests { .expect("bind callable"); assert!(matches!(callable, Value::Callable(_))); - vm.stack.extend([callable, Value::Int(41)]); + vm.instance.stack.extend([callable, Value::Int(41)]); assert_eq!( pd_vm_native_enter_call_value(&mut vm, 1, call_ip as i64, resume_ip as i64,), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.ip, function.entry_ip as usize); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.ip, function.entry_ip as usize); - vm.stack.push(Value::Int(42)); + vm.instance.stack.push(Value::Int(42)); assert_eq!( pd_vm_native_leave_frame(&mut vm, ret_ip as i64), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 0); - assert_eq!(vm.ip, resume_ip); - assert_eq!(vm.stack, vec![Value::Int(42)]); + assert_eq!(vm.instance.call_depth, 0); + assert_eq!(vm.instance.ip, resume_ip); + assert_eq!(vm.instance.stack, vec![Value::Int(42)]); } #[test] @@ -2314,7 +2332,7 @@ mod tests { vm.set_local(0, Value::Int(17)).expect("scalar local"); vm.set_local(1, Value::String(preserved.clone())) .expect("heap local"); - vm.stack.push(Value::Int(99)); + vm.instance.stack.push(Value::Int(99)); let status = pd_vm_native_restore_sparse_exit_state( &mut vm, @@ -2342,7 +2360,7 @@ mod tests { crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(1); let mut vm = Vm::new(program); vm.set_local(0, Value::Int(17)).expect("initial local"); - vm.stack.push(Value::Int(23)); + vm.instance.stack.push(Value::Int(23)); let local_value = Value::Int(99); let null_indices = pd_vm_native_restore_sparse_exit_state( diff --git a/src/vm/native/layout.rs b/src/vm/native/layout.rs index c41c95a5..aea17c3c 100644 --- a/src/vm/native/layout.rs +++ b/src/vm/native/layout.rs @@ -66,39 +66,43 @@ pub(crate) fn detect_native_stack_layout() -> VmResult { } fn detect_native_stack_layout_uncached() -> VmResult { - let vm_stack_offset = usize_to_i32(std::mem::offset_of!(Vm, stack), "Vm::stack offset")?; - let vm_locals_offset = usize_to_i32(std::mem::offset_of!(Vm, locals), "Vm::locals offset")?; + let vm_stack_offset = + usize_to_i32(std::mem::offset_of!(Vm, instance.stack), "Vm::stack offset")?; + let vm_locals_offset = usize_to_i32( + std::mem::offset_of!(Vm, instance.locals), + "Vm::locals offset", + )?; let vm_program_constants_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, program_constants_ptr), + std::mem::offset_of!(Vm, engine.program_constants_ptr), "Vm::program_constants_ptr offset", )?; - let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, ip), "Vm::ip offset")?; + let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, instance.ip), "Vm::ip offset")?; let vm_fuel_remaining_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_remaining), + std::mem::offset_of!(Vm, run_ctx.fuel_remaining), "Vm::fuel_remaining offset", )?; let vm_fuel_ops_until_check_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_ops_until_check), + std::mem::offset_of!(Vm, run_ctx.fuel_ops_until_check), "Vm::fuel_ops_until_check offset", )?; let vm_epoch_deadline_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_deadline), + std::mem::offset_of!(Vm, run_ctx.epoch_deadline), "Vm::epoch_deadline offset", )?; let vm_epoch_counter_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_counter_ptr), + std::mem::offset_of!(Vm, run_ctx.epoch_counter_ptr), "Vm::epoch_counter_ptr offset", )?; let vm_jit_native_region_edge_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_region_edge_count), + std::mem::offset_of!(Vm, engine.jit_native_region_edge_count), "Vm::jit_native_region_edge_count offset", )?; let vm_jit_native_direct_link_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_direct_link_count), + std::mem::offset_of!(Vm, engine.jit_native_direct_link_count), "Vm::jit_native_direct_link_count offset", )?; let vm_jit_native_active_direct_trace_id_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_active_direct_trace_id), + std::mem::offset_of!(Vm, engine.jit_native_active_direct_trace_id), "Vm::jit_native_active_direct_trace_id offset", )?; let stack_vec = detect_vec_layout()?; diff --git a/src/vm/program.rs b/src/vm/program.rs new file mode 100644 index 00000000..10d34236 --- /dev/null +++ b/src/vm/program.rs @@ -0,0 +1,22 @@ +//! Immutable program artifact. +//! +//! [`Program`] is the compiled, immutable unit of +//! execution: bytecode, constants, metadata, import requirements, and +//! binding tables. This module documents its ownership contract for the VM +//! runtime decomposition: +//! +//! - A `Program` is immutable after compilation and binding metadata +//! construction; sharing one `Program` (e.g. through `Arc`) is the +//! only supported way to share code between VMs or instances. +//! - Per-run state (stacks, locals, frames, wait state) never lives in the +//! program; it lives in the VM's private `Instance` state. +//! - Backend caches derived from the program (decoded instruction data, +//! operand type hints, AOT/JIT artifacts) live in +//! the VM's private `Engine` state and are keyed by the program's cache +//! identity, never owned by a run. +//! +//! Thread safety: `Program` is `Send + Sync` and `Clone`-cheap only through +//! `Arc`; cloning the struct itself duplicates metadata, which is allowed but +//! wasteful. Prefer `Arc` for sharing. + +pub use crate::bytecode::Program; diff --git a/src/vm/run_context.rs b/src/vm/run_context.rs new file mode 100644 index 00000000..b8dcde73 --- /dev/null +++ b/src/vm/run_context.rs @@ -0,0 +1,174 @@ +//! Run-scoped execution context. +//! +//! [`RunContext`] owns everything that belongs to one execution of a program: +//! fuel and epoch budgets, the interrupt mode, and the epoch counter handle. A +//! fresh logical run starts from a reset context; nothing here survives a reset +//! except the epoch handle identity (which is intentionally process-lifetime). +//! +//! The embedder-facing fuel/epoch APIs live on the VM facade (see +//! `crate::vm::fuel` and `crate::vm::epoch`) and delegate here. Cancellation of +//! pending host operations lives in the facade because it crosses into +//! [`HostRuntime`](super::host_runtime::HostRuntime) state. There is no per-run +//! input/event state here by design: this mechanical decomposition only moves +//! budgets and interruption state, and new runtime semantics (input/event +//! scopes, cancellation tokens) are intentionally left out of this commit. + +use crate::vm::VmError; +use crate::vm::VmResult; +use crate::vm::epoch::EpochHandle; + +/// Run interruption mode: no budget, fuel metering, or epoch deadlines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum InterruptMode { + None = 0, + Fuel = 1, + Epoch = 2, +} + +impl InterruptMode { + pub(crate) fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::Fuel => "fuel", + Self::Epoch => "epoch", + } + } +} + +/// Run-scoped budgets, deadlines, and interruption state. +/// +/// Thread safety: `RunContext` is `!Sync` (counters are mutable) and not +/// shared; one facade owns one context. Clone semantics: not `Clone` — a clone +/// would duplicate budget state across runs. +pub(crate) struct RunContext { + pub(crate) interrupt_mode: InterruptMode, + pub(crate) fuel_remaining: u64, + pub(crate) fuel_check_interval: u32, + pub(crate) fuel_ops_until_check: u32, + pub(crate) epoch_deadline: u64, + pub(crate) epoch_deadline_delta: u64, + pub(crate) epoch_rearm_pending: bool, + pub(crate) epoch_handle: EpochHandle, + // Native ABI mirror: the epoch counter address read by generated code. + // Load-bearing for `crate::vm::native`; see `crate::vm::engine`. + #[allow(dead_code)] + pub(crate) epoch_counter_ptr: usize, +} + +impl RunContext { + /// Creates a fresh run context with no budgets (interrupts disabled). + pub(crate) fn new() -> Self { + let epoch_handle = EpochHandle::default(); + let epoch_counter_ptr = epoch_handle.as_ptr() as usize; + Self { + interrupt_mode: InterruptMode::None, + fuel_remaining: 0, + fuel_check_interval: 1, + fuel_ops_until_check: 1, + epoch_deadline: 0, + epoch_deadline_delta: 0, + epoch_rearm_pending: false, + epoch_handle, + epoch_counter_ptr, + } + } + + /// Closes run-scoped state for reuse: fuel/epoch budgets are dropped and + /// rearm state is cleared, so metering is disabled on the next run. + pub(crate) fn reset_for_reuse(&mut self) { + self.epoch_rearm_pending = false; + self.clear_fuel_internal(); + self.clear_epoch_deadline_internal(); + } + + pub(crate) fn reset_interrupt_countdown(&mut self) { + self.fuel_ops_until_check = self.fuel_check_interval.max(1); + } + + pub(crate) fn clear_fuel_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Fuel { + self.interrupt_mode = InterruptMode::None; + } + self.fuel_remaining = 0; + self.reset_interrupt_countdown(); + } + + pub(crate) fn clear_epoch_deadline_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Epoch { + self.interrupt_mode = InterruptMode::None; + } + self.epoch_deadline = 0; + self.epoch_deadline_delta = 0; + self.epoch_rearm_pending = false; + self.reset_interrupt_countdown(); + } + + pub(crate) fn pending_fuel_debt(&self) -> u64 { + if self.interrupt_mode != InterruptMode::Fuel { + return 0; + } + let executed_since_last_check = self + .fuel_check_interval + .saturating_sub(self.fuel_ops_until_check); + u64::from(executed_since_last_check) + } + + /// Charges a fixed amount of fuel; errors when the budget is exhausted. + pub(crate) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { + if amount == 0 || self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + let remaining = self.fuel_remaining; + if remaining < amount { + return Err(VmError::OutOfFuel { + needed: amount, + remaining, + }); + } + self.fuel_remaining = remaining - amount; + Ok(()) + } + + /// Charges one fuel interval according to the countdown; errors when the + /// budget is exhausted. + pub(crate) fn charge_fuel_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let amount = u64::from(self.fuel_check_interval); + self.charge_fuel(amount)?; + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } + + /// Charges one epoch countdown tick; errors when the deadline passed. + pub(crate) fn charge_epoch_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Epoch { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let current = self.epoch_handle.current(); + if current >= self.epoch_deadline { + return Err(VmError::EpochDeadlineReached { + current, + deadline: self.epoch_deadline, + }); + } + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } +} + +impl Default for RunContext { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/superinstructions.rs b/src/vm/superinstructions.rs index 47c8e581..e6e87f1f 100644 --- a/src/vm/superinstructions.rs +++ b/src/vm/superinstructions.rs @@ -46,7 +46,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_ldc_value_at(&self, opcode_ip: usize) -> Option<&Value> { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .ldc_values .get(opcode_ip) .and_then(|value| value.as_ref()) @@ -54,7 +55,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .jump_targets .get(opcode_ip) .and_then(|target| *target) @@ -62,7 +64,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_is_valid_at(&self, opcode_ip: usize) -> bool { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .valid_jump_targets .get(opcode_ip) .copied() @@ -71,7 +74,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_local_index_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .local_indices .get(opcode_ip) .and_then(|index| *index) @@ -90,7 +94,7 @@ impl Vm { let Some(initial) = self.local_scalar_value_with_hint(src) else { return Ok(false); }; - let mut cursor = self.ip; + let mut cursor = self.instance.ip; let mut stack = [None; 8]; let mut stack_len = 1usize; stack[0] = Some(initial); @@ -221,7 +225,7 @@ impl Vm { ))?; self.store_local_absolute_with_drop_contract(absolute, dst, value)?; self.record_scalar_superinstruction(); - self.ip = cursor + 2; + self.instance.ip = cursor + 2; return Ok(true); } OpCode::Clt | OpCode::Cgt => { @@ -261,10 +265,10 @@ impl Vm { }, _ => unreachable!(), }; - self.ip = cursor + 6; + self.instance.ip = cursor + 6; if !condition { if self.decoded_jump_target_is_valid_at(jump_opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } diff --git a/src/vm/tests.rs b/src/vm/tests.rs index b7f0e0d1..bc26d402 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -12,15 +12,18 @@ fn native_cache_test_lock() -> &'static Mutex<()> { #[test] fn root_ret_completes_explicit_halt_frame() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); - assert_eq!(vm.execution_frames.len(), 1); - assert_eq!(vm.execution_frames[0].continuation, FrameContinuation::Halt); + assert_eq!(vm.instance.execution_frames.len(), 1); + assert_eq!( + vm.instance.execution_frames[0].continuation, + FrameContinuation::Halt + ); assert_eq!(vm.run().expect("root ret should run"), VmStatus::Halted); - assert!(vm.execution_frames.is_empty()); + assert!(vm.instance.execution_frames.is_empty()); assert!(vm.stack().is_empty()); vm.reset_for_reuse(); - assert_eq!(vm.execution_frames.len(), 1); + assert_eq!(vm.instance.execution_frames.len(), 1); assert_eq!(vm.stack(), &[]); } @@ -36,7 +39,7 @@ fn reset_for_reuse_keeps_host_operation_ids_monotonic() { fn shared_capture_cell_rejects_callable_ownership_cycle() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(1)); let cell = Arc::new(Mutex::new(Value::Null)); - vm.capture_cells.insert(0, Arc::clone(&cell)); + vm.instance.capture_cells.insert(0, Arc::clone(&cell)); let environment = Arc::new(crate::CallableEnvironment { cells: Mutex::new(vec![cell]), }); @@ -109,7 +112,7 @@ fn callvalue_decodes_its_arity_before_callable_validation() { Vec::new(), vec![OpCode::CallValue as u8, 0, OpCode::Ret as u8], )); - vm.stack.push(Value::Null); + vm.instance.stack.push(Value::Null); assert!(matches!(vm.run(), Err(VmError::InvalidCallable))); assert_eq!(vm.ip(), 2); } @@ -293,7 +296,7 @@ fn aot_executes_move_detach_without_stack_contract_mismatch() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::String(Arc::new("x".to_string()))]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -314,7 +317,7 @@ fn aot_executes_script_callable_frames_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -334,7 +337,7 @@ fn aot_executes_typed_script_callable_parameter_equality_without_interpreter_bou VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Bool(true)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -355,7 +358,7 @@ fn aot_executes_script_callable_bool_return_in_branch_without_interpreter_bounda VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(1)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -377,7 +380,7 @@ fn aot_executes_capturing_closure_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -397,7 +400,7 @@ fn aot_executes_builtin_callable_values_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(3)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -424,7 +427,7 @@ fn aot_callable_call_resumes_after_fuel_yield_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -445,7 +448,7 @@ fn aot_executes_nested_script_callables_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -464,7 +467,7 @@ fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundar vm.run(), Err(VmError::CallStackOverflow { limit: 1024 }) )); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -493,7 +496,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { vm.run().expect("pending host callable should wait"), VmStatus::Waiting(812) ); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); vm.complete_host_op(812, vec![Value::Int(42)]) .expect("host operation should complete"); assert_eq!( @@ -501,7 +504,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[test] @@ -943,8 +946,8 @@ fn vm_instances_share_decoded_instruction_metadata_across_program_clones() { assert!( Arc::ptr_eq( - &vm_one.decoded_instruction_data, - &vm_two.decoded_instruction_data + &vm_one.engine.decoded_instruction_data, + &vm_two.engine.decoded_instruction_data ), "program clones should share decoded instruction metadata" ); @@ -969,7 +972,11 @@ fn borrowed_map_iterator_state_is_released_after_break() { assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "break must release every iterator owned by the exited loop" ); } @@ -991,7 +998,11 @@ fn borrowed_map_iterator_state_is_released_after_runtime_error() { vm.run().expect_err("program should fail at runtime"); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "runtime errors must release active map iterators" ); } @@ -1008,7 +1019,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { }; vm.init_map_iterator(7, outer).expect("outer init"); - vm.call_depth = 1; + vm.instance.call_depth = 1; vm.init_map_iterator(7, inner).expect("inner init"); assert!(vm.advance_map_iterator(7).expect("inner advance")); assert_eq!( @@ -1017,7 +1028,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { ); vm.close_map_iterator(7).expect("inner close"); - vm.call_depth = 0; + vm.instance.call_depth = 0; assert!(vm.advance_map_iterator(7).expect("outer advance")); assert_eq!( vm.take_map_iterator_key(7).expect("outer key"), @@ -1079,7 +1090,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to first program after first run" ); assert_eq!( @@ -1094,7 +1105,7 @@ fn native_trace_cache_resets_when_program_changes() { max_trace_len: 512, }); assert_ne!( - vm_one.program_cache_key, vm_two.program_cache_key, + vm_one.engine.program_cache_key, vm_two.engine.program_cache_key, "test programs should have different cache keys" ); let status_two = vm_two.run().expect("second vm should run"); @@ -1109,7 +1120,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache should switch to second program key" ); assert_eq!( @@ -1161,7 +1172,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to the first program" ); assert_eq!( @@ -1176,7 +1187,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { max_trace_len: 512, }); assert_eq!( - vm_two.program_cache_key, vm_one.program_cache_key, + vm_two.engine.program_cache_key, vm_one.engine.program_cache_key, "same program should use identical cache key" ); @@ -1192,7 +1203,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache key should remain the same for identical program" ); assert_eq!( @@ -1344,7 +1355,7 @@ fn interpreter_superinstructions_use_local_type_hints() { let outcome = step_once(&mut vm).expect("ldloc should fuse scalar sequence"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], Value::Int(10)); + assert_eq!(vm.instance.locals[0], Value::Int(10)); let metrics = vm.interpreter_metrics_snapshot(); assert_eq!(metrics.scalar_superinstruction_count, 1); assert!( @@ -1375,7 +1386,8 @@ fn interpreter_ldc_shares_string_constant_backing() { fn interpreter_dup_shares_array_backing() { let program = Program::new(vec![], vec![OpCode::Dup as u8, OpCode::Ret as u8]); let mut vm = Vm::new(program); - vm.stack + vm.instance + .stack .push(Value::array(vec![Value::Int(1), Value::Int(2)])); let outcome = step_once(&mut vm).expect("dup should execute"); @@ -1503,14 +1515,17 @@ fn interpreter_ldloc_preserves_local_slot() { let outcome = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2); - assert_eq!(vm.locals[0], map_value, "ldloc should leave local intact"); + assert_eq!(vm.instance.ip, 2); + assert_eq!( + vm.instance.locals[0], map_value, + "ldloc should leave local intact" + ); assert_eq!( vm.stack(), &[map_value], "stack should receive copied value" ); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); assert_eq!(vm.drop_contract_event_count(), 0); } @@ -1539,9 +1554,9 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let ldloc = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(ldloc, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], map_value); + assert_eq!(vm.instance.locals[0], map_value); assert_eq!(vm.stack(), std::slice::from_ref(&map_value)); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); let ldc = step_once(&mut vm).expect("ldc should execute"); assert!(matches!(ldc, ExecOutcome::Continue)); @@ -1549,8 +1564,8 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let stloc = step_once(&mut vm).expect("stloc should execute"); assert!(matches!(stloc, ExecOutcome::Continue)); - assert_eq!(vm.ip, 9); - assert_eq!(vm.locals[0], Value::Null); + assert_eq!(vm.instance.ip, 9); + assert_eq!(vm.instance.locals[0], Value::Null); assert_eq!(vm.stack(), &[map_value]); } @@ -1579,9 +1594,9 @@ fn interpreter_fuses_ldloc_ldc_add_stloc_without_touching_stack() { let outcome = step_once(&mut vm).expect("fused sequence should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10, "fusion should consume ldc/add/stloc"); - assert_eq!(vm.locals[0], Value::Int(41)); - assert_eq!(vm.locals[1], Value::Int(42)); + assert_eq!(vm.instance.ip, 10, "fusion should consume ldc/add/stloc"); + assert_eq!(vm.instance.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[1], Value::Int(42)); assert!( vm.stack().is_empty(), "fusion should avoid transient stack traffic" @@ -1621,7 +1636,10 @@ fn interpreter_fuses_ldloc_ldc_compare_brfalse() { let outcome = step_once(&mut vm).expect("fused compare should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 15, "fusion should jump directly to branch target"); + assert_eq!( + vm.instance.ip, 15, + "fusion should jump directly to branch target" + ); assert!( vm.stack().is_empty(), "fusion should avoid bool stack traffic" @@ -1664,9 +1682,9 @@ fn interpreter_fuses_generic_scalar_update_chain() { let outcome = step_once(&mut vm).expect("generic chain should fuse"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 19); - assert_eq!(vm.locals[0], Value::Int(29)); - assert_eq!(vm.locals[1], Value::Int(4)); + assert_eq!(vm.instance.ip, 19); + assert_eq!(vm.instance.locals[0], Value::Int(29)); + assert_eq!(vm.instance.locals[1], Value::Int(4)); assert!(vm.stack().is_empty()); } @@ -1708,13 +1726,13 @@ fn interpreter_fuses_float_scalar_sequences() { let first = step_once(&mut vm).expect("float update should fuse"); assert!(matches!(first, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10); - assert_eq!(vm.locals[0], Value::Float(2.5)); + assert_eq!(vm.instance.ip, 10); + assert_eq!(vm.instance.locals[0], Value::Float(2.5)); assert!(vm.stack().is_empty()); let second = step_once(&mut vm).expect("float compare should fuse"); assert!(matches!(second, ExecOutcome::Continue)); - assert_eq!(vm.ip, 23); + assert_eq!(vm.instance.ip, 23); assert!(vm.stack().is_empty()); } @@ -1747,9 +1765,12 @@ fn interpreter_does_not_fuse_ldloc_sequences_when_fuel_is_enabled() { .execute_interpreter_instruction(opcode, false) .expect("ldloc should execute without fusion"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2, "ldloc should advance only past its operand"); + assert_eq!( + vm.instance.ip, 2, + "ldloc should advance only past its operand" + ); assert_eq!(vm.stack(), &[Value::Int(41)]); - assert_eq!(vm.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[0], Value::Int(41)); } #[test] @@ -1776,7 +1797,7 @@ fn interpreter_copy_like_ldloc_dup_stloc_shares_map_backing_with_fuel() { let _ = step_once(&mut vm).expect("stloc should execute"); assert_eq!(vm.stack().len(), 1); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); } #[test] @@ -1787,11 +1808,14 @@ fn interpreter_fuses_call_ret_without_fuel() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm = Vm::new(program); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let outcome = step_once(&mut vm).expect("call should execute"); assert!(matches!(outcome, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1804,12 +1828,15 @@ fn interpreter_fuses_call_ret_when_fuel_enabled_if_tail_tick_available() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); // `step_once` bypasses the outer run-loop pre-tick, so this fuel only covers fused `ret`. let call = step_once(&mut vm).expect("call should execute"); assert!(matches!(call, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!(vm.get_fuel(), Some(0)); } @@ -1823,7 +1850,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { ); let mut vm = Vm::new(program); vm.set_fuel(0); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with out-of-fuel"), @@ -1831,7 +1858,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { }; assert!(matches!(err, VmError::OutOfFuel { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when tail tick cannot be charged" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1847,7 +1874,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { let mut vm = Vm::new(program); vm.set_epoch_deadline(0) .expect("setting epoch deadline should succeed"); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with epoch deadline reached"), @@ -1855,7 +1882,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { }; assert!(matches!(err, VmError::EpochDeadlineReached { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when the epoch check trips during fused tail execution" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1870,11 +1897,11 @@ fn run_consumes_two_ticks_for_call_ret_when_fuel_enabled() { ); let mut vm = Vm::new(program); vm.set_fuel(2); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("run should complete"); assert_eq!(status, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!( vm.get_fuel(), @@ -1892,12 +1919,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "fuel exhaustion should happen before trailing ret" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1906,7 +1933,7 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { vm.add_fuel(1).expect("recharging fuel should succeed"); let resumed = vm.resume().expect("resume should execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1923,12 +1950,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { vm.set_epoch_deadline(1) .expect("setting epoch deadline should succeed"); assert_eq!(vm.increment_epoch(), 1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "epoch interruption should happen before trailing ret" ); assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); @@ -1938,7 +1965,7 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { .resume() .expect("resume should auto re-arm the epoch deadline and execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1950,7 +1977,7 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm_with_ret = Vm::new(with_ret); - vm_with_ret.ip = 4; + vm_with_ret.instance.ip = 4; assert!(vm_with_ret.can_fuse_call_ret_pattern()); let wrong_next = Program::new( @@ -1958,11 +1985,11 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Nop as u8], ); let mut vm_wrong_next = Vm::new(wrong_next); - vm_wrong_next.ip = 4; + vm_wrong_next.instance.ip = 4; assert!(!vm_wrong_next.can_fuse_call_ret_pattern()); let no_next = Program::new(vec![], vec![OpCode::Call as u8, call_lo, call_hi, 1]); let mut vm_no_next = Vm::new(no_next); - vm_no_next.ip = 4; + vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } From f6f0040a2f4d25772bb870f9054170b6f1741adb Mon Sep 17 00:00:00 2001 From: fffonion Date: Thu, 27 Aug 2026 07:42:12 +0800 Subject: [PATCH 2/2] feat(vm): add scoped resource and operation lifecycle --- ...26-08-26_host-adapter-owned-scope-state.md | 428 ++++++ src/lib.rs | 5 +- src/vm/execution_scope.rs | 632 ++++++++ src/vm/host_runtime.rs | 86 +- src/vm/host_state.rs | 125 ++ src/vm/mod.rs | 32 +- src/vm/operation/driver.rs | 126 ++ src/vm/operation/error.rs | 151 ++ src/vm/operation/id.rs | 312 ++++ src/vm/operation/mod.rs | 37 + src/vm/operation/reason.rs | 124 ++ src/vm/operation/registry.rs | 1331 +++++++++++++++++ src/vm/resource/close.rs | 54 + src/vm/resource/error.rs | 174 +++ src/vm/resource/handle.rs | 342 +++++ src/vm/resource/mod.rs | 35 + src/vm/resource/reason.rs | 186 +++ src/vm/resource/table.rs | 1118 ++++++++++++++ tests/vm/execution_scope_tests.rs | 721 +++++++++ tests/vm_tests.rs | 3 + 20 files changed, 6008 insertions(+), 14 deletions(-) create mode 100644 plans/2026-08-26_host-adapter-owned-scope-state.md create mode 100644 src/vm/execution_scope.rs create mode 100644 src/vm/host_state.rs create mode 100644 src/vm/operation/driver.rs create mode 100644 src/vm/operation/error.rs create mode 100644 src/vm/operation/id.rs create mode 100644 src/vm/operation/mod.rs create mode 100644 src/vm/operation/reason.rs create mode 100644 src/vm/operation/registry.rs create mode 100644 src/vm/resource/close.rs create mode 100644 src/vm/resource/error.rs create mode 100644 src/vm/resource/handle.rs create mode 100644 src/vm/resource/mod.rs create mode 100644 src/vm/resource/reason.rs create mode 100644 src/vm/resource/table.rs create mode 100644 tests/vm/execution_scope_tests.rs diff --git a/plans/2026-08-26_host-adapter-owned-scope-state.md b/plans/2026-08-26_host-adapter-owned-scope-state.md new file mode 100644 index 00000000..8e8b8402 --- /dev/null +++ b/plans/2026-08-26_host-adapter-owned-scope-state.md @@ -0,0 +1,428 @@ +# Host-Adapter-Owned Scope State Implementation Plan + +> **For Hermes:** Use subagent-driven development to implement this plan task by task, with strict RED/GREEN verification and two-stage review before publishing the rewritten stack. + +**Goal:** Remove all concrete host-adapter state and feature knowledge from the generic VM runtime, allowing each host function or adapter to declare typed scope-local state backed by the generic resource arena while keeping persistent policy in one generic module-state store. + +**Architecture:** `ExecutionScope` remains the VM's generic resource/operation lifecycle owner and gains typed scope-state access backed by its `ResourceTable`. A host adapter obtains its state lazily through generic APIs at the point of use; scope close/reset destroys that state through the ordinary resource close path. Persistent policy/configuration uses one typed `ModuleStateStore`. `src/vm/**` never imports or pattern-matches IO, SQLite, HTTP, SSE, or other concrete adapter types and never carries adapter feature guards. + +**Tech Stack:** Rust 2024, RustScript VM, `ExecutionScope`, `ResourceTable`, `HostResource`, `HostContext`, Cargo feature composition, stacked GitHub PRs #16/#18/#23/#24/#26. + +--- + +## 1. Architectural invariants + +### 1.1 Dependency direction + +The permitted dependency direction is: + +```text +host adapter / builtin + -> generic HostContext / ExecutionScope / ResourceTable APIs + -> generic VM lifecycle primitives +``` + +The reverse dependency is forbidden. In particular, files under `src/vm/**` must not: + +- import `crate::builtins::*` or a concrete host library; +- mention `IoState`, `IoPolicy`, `SqliteState`, `SqlitePolicy`, `HttpHostState`, `HttpConfig`, SSE state, or future adapter state/policy types; +- inspect adapter `TypeId`s to preserve or reset selected state; +- contain `cfg(feature = "sqlite")`, `cfg(feature = "http-client")`, or equivalent adapter feature guards; +- add concrete adapter fields to `HostRuntime`, `Vm`, `ExecutionScope`, `HostContext`, or another generic VM structure. + +`ExecutionScope` is allowed in generic VM code because it is the host-agnostic lifecycle primitive. Its implementation must remain independent of concrete host adapters and Cargo adapter features. + +### 1.2 Feature-guard boundary + +A host-adapter feature guard is allowed only where the build genuinely composes or exposes that adapter: + +- Cargo dependency/feature declarations (`Cargo.toml`, `Cargo.lock`); +- build-time feature composition where required (`build.rs`); +- builtin module declarations and re-exports (`src/builtins/mod.rs`, `src/builtins/runtime/mod.rs`); +- standard host-function registration/composition (`src/builtins/runtime/standard_composition.rs`, `src/builtins/runtime/host.rs`, or the adapter-owned registration module); +- crate-level public re-export of an enabled adapter API, when such an export already belongs to the public surface; +- the concrete adapter implementation and its adapter-specific tests. + +A guard is not allowed around a generic helper merely because its only current consumer is a guarded adapter. Generic helpers must compile and be tested independently of every concrete adapter feature. + +These rules apply equally to SQLite, IO, HTTP/SSE, and future host adapters. IO may be unconditionally composed under the runtime feature today; that does not grant it fields or branches in generic VM structures. + +### 1.3 State lifetimes + +There are exactly two host-owned state lifetimes: + +1. **Scope-local state** + - pending-result maps; + - connection/permit counters tied to live scope resources; + - adapter operation bookkeeping; + - ephemeral pools, workers, and per-invocation mutable state. + + Scope-local state is created lazily by its adapter and stored as a resource-arena-owned typed state entry. It is closed and destroyed by ordinary `ExecutionScope` shutdown. VM reset contains no adapter-specific reset branch. + +2. **Persistent module state** + - `IoPolicy`; + - `SqlitePolicy`; + - `HttpConfig`; + - external extension configuration intended to survive `Vm::reset_for_reuse()`. + + Persistent state lives in the single generic `ModuleStateStore`. It never participates in resource close and survives execution-scope replacement until explicitly replaced/removed or until the VM is dropped. + +No third `host_function_state` type map is permitted. + +--- + +## 2. Target generic API + +### 2.1 Arena-backed typed scope state + +Add a private generic resource wrapper and a type-indexed singleton mapping inside the generic lifecycle layer. The exact internal representation may vary, but the public behavior must match: + +```rust +impl ExecutionScope { + pub fn scope_state_or_insert_with(&mut self, init: F) + -> ExecutionScopeResult<&mut T> + where + T: Send + 'static, + F: FnOnce() -> T; + + pub fn scope_state(&self) -> Option<&T> + where + T: Send + 'static; + + pub fn scope_state_mut(&mut self) -> Option<&mut T> + where + T: Send + 'static; + + pub fn take_scope_state(&mut self) -> Option + where + T: Send + 'static; +} +``` + +The `scope_` prefix is required because `ExecutionScope::state()` already reports the generic Active/Closing/Quiescent lifecycle phase; Rust does not overload methods by generic arity. + +Required semantics: + +- one scope-state value per concrete `T` per `ExecutionScope`; +- lazy initialization executes at most once while the state is present; +- state is physically owned by the resource arena and participates in arena identity/generation validation; +- state insertion is rejected when the scope is Closing or Quiescent; +- scope close removes state through the same generic resource close sweep; +- a fresh scope cannot observe state or handles from the previous scope; +- no adapter name, feature, or type appears in the implementation; +- ordinary resources of the same payload type cannot collide with scope-state identity (use an internal wrapper or a dedicated generic state key); +- failure to insert leaves no stale type-index entry; +- `take_state` removes both arena entry and type index atomically. + +Expose equivalent generic wrappers through `HostContext` once that public SDK exists: + +```rust +context.scope_state_or_insert_with::(IoState::default)?; +context.scope_state::(); +context.scope_state_mut::(); +context.take_scope_state::(); +``` + +Same-crate adapters that precede the public `HostContext` layer may call crate-private generic VM/host-context forwarding methods. Those forwarding methods must remain type-generic and feature-neutral. + +### 2.2 Single persistent module-state store + +Retain one generic `ModuleStateStore` keyed by `TypeId`, with typed set/get/get_mut/remove operations. Move its earliest required implementation into the owning lower stack layer if IO/SQLite persistent policy needs it before PR #18; PR #18 then exposes the same store through `HostContext` instead of introducing another map. + +The store must not use `Arc::get_mut(...).expect(...)` as a uniqueness invariant. Prefer uniquely owned `Box` entries unless a demonstrated concurrent sharing requirement exists. + +### 2.3 HostRuntime target shape + +After the refactor, `HostRuntime` may own only generic runtime machinery: + +```rust +pub(crate) struct HostRuntime { + // host-function symbols/bindings and capability data + // generic async bridge/stream drivers + execution_scope: ExecutionScope, + module_state_store: ModuleStateStore, + // generic operation ids and print sink +} +``` + +It must not contain: + +```rust +io_state: IoState, +sqlite_state: SqliteState, +host_function_state: HashMap, +``` + +`reset_execution_scope()` must close/replace only generic scope structures. It must not preserve HTTP state by concrete `TypeId`, reconstruct IO/SQLite state, or branch on adapter features. + +--- + +## 3. Stack ownership and commit boundaries + +The final stack remains: + +```text +master + -> PR #16 (4 commits) + -> PR #18 (1 commit) + -> PR #23 (1 commit) + -> PR #24 (1 commit) + -> PR #26 (1 commit) +``` + +PR #28 remains absorbed/merged with zero delta. + +### PR #16 commit 1 — VM decomposition + +`refactor(vm): split runtime state from VM facade` + +- Keep this commit mechanical where practical. +- If `HostRuntime` currently gains concrete adapter fields in this commit, remove those fields and initializers here. +- The new plan document may be introduced in this commit or commit 2; choose the first commit where the architectural boundary becomes meaningful. + +### PR #16 commit 2 — generic lifecycle and state primitives + +`feat(vm): add scoped resource and operation lifecycle` + +- Add arena-backed typed scope state to `ResourceTable`/`ExecutionScope`. +- Add the single generic persistent state store at its earliest required layer. +- Make `HostRuntime::reset_execution_scope` wholly generic. +- Add feature-boundary architecture tests. +- Remove concrete HTTP-preservation logic and the duplicate `host_function_state` map if present at this layer. + +### PR #16 commit 3 — IO migration + +`refactor(io): migrate IO onto scoped lifecycle` + +- Move `IoState` from `HostRuntime` into lazily declared arena-backed scope state. +- Move `IoPolicy` into the persistent module-state store. +- Remove `vm.host.io_state` access. +- Ensure pending IO workers and result maps close/quiesce through generic scope lifecycle. +- Keep IO registration/composition in the builtin layer. + +### PR #16 commit 4 — SQLite migration + +`feat(sqlite): add scoped SQLite host functions` + +- Move `SqliteState` from `HostRuntime` into lazily declared arena-backed scope state. +- Move `SqlitePolicy` into persistent module state. +- Move `configure_sqlite`, `clear_sqlite`, and policy access implementations out of `src/vm/**` into the SQLite adapter module or an adapter-owned extension trait/`impl Vm` block. +- Keep `cfg(feature = "sqlite")` at concrete module registration/export/composition boundaries only. +- Preserve close behavior for queued and running operations. + +### PR #18 — public host-extension SDK and HTTP generic state use + +`feat(host): add capability profiles and async host execution` + +- Expose the existing single module-state store through `HostContext`. +- Expose generic scope-state methods through `HostContext`. +- Remove any second module-state or host-function-state storage. +- Ensure external `HostExtension` implementations can declare both persistent module state and scope-local state without private `HostRuntime` access. +- Migrate HTTP runtime counters/permits/bookkeeping to scope state and `HttpConfig` to persistent module state. +- Keep HTTP feature guards in builtin registration/export/adapter files; generic host SDK files remain feature-neutral. + +### PR #23/#24/#26 — cascade only where required + +- Replay invocation streaming, compiler ownership, and HTTP/SSE changes on the rewritten lower layers. +- #26 contains HTTP/SSE adapter implementation that depends on the public generic API, but does not add feature-specific branches to generic VM code. +- Preserve one commit per PR and independent CI validity at every PR head. + +--- + +## 4. TDD implementation tasks + +### Task 1: Lock the feature boundary with failing architecture tests + +**Files:** +- Modify: `tests/host_binding_generation_tests.rs` +- Modify or create: `tests/host_context_arch_tests.rs` +- Create if clearer: `tests/host_feature_boundary_arch_tests.rs` + +**RED tests:** + +1. Scan all Rust sources under `src/vm/**` and fail on: + - `crate::builtins`; + - adapter state/policy/config symbols; + - `cfg(feature = "sqlite")`; + - `cfg(feature = "http-client")`; + - concrete adapter `TypeId` references. +2. Assert `HostRuntime` has no concrete adapter state fields and no duplicate type map. +3. Assert the allowed guard locations are adapter composition/registration/export files only. + +Run the focused architecture tests and confirm they fail against the current stack for the expected `host_runtime.rs` and `vm/mod.rs` references. + +### Task 2: Add arena-backed typed state to the generic lifecycle + +**Files:** +- Modify: `src/vm/resource/table.rs` +- Modify: `src/vm/resource/mod.rs` and related private resource files as needed +- Modify: `src/vm/execution_scope.rs` +- Modify: `tests/vm/execution_scope_tests.rs` +- Modify: `tests/vm/resource_table_tests.rs` + +**RED/GREEN slices:** + +1. lazy insertion and repeated typed access; +2. separate state for separate scopes; +3. insertion rejection after close begins; +4. close/reset drops state exactly once; +5. stale state token/index cannot alias a fresh scope; +6. ordinary resource and scope-state payload types do not collide; +7. failed initialization/insertion leaves no stale index; +8. `take_state` atomically removes state and index. + +### Task 3: Consolidate persistent state and generic reset + +**Files:** +- Modify: `src/vm/host_runtime.rs` +- Modify: `src/vm/host_context.rs` when present in the layer +- Modify: `src/vm/mod.rs` +- Modify: focused host runtime/context tests + +**RED/GREEN slices:** + +1. persistent state survives scope reset; +2. scope state is destroyed by reset; +3. generic reset source contains no adapter name, feature, or concrete `TypeId` branch; +4. one persistent state store provides internal and public SDK access; +5. removal returns uniquely owned state without `Arc::get_mut` assumptions. + +### Task 4: Migrate IO + +**Files:** +- Modify: `src/builtins/runtime/io/mod.rs` +- Modify: `src/builtins/runtime/io/blocking.rs` +- Modify: `src/builtins/runtime/io/async_io.rs` +- Modify: `src/builtins/runtime/io_wasm.rs` +- Modify: IO lifecycle tests + +**RED/GREEN slices:** + +1. first IO operation lazily creates `IoState`; +2. repeated operations reuse the same state in one scope; +3. reset removes pending-result state and a later operation gets fresh state; +4. IO policy survives reset via module state; +5. cancellation and quiescence remain correct; +6. no `vm.host.io_state` reference remains. + +### Task 5: Migrate SQLite and move its public control API + +**Files:** +- Modify: `src/builtins/runtime/sqlite.rs` +- Modify: `src/builtins/runtime/mod.rs` +- Modify: `src/vm/mod.rs` +- Modify: `tests/builtins/sqlite_scope_lifecycle_tests.rs` +- Modify: architecture tests + +**RED/GREEN slices:** + +1. first SQLite operation lazily creates `SqliteState`; +2. configured policy survives reset; +3. runtime pending state does not survive reset; +4. clear/replace policy is adapter-owned and affects subsequent opens; +5. queued and active operations quiesce before scope close completes; +6. SQLite-disabled builds compile generic VM code unchanged; +7. no SQLite symbol or feature guard remains under `src/vm/**`. + +### Task 6: Expose generic state through HostContext and migrate HTTP/SSE + +**Files:** +- Modify: `src/vm/host_context.rs` +- Modify: `src/vm/host_extension.rs` +- Modify: `src/builtins/runtime/http/mod.rs` +- Modify: HTTP/SSE operation modules as needed +- Modify: `tests/host_context_arch_tests.rs` +- Modify: `tests/host_sdk_tests.rs` +- Modify: external extension fixture tests +- Modify: HTTP/SSE lifecycle tests + +**RED/GREEN slices:** + +1. external extension lazily declares scope state through public `HostContext`; +2. scope state closes on reset while extension policy persists; +3. HTTP config persists without a concrete reset exception; +4. HTTP permits/bookkeeping reset through resource close; +5. no HTTP feature guard or concrete HTTP state reference exists under `src/vm/**`. + +### Task 7: Rewrite and validate every stack layer + +For each final PR head, in a detached isolated worktree run the exact CI workflow commands, including: + +```bash +cargo fmt --all -- --check +cargo test -p pd-vm-nostd --no-default-features +cargo tree -p pd-vm-wasm --target wasm32-unknown-unknown --no-default-features +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features +cargo build -p pd-vm-cli --release +cargo check -p pd-vm-wasm --target wasm32-unknown-unknown +cargo check -p pd-vm-wasm --target wasm32-unknown-unknown --features runtime +``` + +Also test feature isolation explicitly: + +```bash +cargo check --workspace --no-default-features +cargo check --workspace --features runtime +cargo check --workspace --features runtime,sqlite +cargo check --workspace --features runtime,http-client +``` + +Use only feature combinations actually defined by the workspace; adjust package selection where a workspace-wide combination is invalid, and record the exact equivalent command. + +Acceptance: + +- #16 contains exactly four commits; +- #18/#23/#24/#26 contain exactly one commit each over their configured base; +- every PR head is independently formatted, compilable, and CI-green; +- `git grep` confirms no concrete adapter state/policy or adapter feature guard under `src/vm/**`; +- PR #28 remains zero-delta/merged; +- remote updates use one atomic `--force-with-lease` push after local verification; +- GitHub push and pull-request suites pass on every open PR. + +--- + +## 5. Required architecture checks + +Before publish, all of the following searches must return no disallowed match: + +```bash +git grep -n -E 'IoState|SqliteState|HttpHostState|IoPolicy|SqlitePolicy|HttpConfig' -- src/vm +git grep -n -E 'cfg\([^)]*feature = "(sqlite|http-client)"' -- src/vm +git grep -n 'crate::builtins' -- src/vm +git grep -n -E 'host_function_state|io_state:|sqlite_state:' -- src/vm +``` + +A match in comments or an architecture test fixture must be classified explicitly; production generic VM code has zero matches. + +Allowed adapter guards must be reviewed by location rather than count. Every guard must correspond to one of: + +- dependency/build composition; +- builtin module declaration/export; +- concrete host-function registration/composition; +- concrete adapter implementation/test compilation. + +No generic API or helper may be guarded solely because its first consumer is an adapter. + +--- + +## 6. Review and publication + +1. Implement each owning stack scope with strict RED/GREEN evidence. +2. Run spec-compliance review against this plan. +3. Address findings and repeat spec review until clean. +4. Run code-quality review focused on lifecycle, borrow safety, state indexing, close idempotence, and feature boundaries. +5. Address findings and repeat quality review until clean. +6. Main agent verifies diff, commit ownership, every PR-head CI matrix, and forbidden-symbol scans. +7. Back up current remote refs. +8. Atomically force-with-lease update #16/#18/#23/#24/#26 branches. +9. Verify GitHub PR heads, bases, commit counts, and CI; do not publish a partial stack. + +## 7. Risks and mitigations + +- **Borrow conflicts between adapter state and resource/operation access:** expose closure-based accessors where returning `&mut T` would hold a borrow across another scope mutation; test real host-function flows. +- **Type-index drift after close/take/failure:** update arena entry and type index in one generic operation; test failed insertion and close retries. +- **State close ordering:** operations drain before resources; scope state that tracks operation results must remain available until operation drain completes, then close with resources. +- **Policy/runtime conflation:** persistent policy and scope runtime state use distinct concrete types and stores; tests assert opposite reset behavior. +- **Intermediate PR breakage:** run exact CI at every PR head, not only stack tip. +- **Feature leakage returning later:** architecture tests scan the entire generic VM tree and apply equally to future adapters. diff --git a/src/lib.rs b/src/lib.rs index cf678b6b..f88cf055 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,8 +85,9 @@ pub use vm::{ AotArtifactError, CallOutcome, CallReturn, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, - ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, - StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, + ResourceCloseReason, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, + StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, + VmYieldReason, execution_scope, operation, resource, }; #[cfg(feature = "runtime")] pub use vmbc::{ diff --git a/src/vm/execution_scope.rs b/src/vm/execution_scope.rs new file mode 100644 index 00000000..190ab414 --- /dev/null +++ b/src/vm/execution_scope.rs @@ -0,0 +1,632 @@ +//! Host-agnostic execution scope: one resource registry plus one operation +//! registry with a single Active → Closing → Quiescent lifecycle. +//! +//! An [`ExecutionScope`] is the isolated ownership unit the VM exposes to +//! host code: it owns exactly one [`ResourceTable`] and exactly one +//! [`OperationRegistry`], so nothing in one scope can alias handles or +//! operation ids from another. New inserts are guarded by the scope state; +//! shutdown cancels and drains operations before closing resources, and the +//! terminal outcome is fixed once (idempotent) when both registries empty. +//! +//! The scope stays host-agnostic: it never dispatches on a concrete resource +//! class or a host operation domain. Concrete drivers own poll/cancel (see +//! [`HostOperation`](crate::vm::operation::HostOperation)) and concrete +//! resources own their close (see +//! [`HostResource`](crate::vm::resource::HostResource)). + +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; + +use super::operation::driver::{OperationOutcome, OperationSpec}; +use super::operation::error::OperationError; +use super::operation::id::OperationId; +use super::operation::reason::OperationCancelReason; +use super::operation::registry::{DEFAULT_MAX_PENDING_OPERATIONS, OperationRegistry}; +use super::resource::HostResource; +use super::resource::close::CloseProgress; +use super::resource::error::ResourceError; +use super::resource::handle::{Resource, ResourceHandle}; +use super::resource::reason::ResourceCloseReason; +use super::resource::table::ResourceTable; + +/// Result alias used by the execution-scope surface. +pub type ExecutionScopeResult = Result; + +/// Lifecycle phase of one execution scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeState { + /// The scope accepts new resources and operations through the generic API. + Active, + /// Shutdown has begun: new inserts are rejected and [`ExecutionScope::poll_close`] + /// drives operations then resources to quiescence. + Closing, + /// Both the resource table and the operation registry are empty and the + /// terminal outcome is fixed (idempotent). + Quiescent, +} + +/// Structured error returned on a scope-state violation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExecutionScopeError { + /// A close was already begun with a different reason (first-reason-wins). + /// + /// `current` is the already-bound reason, `requested` the rejected one. + CloseAlreadyInProgress { + current: Option, + requested: ResourceCloseReason, + }, + /// A new resource/operation insert was rejected because the scope is + /// Closing or Quiescent. + ScopeClosing, + /// A close/poll was requested while the scope was still Active. + ScopeNotClosing, + /// Construction of a fresh scope failed because the process-unique + /// resource-arena identity space is exhausted. Carries the typed resource + /// error ([`ResourceErrorCode::ResourceTableArenaExhausted`]); the scope + /// was not created and no partial state exists. + ArenaExhausted(ResourceError), + /// The underlying resource insert/close failed. + Resource(ResourceError), + /// The underlying operation start/cancel failed. + Operation(OperationError), +} + +impl std::fmt::Display for ExecutionScopeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CloseAlreadyInProgress { current, requested } => write!( + formatter, + "execution scope close already in progress with {current:?}; conflicting {requested:?} rejected", + ), + Self::ScopeClosing => { + write!( + formatter, + "execution scope is closing and rejects new inserts" + ) + } + Self::ScopeNotClosing => { + write!( + formatter, + "execution scope close was requested on an active scope" + ) + } + Self::ArenaExhausted(error) => { + write!(formatter, "execution scope creation failed: {error}") + } + Self::Resource(error) => write!(formatter, "execution scope resource error: {error}"), + Self::Operation(error) => { + write!(formatter, "execution scope operation error: {error}") + } + } + } +} + +impl ExecutionScopeError { + /// Recovers the underlying `OperationError` when the failure is an + /// operation-domain error; returns `None` for scope-state violations. + pub fn into_operation_error(self) -> Option { + match self { + ExecutionScopeError::Operation(error) => Some(error), + _ => None, + } + } + + /// Recovers the underlying `ResourceError` when the failure is a + /// resource-domain error; returns `None` for scope-state violations. + pub fn into_resource_error(self) -> Option { + match self { + ExecutionScopeError::Resource(error) | ExecutionScopeError::ArenaExhausted(error) => { + Some(error) + } + _ => None, + } + } +} + +impl std::error::Error for ExecutionScopeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ArenaExhausted(error) | Self::Resource(error) => Some(error), + Self::Operation(error) => Some(error), + _ => None, + } + } +} + +/// First cleanup failure preserved across the close sweep, plus the total +/// number of failed cleanups observed. +/// +/// Best-effort shutdown continues past a failing entry; this carries the +/// earliest failure so the terminal state never claims a fake success, and +/// the failure count so the caller can size the blast radius. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScopeCloseFailure { + /// The earliest cleanup failure (first-error-wins). + pub first: ScopeCloseError, + /// Total number of cleanup failures observed during the sweep + /// (operations then resources), including `first`. + pub failed: usize, +} + +/// One typed cleanup failure in the scope close sweep. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScopeCloseError { + /// An operation driver/cleanup failed during the operation drain. + Operation(OperationError), + /// A resource cleanup failed during resource close. + Resource(ResourceError), +} + +/// Terminal result of a fully-driven scope shutdown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScopeCloseOutcome { + /// Every operation drained and every resource closed cleanly. + Success, + /// The scope quiesced but at least one cleanup failed; the first error is + /// preserved, never overwritten by later successes or failures, and the + /// total failure count is carried alongside it. + SuccessWithErrors(ScopeCloseFailure), +} + +/// One execution scope: an isolated resource arena plus an isolated operation +/// registry, with an Active → Closing → Quiescent lifecycle. +/// +/// `Send + !Sync`: the scope owns its registries and must be driven by a +/// single thread. +pub struct ExecutionScope { + operations: OperationRegistry, + resources: ResourceTable, + state: ScopeState, + close_reason: Option, + /// Whether the operation phase of this close already ran (idempotent). + operations_drained: bool, + /// First cleanup failure across the whole shutdown (operations then resources). + first_error: Option, + /// Total cleanup failures observed across the whole shutdown (operations + /// then resources); includes the failure recorded in `first_error`. + failed_count: usize, + terminal: Option, +} + +impl ExecutionScope { + /// Creates a fresh, independent execution scope. + /// + /// The resource table gets a brand-new process-unique arena identity and + /// the operation registry a brand-new process-unique tag, so nothing in a + /// new scope can alias handles/ids from any other scope. + /// + /// Fallible: arena identity or operation-registry tag allocation can fail + /// with [`ExecutionScopeError::ArenaExhausted`] or + /// [`ExecutionScopeError::Operation`] once the process-unique identity + /// space is exhausted. No partial scope is created on failure. + pub fn new() -> ExecutionScopeResult { + let resources = ResourceTable::new().map_err(ExecutionScopeError::ArenaExhausted)?; + Ok(Self { + resources, + operations: OperationRegistry::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + .map_err(ExecutionScopeError::Operation)?, + state: ScopeState::Active, + close_reason: None, + operations_drained: false, + first_error: None, + failed_count: 0, + terminal: None, + }) + } + + /// The current lifecycle phase. + pub fn state(&self) -> ScopeState { + self.state + } + + /// Whether the scope is still accepting new resources/operations. + pub fn is_active(&self) -> bool { + self.state == ScopeState::Active + } + + /// Whether shutdown has begun but is not yet quiescent. + pub fn is_closing(&self) -> bool { + self.state == ScopeState::Closing + } + + /// Whether both registries are empty and the terminal outcome is fixed. + pub fn is_quiescent(&self) -> bool { + self.state == ScopeState::Quiescent + } + + /// The first-close reason bound by [`begin_close`](Self::begin_close), if any. + pub fn close_reason(&self) -> Option { + self.close_reason + } + + /// Read access to the owned resource table (observe counts, borrow, type + /// validation). New inserts must go through the guarded scope API. + pub fn resources(&self) -> &ResourceTable { + &self.resources + } + + // ---- typed scope-state arena ------------------------------------------------- + + /// Returns a mutable handle to the `T`-typed scope state, creating it with + /// `init` on first access while the scope is Active. + /// + /// A Closing/Quiescent scope rejects the insert with + /// [`ExecutionScopeError::ScopeClosing`] (the existing admission guard). + /// The state lives in the arena-owned map on the underlying + /// [`ResourceTable`], separate from ordinary resource slots. + pub fn scope_state_or_insert_with T>( + &mut self, + init: F, + ) -> ExecutionScopeResult<&mut T> { + self.ensure_accepting()?; + Ok(self.resources.scope_state_or_insert_with(init)) + } + + /// Borrows the `T`-typed scope state, if present. + /// + /// Returns `None` after the terminal close cleared the arena (and for a + /// type that was never inserted). + pub fn scope_state(&self) -> Option<&T> { + self.resources.scope_state::() + } + + /// Mutably borrows the `T`-typed scope state, if present. + /// + /// Returns `None` after the terminal close cleared the arena (and for a + /// type that was never inserted). + pub fn scope_state_mut(&mut self) -> Option<&mut T> { + self.resources.scope_state_mut::() + } + + /// Removes and returns the `T`-typed scope state, if present. + pub fn take_scope_state(&mut self) -> Option { + self.resources.take_scope_state::() + } + + /// Read access to the owned operation registry (observe counts/status). + /// New starts must go through the guarded scope API. + pub fn operations(&self) -> &OperationRegistry { + &self.operations + } + + /// The fixed terminal outcome, once the scope reached quiescence. + pub fn terminal(&self) -> Option<&ScopeCloseOutcome> { + self.terminal.as_ref() + } + + /// Inserts a root resource while the scope is Active. + /// + /// A Closing/Quiescent scope rejects the insert with + /// [`ExecutionScopeError::ScopeClosing`]. + pub fn push_resource( + &mut self, + value: T, + ) -> ExecutionScopeResult> { + self.ensure_accepting()?; + self.resources + .push(value) + .map_err(ExecutionScopeError::Resource) + } + + /// Registers a host operation while the scope is Active. + pub fn start_operation(&mut self, spec: OperationSpec) -> ExecutionScopeResult { + self.ensure_accepting()?; + self.operations + .start(spec) + .map_err(ExecutionScopeError::Operation) + } + + /// Cancels one registered operation by id, forwarding the reason to its + /// driver. Generic and host-agnostic; returns `false` when the operation + /// was already terminal. + pub fn cancel_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> ExecutionScopeResult { + self.operations + .cancel(id, reason) + .map_err(ExecutionScopeError::Operation) + } + + /// Marks an operation completed without polling. The terminal slot remains + /// occupied until [`take_operation_outcome`](Self::take_operation_outcome). + pub fn complete_operation(&mut self, id: OperationId) -> ExecutionScopeResult { + self.operations + .complete(id) + .map_err(ExecutionScopeError::Operation) + } + + /// Consumes one terminal outcome and releases its slot for generation reuse. + pub fn take_operation_outcome( + &mut self, + id: OperationId, + ) -> ExecutionScopeResult { + self.operations + .take_outcome(id) + .map_err(ExecutionScopeError::Operation) + } + + /// Aborts a started operation in one step so it never produces a + /// guest-visible result: cancels the driver exactly once if pending + /// (recording the first reason), then consumes and immediately releases + /// the slot, restoring full registry capacity and making the id stale. + /// + /// This is the rollback counterpart to + /// [`start_operation`](Self::start_operation), intended for call sites + /// that register an operation and then hit a fallible handoff. Even when + /// the driver's `cancel` reports a typed failure, the slot is still + /// released. A stale/foreign/out-of-range id is rejected with the typed + /// error and no registry mutation. + pub fn abort_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> ExecutionScopeResult { + self.operations + .abort(id, reason) + .map_err(ExecutionScopeError::Operation) + } + + /// Begins closing the resource through the generic table contract. + /// + /// This is the generic "close one resource" adapter (host-agnostic): the + /// resource arena/type/generation/live checks and `begin_close` happen + /// before any state mutation, so a rejected close leaves the table + /// untouched. A `Pending` close is driven by the usual scope + /// [`poll_close`](Self::poll_close) machinery, so the caller never has to + /// dispatch on a concrete resource class. + pub fn close_resource( + &mut self, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> ExecutionScopeResult { + let token = self + .resources + .typed::(handle) + .map_err(ExecutionScopeError::Resource)?; + self.resources + .begin_close(token, reason) + .map_err(ExecutionScopeError::Resource) + } + + /// The first cleanup failure recorded so far, if any. + pub fn first_error(&self) -> Option<&ScopeCloseError> { + self.first_error.as_ref() + } + + /// Total cleanup failures recorded so far across the whole shutdown + /// (operations then resources), including the one in + /// [`first_error`](Self::first_error). + pub fn failed_count(&self) -> usize { + self.failed_count + } + + /// Begins scope shutdown: **Active → Closing**, sealing new inserts. + /// + /// Idempotent and first-reason-wins: + /// - `Ok(true)` on the first transition; + /// - `Ok(false)` on a repeat with the already-bound reason; + /// - `Err([`ExecutionScopeError::CloseAlreadyInProgress`])` on a conflicting + /// reason (the first reason is preserved). + pub fn begin_close(&mut self, reason: ResourceCloseReason) -> ExecutionScopeResult { + match self.state { + ScopeState::Active => { + self.state = ScopeState::Closing; + self.close_reason = Some(reason); + // Operationally seal the registry so no operation can start after + // this point, in addition to the scope-level guard. + self.operations.seal(); + Ok(true) + } + ScopeState::Closing | ScopeState::Quiescent => { + if self.close_reason == Some(reason) { + Ok(false) + } else { + Err(ExecutionScopeError::CloseAlreadyInProgress { + current: self.close_reason, + requested: reason, + }) + } + } + } + } + + /// Runs the VM-Drop-only nonblocking resource close launch after the normal + /// scope close poll has cancelled operations and begun all current leaves. + /// This never changes the scope state or claims quiescence. + pub(crate) fn begin_drop_resource_close_nonblocking(&mut self) -> ExecutionScopeResult<()> { + debug_assert_eq!(self.state, ScopeState::Closing); + let reason = self.close_reason.unwrap_or(ResourceCloseReason::VmDrop); + self.resources + .begin_close_remaining_for_drop(reason) + .map_err(ExecutionScopeError::Resource) + } + + /// Drives the closing scope to quiescence. + /// + /// Pipeline (in order): + /// 1. *operations* (once): every pending operation is cancelled; + /// 2. *resources*: every resource closes child-first via the table's + /// caller-context poll close. + /// + /// Returns [`Poll::Pending`] while any operation or resource is still + /// pending (quiescence is blocked), and [`Poll::Ready`] with the fixed + /// terminal outcome exactly once both registries are empty. Once quiescent, + /// repeated polls return the same terminal outcome (idempotent). + /// + /// An Active scope (no close requested) returns + /// [`ExecutionScopeError::ScopeNotClosing`]. + pub fn poll_close( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + match self.state { + ScopeState::Active => { + return Poll::Ready(Err(ExecutionScopeError::ScopeNotClosing)); + } + ScopeState::Quiescent => { + return Poll::Ready(Ok(self.terminal.clone().expect("quiescent has terminal"))); + } + ScopeState::Closing => {} + } + + let reason = self.close_reason.expect("closing scope has a bound reason"); + + // Phase 1 — operations: cancel every pending operation exactly once. + if !self.operations_drained { + let summary = self.operations.cancel_all(operation_reason(reason)); + if let Some(error) = summary.first_error() { + self.record_failure(ScopeCloseError::Operation(error.clone())); + } + // Every failed operation cancellation/cleanup counts toward the + // failure total; `failed` includes the first-error case above. + self.failed_count += summary + .failed() + .saturating_sub(usize::from(summary.first_error().is_some())); + self.operations_drained = true; + } + + // A cancelled worker may keep its terminal slot until its driver is + // polled to quiescence; keep the scope Closing and let the worker's + // completion waker drive the next poll. + if !self.operations.poll_quiescence(cx) { + return Poll::Pending; + } + if !self.operations.is_empty() { + // A still-registered operation (not yet drained) blocks quiescence. + return Poll::Pending; + } + + // Phase 2 — resources: child-first, best-effort, caller-context close. + match self.resources.poll_close_all_report(reason, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(report)) => { + if let Some(error) = report.first_error.clone() { + self.record_failure(ScopeCloseError::Resource(error)); + } + // The resource sweep's failure count already includes the + // first error (recorded above); only the remainder is new. + self.failed_count += report + .failed + .saturating_sub(usize::from(report.first_error.is_some())); + self.finish_close(); + Poll::Ready(Ok(self + .terminal + .clone() + .expect("finish_close set terminal"))) + } + Poll::Ready(Err(error)) => { + self.record_failure(ScopeCloseError::Resource(error)); + self.finish_close(); + Poll::Ready(Ok(self + .terminal + .clone() + .expect("finish_close set terminal"))) + } + } + } + + /// Guard applied before any new resource/operation insert. + fn ensure_accepting(&self) -> ExecutionScopeResult<()> { + if self.state == ScopeState::Active { + Ok(()) + } else { + Err(ExecutionScopeError::ScopeClosing) + } + } + + /// Records a cleanup failure: first-error-wins plus a failure-count + /// increment (host-agnostic; used by operations and resources). + fn record_failure(&mut self, error: ScopeCloseError) { + if self.first_error.is_none() { + self.first_error = Some(error); + } + self.failed_count += 1; + } + + /// Freezes the terminal outcome once both registries are empty. + fn finish_close(&mut self) { + debug_assert!(self.operations.is_empty(), "operations must be drained"); + debug_assert!(self.resources.is_empty(), "resources must be closed"); + self.state = ScopeState::Quiescent; + self.terminal = Some(match self.first_error.take() { + Some(first) => ScopeCloseOutcome::SuccessWithErrors(ScopeCloseFailure { + first, + failed: self.failed_count, + }), + None => ScopeCloseOutcome::Success, + }); + } +} + +struct ScopeDropWake; + +impl Wake for ScopeDropWake { + fn wake(self: Arc) {} +} + +impl Drop for ExecutionScope { + fn drop(&mut self) { + if self.state == ScopeState::Active { + self.state = ScopeState::Closing; + self.close_reason = Some(ResourceCloseReason::VmDrop); + self.operations.seal(); + } + if self.state != ScopeState::Closing { + return; + } + let waker = Waker::from(Arc::new(ScopeDropWake)); + let mut cx = Context::from_waker(&waker); + let _ = self.poll_close(&mut cx); + if self.state == ScopeState::Closing { + // A standalone scope drop cannot keep polling a Pending resource, + // but it must still launch every remaining ancestor close with the + // VmDrop reason before ResourceTable itself is dropped. + let _ = self.begin_drop_resource_close_nonblocking(); + } + } +} + +/// Maps the generic resource-layer close reason onto the parallel generic +/// operation-layer cancellation reason. Both vocabularies are stable and +/// 1:1; the scope stays host-agnostic. +fn operation_reason(reason: ResourceCloseReason) -> OperationCancelReason { + match reason { + ResourceCloseReason::Requested => OperationCancelReason::Requested, + ResourceCloseReason::Deadline => OperationCancelReason::Deadline, + ResourceCloseReason::VmReset => OperationCancelReason::VmReset, + ResourceCloseReason::Parent => OperationCancelReason::Parent, + ResourceCloseReason::ResourceClosed => OperationCancelReason::ResourceClosed, + ResourceCloseReason::VmDrop => OperationCancelReason::VmDrop, + } +} + +#[cfg(test)] +mod tests { + use super::{ExecutionScope, ExecutionScopeError}; + use crate::vm::operation::error::OperationErrorCode; + use crate::vm::operation::id::MAX_REGISTRY_TAG; + use std::sync::atomic::AtomicU64; + + #[test] + fn construction_propagates_operation_registry_tag_exhaustion() { + static COUNTER: AtomicU64 = AtomicU64::new(MAX_REGISTRY_TAG + 1); + let _source = + crate::vm::operation::id::test_seam::ScopedRegistryTagSource::install(&COUNTER); + + let error = match ExecutionScope::new() { + Ok(_) => panic!("operation registry tag exhaustion must be fallible"), + Err(error) => error, + }; + let ExecutionScopeError::Operation(error) = error else { + panic!("expected the operation exhaustion variant"); + }; + assert_eq!( + error.code(), + OperationErrorCode::OperationRegistryTagExhausted + ); + assert_eq!(error.limit(), Some(MAX_REGISTRY_TAG)); + assert_eq!(error.value(), Some(MAX_REGISTRY_TAG + 1)); + } +} diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index 38f08c52..50b16106 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -2,28 +2,37 @@ //! //! [`HostRuntime`] owns the host-facing capability surface: bound host //! functions and their symbol table, builtin overrides, resolved call slots, -//! host operation id allocation, the async bridge, and the print sink. -//! Interpreter state and run budgets live outside this struct (see +//! host operation id allocation, the async bridge, the execution scope (one +//! resource table + one operation registry), and the print sink. Interpreter +//! state and run budgets live outside this struct (see //! [`Instance`](super::instance::Instance) and //! [`RunContext`](super::run_context::RunContext)). //! //! This mechanical decomposition groups host-facing ownership and reset/drop -//! behavior. Concrete adapter runtime state (currently the legacy IO -//! completion mailbox) deliberately stays on the `Vm` facade in this commit; -//! it moves onto the generic execution-scope lifecycle in a later commit. +//! behavior. The execution scope is the isolated resource/operation owner +//! that host code addresses through the generic, host-agnostic +//! [`ExecutionScope`] lifecycle. Persistent adapter policy/configuration +//! lives in the generic [`ModuleStateStore`](super::host_state::ModuleStateStore); +//! [`HostRuntime`] stays feature-neutral and owns no concrete adapter state +//! fields (the legacy IO completion mailbox remains on the `Vm` facade until +//! the adapter migrates it onto the scope lifecycle). +use std::any::{Any, TypeId}; use std::collections::HashMap; +use crate::vm::execution_scope::ExecutionScope; use crate::vm::host::{HostAsyncBridge, HostOpId, VmHostFunction}; +use crate::vm::host_state::ModuleStateStore; /// Embedder-supplied print sink for `print`/`debug` output. pub(crate) type RuntimePrintSink = dyn FnMut(String) + Send; /// Host-owned capabilities, resources, operations, and subsystem state. /// -/// Thread safety: `HostRuntime` is `!Sync` (host functions are mutable and -/// not shareable) and not shared; one facade owns one host runtime. Clone -/// semantics: not `Clone` — host bindings must not be duplicated across VMs. +/// Thread safety: `HostRuntime` is `!Sync` (host functions and the execution +/// scope are mutable and not shareable) and not shared; one facade owns one +/// host runtime. Clone semantics: not `Clone` — host bindings must not be +/// duplicated across VMs. pub(crate) struct HostRuntime { pub(super) host_functions: Vec, pub(crate) host_function_symbols: HashMap, @@ -33,11 +42,25 @@ pub(crate) struct HostRuntime { pub(crate) async_bridge: Option>, pub(crate) runtime_print_sink: Option>, pub(crate) next_host_op_id: HostOpId, + /// The isolated execution scope owned by this host runtime. + pub(super) execution_scope: ExecutionScope, + /// The single generic per-VM module-state store. + /// + /// Persistent adapter policy/configuration (and later external-extension + /// module state) lives here, keyed by `TypeId`, and deliberately survives + /// execution-scope reset for the lifetime of the VM. + pub(crate) module_state_store: ModuleStateStore, } impl HostRuntime { /// Creates an empty host runtime with no bound functions, no async bridge - /// or print sink. + /// or print sink, plus a fresh active `ExecutionScope`. + /// + /// The execution-scope construction is fallible only when a process-unique + /// identity space (resource arena or operation-registry tag) is exhausted, + /// which cannot happen in a host runtime owned by a single `Vm` in one + /// process. The scope-owned `expect` keeps `Vm::new` infallible while + /// still giving every VM a live, independent scope. pub(crate) fn new() -> Self { Self { host_functions: Vec::new(), @@ -48,8 +71,53 @@ impl HostRuntime { async_bridge: None, runtime_print_sink: None, next_host_op_id: 1, + execution_scope: ExecutionScope::new() + .expect("host runtime execution-scope identity space must be available"), + module_state_store: ModuleStateStore::new(), } } + + /// Stores host-owned typed module state, replacing any earlier value of + /// the same type. + pub(crate) fn set_module_state(&mut self, state: T) -> bool { + self.module_state_store.set(state) + } + + /// Borrows the registered typed module state, if any. + pub(crate) fn get_module_state(&self) -> Option<&T> { + self.module_state_store.get() + } + + /// Borrows the registered typed module state mutably, if any. + pub(crate) fn get_module_state_mut(&mut self) -> Option<&mut T> { + self.module_state_store.get_mut() + } + + /// Removes and returns the registered typed module state, if any. + pub(crate) fn remove_module_state(&mut self) -> Option { + self.module_state_store.remove() + } + + /// Returns `true` when no module state is currently registered. + pub(crate) fn is_module_state_empty(&self) -> bool { + self.module_state_store.is_empty() + } + + /// Replaces the active execution scope with a fresh one. + /// + /// Dropping the old scope runs its generic close sweep, retiring every + /// in-flight operation and closing every resource before the new scope + /// starts. Used by `Vm::reset_for_reuse` so resource/operation teardown + /// goes through the generic scope lifecycle. + /// + /// Persistent policy/configuration in the `ModuleStateStore` is + /// deliberately **not** touched here; it survives reset. This function + /// contains no adapter name, feature, or concrete `TypeId`: it is wholly + /// feature-neutral. + pub(crate) fn reset_execution_scope(&mut self) { + self.execution_scope = ExecutionScope::new() + .expect("host runtime execution-scope identity space must be available"); + } } impl Default for HostRuntime { diff --git a/src/vm/host_state.rs b/src/vm/host_state.rs new file mode 100644 index 00000000..a1ff8f78 --- /dev/null +++ b/src/vm/host_state.rs @@ -0,0 +1,125 @@ +//! Generic lower-layer module-state store. +//! +//! [`ModuleStateStore`] is the single persistent, typed per-VM module-state +//! store owned by [`HostRuntime`](super::host_runtime::HostRuntime) and +//! surfaced to host extensions through +//! [`HostContext`](super::host_context::HostContext). It lives in the generic +//! VM layer so persistent policy/configuration storage is one generic +//! primitive that does not depend on [`HostContext`], [`HostModule`], +//! builtins, or any adapter feature. +//! +//! Entries are uniquely owned `Box` values keyed by +//! [`TypeId`], with typed `set` / `get` / `get_mut` / `remove` operations. +//! The store is deliberately opaque to the VM and survives execution-scope +//! reset for the lifetime of the owning runtime/`Vm`. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; + +/// The typed per-VM module-state store. +/// +/// Persistent policy/configuration lives here and is exposed through the +/// host-context boundary. State is typed at compile time (keyed by +/// [`TypeId`]) and survives scope reset / scope recycling. +#[derive(Default)] +pub(crate) struct ModuleStateStore { + entries: HashMap>, +} + +impl ModuleStateStore { + /// Creates an empty module-state store. + pub(crate) fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + /// Registers a typed module-state value, replacing any earlier value of + /// the same type. + /// + /// Returns `true` when a previously registered value of the same type was + /// replaced, and `false` when this value was freshly registered. + pub(crate) fn set(&mut self, state: T) -> bool { + self.entries + .insert(TypeId::of::(), Box::new(state)) + .is_some() + } + + /// Borrows the registered typed module state, if any. + pub(crate) fn get(&self) -> Option<&T> { + self.entries + .get(&TypeId::of::()) + .and_then(|state| state.downcast_ref::()) + } + + /// Borrows the registered typed module state mutably, if any. + pub(crate) fn get_mut(&mut self) -> Option<&mut T> { + self.entries + .get_mut(&TypeId::of::()) + .and_then(|state| state.downcast_mut::()) + } + + /// Removes and returns the registered typed module state. + /// + /// Returns the uniquely owned value, removing its store entry. No + /// uniqueness invariant (`Arc::get_mut` style) is required because each + /// entry is owned exclusively by this store. + pub(crate) fn remove(&mut self) -> Option { + self.entries + .remove(&TypeId::of::()) + .and_then(|state| state.downcast::().ok()) + .map(|state| *state) + } + + /// Returns `true` when no module state is currently registered. + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::ModuleStateStore; + + #[derive(Debug, PartialEq)] + struct DemoState { + value: u64, + } + + #[test] + fn set_get_and_replacement_reporting() { + let mut store = ModuleStateStore::new(); + assert!(!store.set(DemoState { value: 1 })); + assert_eq!(store.get::(), Some(&DemoState { value: 1 })); + assert!(store.set(DemoState { value: 2 })); + assert_eq!(store.get::(), Some(&DemoState { value: 2 })); + } + + #[test] + fn get_mut_mutates_in_place() { + let mut store = ModuleStateStore::new(); + store.set(DemoState { value: 1 }); + store.get_mut::().expect("state present").value += 10; + assert_eq!(store.get::(), Some(&DemoState { value: 11 })); + } + + #[test] + fn remove_returns_uniquely_owned_value() { + let mut store = ModuleStateStore::new(); + store.set(DemoState { value: 7 }); + assert_eq!(store.remove::(), Some(DemoState { value: 7 })); + assert!(store.is_empty()); + assert!(store.get::().is_none()); + assert_eq!(store.remove::(), None); + } + + #[test] + fn distinct_types_do_not_collide() { + let mut store = ModuleStateStore::new(); + store.set(DemoState { value: 1 }); + store.set(String::from("policy")); + assert_eq!(store.get::(), Some(&DemoState { value: 1 })); + assert_eq!(store.get::(), Some(&String::from("policy"))); + assert!(!store.is_empty()); + } +} diff --git a/src/vm/mod.rs b/src/vm/mod.rs index ed982c01..2f154124 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -7,14 +7,18 @@ pub(crate) mod aot; pub mod diagnostics; mod engine; mod epoch; +pub mod execution_scope; mod fuel; mod host; mod host_runtime; +pub(crate) mod host_state; mod instance; pub(crate) mod jit; mod map_iter; pub(crate) mod native; +pub mod operation; pub mod program; +pub mod resource; mod run_context; mod store; mod superinstructions; @@ -23,6 +27,7 @@ mod tests; pub use self::aot::AotArtifactError; use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; +use self::execution_scope::ExecutionScopeError; pub use self::fuel::FuelCheckpoint; pub use self::host::{ CallOutcome, CallReturn, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, @@ -32,6 +37,7 @@ pub use self::host::{ use self::host::{HostCallExecOutcome, VmHostFunction}; use self::host_runtime::HostRuntime; use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; +pub use self::resource::ResourceCloseReason; use self::run_context::{InterruptMode, RunContext}; pub use crate::bytecode::{ CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, @@ -107,6 +113,10 @@ pub enum VmError { BytecodeBounds, HostError(String), JitNative(String), + /// A structured failure from the execution scope (resource or operation + /// registry state/close error), preserved for the modern resource and + /// operation lifecycle. + ExecutionScope(ExecutionScopeError), InvalidFuelCheckInterval(u32), InvalidEpochCheckInterval(u32), InterruptionModeConflict { @@ -183,6 +193,7 @@ impl std::fmt::Display for VmError { VmError::BytecodeBounds => write!(f, "bytecode bounds"), VmError::HostError(message) => write!(f, "host error: {message}"), VmError::JitNative(message) => write!(f, "jit native error: {message}"), + VmError::ExecutionScope(error) => write!(f, "execution scope error: {error}"), VmError::InvalidFuelCheckInterval(value) => { write!(f, "invalid fuel check interval {value}, expected >= 1") } @@ -281,9 +292,8 @@ pub struct Vm { pub(crate) host: HostRuntime, /// Legacy pre-scope IO runtime state. /// - /// This commit keeps IO ownership on the `Vm` facade (it was not moved - /// into [`HostRuntime`]); the generic scope-lifecycle migration relocates - /// it in a later commit. + /// This commit keeps IO ownership on the `Vm` facade; the generic + /// scope-lifecycle migration relocates it in a later commit. pub(crate) io_state: crate::builtins::runtime::IoState, } @@ -2650,10 +2660,20 @@ impl Vm { self.program.as_ref() } + /// Returns the bound host function count. pub fn bound_function_count(&self) -> usize { self.host.host_functions.len() } + /// Mutable access to the VM's isolated execution scope. + /// + /// The scope owns one resource registry and one operation registry; this + /// is the host-facing surface for allocating/borrowing resources and + /// starting/cancelling operations without reaching into VM private state. + pub fn execution_scope(&mut self) -> &mut crate::vm::execution_scope::ExecutionScope { + &mut self.host.execution_scope + } + pub fn has_bound_function(&self, name: &str) -> bool { self.host.host_function_symbols.contains_key(name) } @@ -2795,6 +2815,12 @@ impl Vm { pub fn shutdown(&mut self) { self.invalidate_callback_registries(); self.cancel_waiting_host_op(); + // Begin execution-scope shutdown (first-reason-wins; sealing the + // operation registry) before tearing down interpreter state. + let _ = self + .host + .execution_scope + .begin_close(crate::vm::resource::ResourceCloseReason::VmDrop); self.instance.queued_callables.clear(); self.instance.completed_callable_results.clear(); self.instance.owned_callables.clear(); diff --git a/src/vm/operation/driver.rs b/src/vm/operation/driver.rs new file mode 100644 index 00000000..1f027b8a --- /dev/null +++ b/src/vm/operation/driver.rs @@ -0,0 +1,126 @@ +//! Object-safe operation driver contract. +//! +//! This module defines the [`HostOperation`] driver contract that the +//! operation registry drives. Each pending operation owns its poll and +//! cancel behaviour; the registry performs no owner/poller dispatch. +//! +//! Cancellation has a single authority: the operation's *owner* (or the +//! scope that owns the operation). Drivers implement the concrete +//! [`HostOperation::cancel`] action; the registry records the first +//! [`OperationCancelReason`] and the terminal status but does not build a +//! parent/child signal graph. + +use std::any::Any; +use std::task::{Context, Poll}; + +use super::error::{OperationError, OperationResult}; +use super::reason::OperationCancelReason; + +/// Opaque terminal result reported by an operation once it finishes. +/// +/// A driver returns this from [`HostOperation::poll`]. The registry stores it +/// as the operation's terminal result for later retrieval. The actual host +/// *value* the operation produced is delivered by the driver to its own +/// consumer (e.g. a captured completion callback); the operation layer tracks +/// lifecycle and status, not the concrete produced byte stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationOutcome { + /// Operation finished successfully. + Completed, + /// Operation failed with an operation error. + Failed(OperationError), + /// Operation was cancelled; carries the first recorded cancellation + /// reason. + Cancelled(OperationCancelReason), +} + +/// Object-safe driver contract for a single in-flight host operation. +/// +/// Implementors must be `Send` (the operation may be owned by a host that +/// runs work on another thread) and not borrow from the VM across a poll. +/// Polling advances the operation; cancellation is delivered in-band through +/// [`HostOperation::cancel`]. +pub trait HostOperation: Any + Send + 'static { + /// Drive the operation one step. + /// + /// Return `Poll::Pending` while the operation is still running, or + /// `Poll::Ready(Ok(()))` / `Poll::Ready(Err(error))` once it reaches a + /// terminal state. Implementors must be cancellation-aware: after + /// [`HostOperation::cancel`] has been observed they should return + /// `Poll::Ready` promptly so the registry can record the terminal status. + fn poll(&mut self, cx: &mut Context<'_>) -> Poll>; + + /// Ask the driver to stop the underlying work. + /// + /// Must be idempotent: it is invoked at most once per operation + /// (later calls on an already-cancelled operation are suppressed by the + /// registry). The reason is typed for diagnostics and for the driver to + /// distinguish scope reset, deadline and explicit requests. This is the + /// single cancellation authority; drivers must not build their own + /// parent/child token trees. + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()>; + + /// Whether all underlying work has terminated after cancellation. The + /// registry uses this to keep scope quiescence from claiming completion + /// while a detached worker still owns resources. Drivers without a + /// background worker must explicitly return `true`; the fail-closed + /// default prevents a worker-bearing driver from being released merely + /// because it reached a terminal status. + fn is_quiescent(&self) -> bool { + false + } + + /// Registers a waker for the transition to quiescent after cancellation. + fn register_quiescence_waker(&mut self, _cx: &Context<'_>) {} + + /// Cancels and, when a resource is already in its close phase, waits for + /// the driver's worker to terminate. The default is appropriate for + /// drivers without separate background work. + fn cancel_and_wait(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancel(reason) + } +} + +/// Optional per-operation cleanup, called exactly once on the first terminal +/// transition. Failures are isolated by the registry: the operation still +/// becomes terminal and any batch cancellation continues past a failing +/// cleanup. +pub type OperationCleanup = + Box OperationResult<()> + Send + 'static>; + +/// Configuration describing one operation for +/// [`OperationRegistry::start`](crate::vm::operation::OperationRegistry::start). +pub struct OperationSpec { + /// Optional absolute deadline. If a deadline elapses while the operation + /// is still pending, the registry cancels it with + /// [`OperationCancelReason::Deadline`] (unless it was already cancelled with + /// an earlier reason). + pub deadline: Option, + /// The driver that owns poll/cancel behaviour. + pub driver: Box, + /// Optional cleanup run once on the first terminal transition. + pub cleanup: Option, +} + +impl OperationSpec { + /// Builds a spec from a driver, leaving deadline/cleanup unset. + pub fn new(driver: impl HostOperation + 'static) -> Self { + Self { + deadline: None, + driver: Box::new(driver), + cleanup: None, + } + } + + /// Sets an optional deadline for the operation. + pub fn with_deadline(mut self, deadline: std::time::Instant) -> Self { + self.deadline = Some(deadline); + self + } + + /// Attaches a cleanup hook. + pub fn with_cleanup(mut self, cleanup: OperationCleanup) -> Self { + self.cleanup = Some(cleanup); + self + } +} diff --git a/src/vm/operation/error.rs b/src/vm/operation/error.rs new file mode 100644 index 00000000..09158a18 --- /dev/null +++ b/src/vm/operation/error.rs @@ -0,0 +1,151 @@ +//! Host-agnostic operation errors. +//! +//! Carries a stable machine-readable category, the operation scope +//! name, and optional limit/value payloads (e.g. the pending +//! capacity reached and the offending operation id). + +use std::fmt; + +/// Result alias used by the generic operation modules. +pub type OperationResult = Result; + +/// Stable, machine-readable categories for operation capability failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationErrorCode { + /// The operation configuration was invalid (zero capacity, etc). + InvalidConfiguration, + /// The configured pending-operation ceiling was reached. + OperationLimitExceeded, + /// A raw operation id did not parse into a valid operation handle. + InvalidOperationId, + /// A handle was valid but referred to a different operation registry. + OperationWrongRegistry, + /// The operation id referred to a generation that had moved on. + OperationStale, + /// The requested operation does not exist in this registry. + OperationNotFound, + /// The operation is currently pending. + OperationPending, + /// The operation exists, but has already reached a terminal status. + OperationNotPending, + /// The operation id space was exhausted. + OperationIdExhausted, + /// The process-unique operation-registry tag space was exhausted. + OperationRegistryTagExhausted, + /// A cleanup hook failed after the operation's terminal transition. + OperationCleanupFailed, + /// The registry is sealed and rejects the start of new operations. + OperationRegistrySealed, + /// A driver poll or cancellation action failed. + OperationDriverFailed, +} + +impl OperationErrorCode { + /// Stable snake_case string for logs and machine use. + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidConfiguration => "invalid_configuration", + Self::OperationLimitExceeded => "operation_limit_exceeded", + Self::InvalidOperationId => "invalid_operation_id", + Self::OperationWrongRegistry => "operation_wrong_registry", + Self::OperationStale => "operation_stale", + Self::OperationNotFound => "operation_not_found", + Self::OperationPending => "operation_pending", + Self::OperationNotPending => "operation_not_pending", + Self::OperationIdExhausted => "operation_id_exhausted", + Self::OperationRegistryTagExhausted => "operation_registry_tag_exhausted", + Self::OperationCleanupFailed => "operation_cleanup_failed", + Self::OperationRegistrySealed => "operation_registry_sealed", + Self::OperationDriverFailed => "operation_driver_failed", + } + } +} + +/// A structured, human- and machine-readable operation error. +/// +/// `code` is the stable category, `operation` is the VM scope the failure +/// occurred in, and `limit`/`value` carry optional numeric payloads (e.g. +/// the pending ceiling and the offending raw operation id). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperationError { + code: OperationErrorCode, + operation: &'static str, + message: String, + limit: Option, + value: Option, +} + +impl OperationError { + /// Builds an operation error without an optional payload. + pub fn new( + code: OperationErrorCode, + operation: &'static str, + message: impl Into, + ) -> Self { + Self { + code, + operation, + message: message.into(), + limit: None, + value: None, + } + } + + /// The stable machine-readable category. + pub fn code(&self) -> OperationErrorCode { + self.code + } + + /// The operation scope this error occurred in. + pub fn operation(&self) -> &'static str { + self.operation + } + + /// The human-readable detail message. + pub fn message(&self) -> &str { + &self.message + } + + /// The optional capacity/limit payload, when one is attached. + pub fn limit(&self) -> Option { + self.limit + } + + /// The optional numeric value payload, when set. + pub fn value(&self) -> Option { + self.value + } + + /// Attaches a numeric limit payload. + pub fn with_limit(mut self, limit: u64) -> Self { + self.limit = Some(limit); + self + } + + /// Attaches a numeric value payload. + pub fn with_value(mut self, value: u64) -> Self { + self.value = Some(value); + self + } +} + +impl fmt::Display for OperationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "operation error [{}] in {}: {}", + self.code.as_str(), + self.operation, + self.message + )?; + if let Some(limit) = self.limit { + write!(f, " (limit: {limit})")?; + } + if let Some(value) = self.value { + write!(f, " (value: {value})")?; + } + Ok(()) + } +} + +impl std::error::Error for OperationError {} diff --git a/src/vm/operation/id.rs b/src/vm/operation/id.rs new file mode 100644 index 00000000..a708b386 --- /dev/null +++ b/src/vm/operation/id.rs @@ -0,0 +1,312 @@ +//! VM-owned packed operation identifiers. +//! +//! An [`OperationId`] is an opaque 63-bit token that *packs* the three +//! identifiers that uniquely address an in-flight operation in this VM: +//! +//! * a **registry tag** identifying which [`registry::OperationRegistry`] +//! owns the id (allocated by [`allocate_registry_tag`]); +//! * a one-based **slot identity** selecting an entry inside that registry; +//! * a **generation** that distinguishes successive occupants of the same +//! slot. +//! +//! Packing the three fields into a single `u64` keeps the id copyable and +//! passable across a dynamic host call as the lone capability token, while +//! still allowing per-field validation and recovery. +//! +//! ## Bit layout (63-bit positive) +//! +//! The top (sign) bit is clear so the id is a positive `i64`. The remaining +//! 63 bits are split into three contiguous fields, high to low: +//! +//! ```text +//! 63 43 42 22 21 0 +//! |<- tag:20 ->|<- slot:21 ->|<- gen:22 ->| +//! MSB LSB +//! ``` +//! +//! Fields are one-based where noted (slot identity, tag, generation all start +//! at `1`); a field value of `0` is never a valid id. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::error::{OperationError, OperationErrorCode, OperationResult}; + +/// Width (bits) of the registry-tag field. +const REG_TAG_BITS: u32 = 20; +/// Width (bits) of the slot-identity field. +const SLOT_BITS: u32 = 21; +/// Width (bits) of the generation field. +const GEN_BITS: u32 = 22; + +/// Shift up to the registry-tag field. +const REG_TAG_SHIFT: u32 = SLOT_BITS + GEN_BITS; +/// Shift up to the slot-identity field. +const SLOT_SHIFT: u32 = GEN_BITS; +/// The generation resides in the low bits. +const GEN_SHIFT: u32 = 0; + +/// Reserved top (sign) bit; must always be clear in a valid raw id. +const SIGN_MASK: u64 = 1u64 << 63; +/// Field mask for the registry tag. +const REG_TAG_MASK: u64 = ((1u64 << REG_TAG_BITS) - 1) << REG_TAG_SHIFT; +/// Field mask for the slot identity. +const SLOT_MASK: u64 = ((1u64 << SLOT_BITS) - 1) << SLOT_SHIFT; +/// Field mask for the generation. +const GEN_MASK: u64 = ((1u64 << GEN_BITS) - 1) << GEN_SHIFT; + +/// Maximum registry tag (inclusive); tag `0` is reserved/invalid. +pub(crate) const MAX_REGISTRY_TAG: u64 = (1u64 << REG_TAG_BITS) - 1; +/// Maximum one-based slot identity (inclusive). +pub(super) const MAX_SLOT_IDENTITY: u64 = (1u64 << SLOT_BITS) - 1; +/// Maximum generation (inclusive); generation `0` is reserved/invalid. +pub(super) const MAX_GENERATION: u64 = (1u64 << GEN_BITS) - 1; + +/// Process-global allocator of registry tags. +/// +/// Tags start at `1`, are handed out monotonically, are never reused, and +/// eventually saturate at [`MAX_REGISTRY_TAG`]; the call immediately after +/// the maximum is handed out fails with `OperationRegistryTagExhausted`. +static NEXT_REGISTRY_TAG: AtomicU64 = AtomicU64::new(1); + +/// Test-only, per-thread registry-tag source override. +#[cfg(test)] +pub(crate) mod test_seam { + use std::cell::Cell; + use std::sync::atomic::AtomicU64; + + thread_local! { + static REGISTRY_TAG_SOURCE: Cell> = const { Cell::new(None) }; + } + + pub(crate) fn source() -> Option<&'static AtomicU64> { + REGISTRY_TAG_SOURCE.with(|cell| cell.get()) + } + + /// Installs a private tag counter for the current thread until drop. + pub(crate) struct ScopedRegistryTagSource { + _private: (), + } + + impl ScopedRegistryTagSource { + pub(crate) fn install(counter: &'static AtomicU64) -> Self { + REGISTRY_TAG_SOURCE.with(|cell| { + assert!( + cell.get().is_none(), + "nested registry tag source override is unsupported" + ); + cell.set(Some(counter)); + }); + Self { _private: () } + } + } + + impl Drop for ScopedRegistryTagSource { + fn drop(&mut self) { + REGISTRY_TAG_SOURCE.with(|cell| cell.set(None)); + } + } +} + +/// Opaque, packed VM operation identifier. +/// +/// Represents the (registry tag, slot identity, generation) triple as a +/// single positive 63-bit token. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OperationId(u64); + +impl OperationId { + /// Validates and decodes a raw packed id. + /// + /// Rejects a zero raw value, a set sign bit, a zero/out-of-range + /// registry tag, a zero slot identity, and a zero generation, each with + /// [`OperationErrorCode::InvalidOperationId`] carrying the offending + /// raw value as its `value` payload. + pub fn from_raw(raw: u64) -> OperationResult { + let invalid = || { + OperationError::new( + OperationErrorCode::InvalidOperationId, + "vm::operation", + "invalid packed operation id", + ) + .with_value(raw) + }; + + if raw == 0 || (raw & SIGN_MASK) != 0 { + return Err(invalid()); + } + + let tag = (raw & REG_TAG_MASK) >> REG_TAG_SHIFT; + let slot_identity = (raw & SLOT_MASK) >> SLOT_SHIFT; + let generation = (raw & GEN_MASK) >> GEN_SHIFT; + + if tag == 0 || tag > MAX_REGISTRY_TAG { + return Err(invalid()); + } + if slot_identity == 0 || slot_identity > MAX_SLOT_IDENTITY { + return Err(invalid()); + } + if generation == 0 || generation > MAX_GENERATION { + return Err(invalid()); + } + + Ok(Self(raw)) + } + + /// The raw packed id, safe to pass across a dynamic host call where the + /// id is the only capability token the script holds. + pub const fn raw(self) -> u64 { + self.0 + } + + /// The owning registry tag (one-based). + pub(super) const fn registry_tag(self) -> u64 { + (self.0 & REG_TAG_MASK) >> REG_TAG_SHIFT + } + + /// The zero-based slot index within the owning registry. + pub(super) fn slot_index(self) -> usize { + let slot_identity = (self.0 & SLOT_MASK) >> SLOT_SHIFT; + // A valid id always has a one-based, non-zero slot identity, so + // this subtraction is safe after `from_raw` validation. + (slot_identity - 1) as usize + } + + /// The slot generation (one-based). + pub(super) const fn generation(self) -> u64 { + (self.0 & GEN_MASK) >> GEN_SHIFT + } +} + +/// Allocates the next process-global registry tag. +/// +/// Returns monotonically increasing tags starting at `1`. Once +/// [`MAX_REGISTRY_TAG`] has been handed out, every subsequent call returns +/// `OperationRegistryTagExhausted`. Uses [`Ordering::Relaxed`] because tags are +/// never compared across threads, only required to be unique. +pub(super) fn allocate_registry_tag() -> OperationResult { + #[cfg(test)] + let source = test_seam::source().unwrap_or(&NEXT_REGISTRY_TAG); + #[cfg(not(test))] + let source = &NEXT_REGISTRY_TAG; + match source.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + // Hand out `current` (1..=MAX), advancing to `current + 1`; once + // `current` exceeds `MAX_REGISTRY_TAG` the space is exhausted. + if current <= MAX_REGISTRY_TAG { + Some(current + 1) + } else { + None + } + }) { + Ok(tag) => Ok(tag), + Err(current) => Err(OperationError::new( + OperationErrorCode::OperationRegistryTagExhausted, + "vm::operation", + "operation registry tag identity space is exhausted", + ) + .with_limit(MAX_REGISTRY_TAG) + .with_value(current)), + } +} + +/// Builds a packed id from structured fields. +/// +/// * `registry_tag` must be in `1..=MAX_REGISTRY_TAG`; +/// * `slot_index` is a zero-based index and is converted to a one-based +/// identity with checked overflow, subject to `1..=MAX_SLOT_IDENTITY`; +/// * `generation` must be in `1..=MAX_GENERATION`. +/// +/// Returns [`None`] for any out-of-bounds/overflowing input. +pub(super) fn encode(registry_tag: u64, slot_index: usize, generation: u64) -> Option { + let slot_identity = u64::try_from(slot_index).ok()?.checked_add(1)?; + + if registry_tag == 0 || registry_tag > MAX_REGISTRY_TAG { + return None; + } + if slot_identity > MAX_SLOT_IDENTITY { + return None; + } + if generation == 0 || generation > MAX_GENERATION { + return None; + } + + let raw = (registry_tag << REG_TAG_SHIFT) | (slot_identity << SLOT_SHIFT) | generation; + Some(OperationId(raw)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reference triple packing helper used to assert exact bit contents. + fn pack(tag: u64, slot_identity: u64, generation: u64) -> u64 { + (tag << REG_TAG_SHIFT) | (slot_identity << SLOT_SHIFT) | generation + } + + #[test] + fn minimum_id_roundtrips_and_is_positive() { + let id = encode(1, 0, 1).expect("minimum id encodes"); + assert_eq!(id.registry_tag(), 1); + assert_eq!(id.slot_index(), 0); + assert_eq!(id.generation(), 1); + let raw = id.raw(); + assert_eq!(raw, pack(1, 1, 1)); + assert!((raw as i64) > 0, "minimum id must be a positive i64"); + assert_eq!(OperationId::from_raw(raw).expect("decodes"), id); + } + + #[test] + fn maximum_id_roundtrips_and_is_positive() { + let id = encode( + MAX_REGISTRY_TAG, + (MAX_SLOT_IDENTITY - 1) as usize, + MAX_GENERATION, + ) + .expect("maximum id encodes"); + assert_eq!(id.registry_tag(), MAX_REGISTRY_TAG); + assert_eq!(id.slot_index(), (MAX_SLOT_IDENTITY - 1) as usize); + assert_eq!(id.generation(), MAX_GENERATION); + let raw = id.raw(); + assert_eq!( + raw, + pack(MAX_REGISTRY_TAG, MAX_SLOT_IDENTITY, MAX_GENERATION) + ); + assert!((raw as i64) > 0, "maximum id must be a positive i64"); + assert_eq!(OperationId::from_raw(raw).expect("decodes"), id); + } + + #[test] + fn decode_rejects_invalid_encodings() { + assert!(OperationId::from_raw(0).is_err()); + assert!(OperationId::from_raw(pack(1, 1, 1) | SIGN_MASK).is_err()); + assert!(OperationId::from_raw(pack(0, 1, 1)).is_err()); + assert!(OperationId::from_raw(pack(1, 0, 1)).is_err()); + assert!(OperationId::from_raw(pack(1, 1, 0)).is_err()); + } + + #[test] + fn encode_rejects_out_of_range_fields() { + assert!(encode(0, 0, 1).is_none(), "zero registry tag"); + assert!(encode(MAX_REGISTRY_TAG + 1, 0, 1).is_none(), "tag overflow"); + assert!( + encode(1, (MAX_SLOT_IDENTITY) as usize, 1).is_none(), + "slot overflow" + ); + assert!(encode(1, 0, 0).is_none(), "zero generation"); + assert!( + encode(1, 0, MAX_GENERATION + 1).is_none(), + "generation overflow" + ); + } + + #[test] + fn allocator_yields_distinct_nonzero_tags() { + let mut tags = Vec::new(); + for _ in 0..64 { + let tag = allocate_registry_tag().expect("tag allocated"); + assert_ne!(tag, 0, "tag must be nonzero"); + assert!(!tags.contains(&tag), "tag must not be reused: {tag}"); + tags.push(tag); + } + assert_eq!(tags.len(), 64); + } +} diff --git a/src/vm/operation/mod.rs b/src/vm/operation/mod.rs new file mode 100644 index 00000000..edf0a77f --- /dev/null +++ b/src/vm/operation/mod.rs @@ -0,0 +1,37 @@ +//! Host-agnostic generic operation layer. +//! +//! This module owns the host-agnostic operation lifecycle (status, +//! cancellation, cleanup) for the VM. The concrete driver contract lives +//! in [`driver`], the registry in [`registry`]. +//! +//! Key ideas: +//! +//! * **Concrete driver owns poll/cancel** — each in-flight operation is a +//! [`HostOperation`] that owns its own [`HostOperation::poll`] and +//! [`HostOperation::cancel`] behaviour; the registry never dispatches on a +//! host domain. +//! * **Registry owns per-entry reason/status** — the registry records the +//! first cancellation reason (deadline included) and the terminal status on +//! each operation entry, forwarding cancellation directly to the owning +//! driver. There is no standalone cancellation-token graph and no second +//! cancellation framework. +//! * **Packed, validated, reusable slots** — [`OperationRegistry`] stores +//! operations in generational slots addressed by a packed registry-tag / +//! slot-identity / generation [`OperationId`]. Caller-supplied ids are +//! validated (foreign tag, out-of-range/future slot, or stale generation are +//! rejected before any status/driver/cleanup mutation) and a released slot +//! is reused under an incremented generation. + +pub mod driver; +pub mod error; +pub mod id; +pub mod reason; +pub mod registry; + +pub use driver::{HostOperation, OperationCleanup, OperationOutcome, OperationSpec}; +pub use error::{OperationError, OperationErrorCode, OperationResult}; +pub use id::OperationId; +pub use reason::OperationCancelReason; +pub use registry::{ + DEFAULT_MAX_PENDING_OPERATIONS, OperationCancelSummary, OperationRegistry, OperationStatus, +}; diff --git a/src/vm/operation/reason.rs b/src/vm/operation/reason.rs new file mode 100644 index 00000000..8cd0c24f --- /dev/null +++ b/src/vm/operation/reason.rs @@ -0,0 +1,124 @@ +//! VM-owned operation cancellation reason. +//! +//! Describes the generic lifecycle of an operation on the VM and the +//! reasons a running operation may be cancelled. This module only +//! covers the *reason* values themselves — the cancellation flow is +//! implemented by the operation executor. + +use core::fmt; + +/// Reason why a VM-owned operation was cancelled. +/// +/// Values are intentionally small and stable — they are persisted as +/// raw bytes in some contexts, so reordering or renumbering is a breaking +/// change. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum OperationCancelReason { + /// The operation was explicitly requested by the caller. + Requested = 1, + /// The operation exceeded its deadline. + Deadline = 2, + /// The VM was reset while the operation was still pending. + VmReset = 3, + /// The parent operation was cancelled/closed first. + Parent = 4, + /// A resource the operation depended on was closed. + ResourceClosed = 5, + /// The `Vm` itself was dropped while the operation was pending. + VmDrop = 6, +} + +impl OperationCancelReason { + /// Raw byte value of this reason. + #[inline] + pub const fn raw(self) -> u8 { + self as u8 + } + + /// Decode from a raw byte. + /// + /// Returns `None` for invalid / reserved values (0 and 255 are + /// explicitly rejected; other unknown values are also rejected). + pub const fn from_raw(value: u8) -> Option { + match value { + 1 => Some(Self::Requested), + 2 => Some(Self::Deadline), + 3 => Some(Self::VmReset), + 4 => Some(Self::Parent), + 5 => Some(Self::ResourceClosed), + 6 => Some(Self::VmDrop), + _ => None, + } + } + + /// Stable string form of this reason. + /// + /// The returned string is a `'static` str and matches the + /// variant name in snake_case exactly. + pub const fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::Deadline => "deadline", + Self::VmReset => "vm_reset", + Self::Parent => "parent", + Self::ResourceClosed => "resource_closed", + Self::VmDrop => "vm_drop", + } + } +} + +impl fmt::Display for OperationCancelReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_values_are_stable() { + assert_eq!(OperationCancelReason::Requested.raw(), 1); + assert_eq!(OperationCancelReason::Deadline.raw(), 2); + assert_eq!(OperationCancelReason::VmReset.raw(), 3); + assert_eq!(OperationCancelReason::Parent.raw(), 4); + assert_eq!(OperationCancelReason::ResourceClosed.raw(), 5); + assert_eq!(OperationCancelReason::VmDrop.raw(), 6); + } + + #[test] + fn from_raw_accepts_valid_values() { + for (raw, expected) in [ + (1, OperationCancelReason::Requested), + (2, OperationCancelReason::Deadline), + (3, OperationCancelReason::VmReset), + (4, OperationCancelReason::Parent), + (5, OperationCancelReason::ResourceClosed), + (6, OperationCancelReason::VmDrop), + ] { + assert_eq!(OperationCancelReason::from_raw(raw), Some(expected)); + } + } + + #[test] + fn from_raw_rejects_invalid_values() { + assert_eq!(OperationCancelReason::from_raw(0), None); + assert_eq!(OperationCancelReason::from_raw(255), None); + assert_eq!(OperationCancelReason::from_raw(7), None); + } + + #[test] + fn as_str_matches_exact_snake_case() { + assert_eq!(OperationCancelReason::Requested.as_str(), "requested"); + assert_eq!(OperationCancelReason::Deadline.as_str(), "deadline"); + assert_eq!(OperationCancelReason::VmReset.as_str(), "vm_reset"); + assert_eq!(OperationCancelReason::Parent.as_str(), "parent"); + assert_eq!( + OperationCancelReason::ResourceClosed.as_str(), + "resource_closed" + ); + assert_eq!(OperationCancelReason::VmDrop.as_str(), "vm_drop"); + } +} diff --git a/src/vm/operation/registry.rs b/src/vm/operation/registry.rs new file mode 100644 index 00000000..40dc87ae --- /dev/null +++ b/src/vm/operation/registry.rs @@ -0,0 +1,1331 @@ +//! Operation registry: slot lifecycle, bounds, deadline and first-reason +//! cancellation tracking for host-agnostic operations. +//! +//! The registry owns a bounded, reusable generational slot arena. Each +//! occupied slot owns an object-safe [`HostOperation`] driver plus an +//! optional deadline, cleanup and its own status. Packed +//! `tag`/`slot`/`generation` ids are fully validated against the live slot +//! descriptor before any mutation, so a foreign-tagged, stale or +//! out-of-range id is rejected rather than aliased to a newer occupant. +//! +//! Cancellation is first-reason-wins, recorded once, and forwarded only to +//! the owning concrete driver via [`HostOperation::cancel`]. There is no +//! host-domain dispatch, no owner/poller table, and no secondary +//! cancellation channel. + +use std::task::{Context, Poll}; +use std::time::Instant; + +use super::driver::{HostOperation, OperationCleanup, OperationOutcome, OperationSpec}; +use super::error::{OperationError, OperationErrorCode, OperationResult}; +use super::id::{MAX_GENERATION, MAX_SLOT_IDENTITY, OperationId, allocate_registry_tag, encode}; +use super::reason::OperationCancelReason; + +/// Default ceiling for concurrently pending operations. +pub const DEFAULT_MAX_PENDING_OPERATIONS: usize = 64; + +/// Public, observable operation status. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationStatus { + /// Still running. + Pending, + /// Finished successfully. + Completed, + /// Cancelled; carries the first cancellation reason. + Cancelled(OperationCancelReason), + /// Failed with an operation error. + Failed(OperationError), +} + +impl OperationStatus { + /// Whether the operation has reached a terminal (non-pending) state. + pub fn is_terminal(&self) -> bool { + !matches!(self, OperationStatus::Pending) + } + + fn terminal_outcome(&self) -> Option { + match self { + OperationStatus::Pending => None, + OperationStatus::Completed => Some(OperationOutcome::Completed), + OperationStatus::Cancelled(reason) => Some(OperationOutcome::Cancelled(*reason)), + OperationStatus::Failed(error) => Some(OperationOutcome::Failed(error.clone())), + } + } +} + +/// One generational slot in the registry's slot arena. +/// +/// A slot keeps a nonzero generation across reuses; each new occupant of the +/// same slot sees an incremented generation, so an id from a previous occupant +/// becomes stale rather than aliasing a newer operation. +struct OperationSlot { + generation: u64, + operation: Option, +} + +struct Operation { + driver: Box, + deadline: Option, + cleanup: Option, + status: OperationStatus, +} + +/// Reusable, slot-arena registry of in-flight host operations. +/// +/// Capacity limits the number of *pending* operations; an operation that has +/// reached a terminal state no longer counts against capacity, so consuming a +/// terminal result releases registry capacity for new operations. +/// +/// Storage is a [`Vec`] backed by a free list of reusable slot +/// indices. Each operation id packs the registry's process-unique tag, the +/// slot identity, and the slot's generation, so a caller-supplied id that +/// carries another registry's tag, an out-of-range/future slot, or a stale +/// generation is rejected before any status, driver, cleanup or free-list +/// mutation. +/// +/// This type is intentionally `!Sync` (no interior mutability for concurrent +/// access); it is owned and driven by a single thread. +pub struct OperationRegistry { + max_pending: usize, + tag: u64, + sealed: bool, + slots: Vec, + free: Vec, +} + +impl OperationRegistry { + /// Creates an empty registry with the default pending-operation ceiling. + /// + /// Tag allocation is process-unique and fallible; callers must propagate + /// [`OperationErrorCode::OperationRegistryTagExhausted`] rather than rely + /// on an infallible default constructor. + pub fn new() -> OperationResult { + Self::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + } + + /// Creates an empty sealed-less registry with the given pending-operation + /// ceiling, allocating a process-unique registry tag. + pub fn with_limit(max_pending: usize) -> OperationResult { + if max_pending == 0 { + return Err(OperationError::new( + OperationErrorCode::InvalidConfiguration, + "vm::operation", + "operation registry capacity must be positive", + )); + } + let tag = allocate_registry_tag()?; + Ok(Self { + max_pending, + tag, + sealed: false, + slots: Vec::new(), + free: Vec::new(), + }) + } + + /// The configured pending-operation ceiling. + pub fn max_pending(&self) -> usize { + self.max_pending + } + + /// Whether this registry has been [`seal`](Self::seal)ed and therefore + /// rejects new operations. + pub fn is_sealed(&self) -> bool { + self.sealed + } + + /// Seals the registry so no further operations can be started. Idempotent; + /// existing operations remain queryable and droppable. + pub fn seal(&mut self) { + self.sealed = true; + } + + /// Number of operations still pending. + pub fn active_count(&self) -> usize { + self.slots + .iter() + .filter_map(|slot| slot.operation.as_ref()) + .filter(|operation| !operation.status.is_terminal()) + .count() + } + + /// Number of occupied slots (pending and terminal). + pub fn len(&self) -> usize { + self.slots.iter().filter(|s| s.operation.is_some()).count() + } + + /// Whether no operation (pending or terminal) is occupied. + pub fn is_empty(&self) -> bool { + !self.slots.iter().any(|s| s.operation.is_some()) + } + + /// Starts a new operation from a spec, enforcing the seal, the capacity + /// ceiling, generic slot reuse, and packed id allocation. + pub fn start(&mut self, spec: OperationSpec) -> OperationResult { + if self.sealed { + return Err(OperationError::new( + OperationErrorCode::OperationRegistrySealed, + "vm::operation", + "operation registry is sealed and rejects new operations", + )); + } + if self.active_count() >= self.max_pending { + return Err(OperationError::new( + OperationErrorCode::OperationLimitExceeded, + "vm::operation", + "pending operation capacity has been reached", + ) + .with_limit(self.max_pending as u64)); + } + let slot_index = self.acquire_slot()?; + let generation = self.slots[slot_index].generation; + let id = encode(self.tag, slot_index, generation).expect("registry id encodes"); + let operation = Operation { + driver: spec.driver, + deadline: spec.deadline, + cleanup: spec.cleanup, + status: OperationStatus::Pending, + }; + // Install exactly once into the acquired slot. + self.slots[slot_index].operation = Some(operation); + debug_assert!(self.slots[slot_index].generation == generation); + Ok(id) + } + + /// Observes the current status of an operation. + pub fn status(&self, id: OperationId) -> OperationResult { + Ok(self.operation(id)?.status.clone()) + } + + /// Consumes the terminal outcome of an operation, delivering it exactly + /// once and immediately releasing its slot for reuse under an incremented + /// generation. After this call the id is stale. + /// + /// A pending operation returns `OperationPending` without mutating the + /// registry; drive it to terminal with `poll` first. A terminal operation + /// whose driver still owns an underlying worker stays pending until the + /// worker reports quiescence. + pub fn take_outcome(&mut self, id: OperationId) -> OperationResult { + let slot = self.location(id)?; + let operation = self.slots[slot] + .operation + .as_ref() + .ok_or_else(|| operation_stale(id))?; + if !operation.driver.is_quiescent() { + return Err(pending_outcome(id)); + } + let status = operation.status.clone(); + let outcome = status + .terminal_outcome() + .ok_or_else(|| pending_outcome(id))?; + self.release_slot(slot); + Ok(outcome) + } + + /// Drives the operation one step. + /// + /// Polls the owning driver first; a `Ready` driver result wins even if a + /// deadline has already elapsed. Only a pending driver result falls + /// through to the deadline check, in which case an elapsed deadline + /// cancels the operation with `OperationCancelReason::Deadline`. + /// + /// The terminal outcome is delivered exactly once: when this returns + /// `Poll::Ready`, the operation's slot is released and the id becomes + /// stale. A cancelled terminal whose driver still owns a worker remains + /// pending until that worker reports quiescence. + pub fn poll( + &mut self, + id: OperationId, + cx: &mut Context<'_>, + ) -> Poll> { + // Validate fully before any mutation. + let slot = match self.location(id) { + Ok(slot) => slot, + Err(error) => return Poll::Ready(Err(error)), + }; + + // An out-of-band terminal (complete/fail/cancel) is consumed one-shot, + // but only after the driver's underlying work is quiescent. + if self.slots[slot] + .operation + .as_ref() + .is_some_and(|operation| operation.status.is_terminal()) + { + return self.poll_terminal(slot, cx); + } + + // Drive the real driver first; a Ready result wins even if a deadline + // has already elapsed. + let driver_result = { + let operation = self.slots[slot].operation.as_mut().expect("slot occupied"); + operation.driver.poll(cx) + }; + match driver_result { + Poll::Pending => { + // Only a pending driver result falls through to the deadline. + let deadline_elapsed = self.slots[slot] + .operation + .as_ref() + .and_then(|operation| operation.deadline) + .is_some_and(|deadline| Instant::now() >= deadline); + if !deadline_elapsed { + return Poll::Pending; + } + // An elapsed deadline cancels; the resulting terminal state is + // then consumed one-shot. + let _ = self.cancel(id, OperationCancelReason::Deadline); + let slot = match self.location(id) { + Ok(slot) => slot, + Err(error) => return Poll::Ready(Err(error)), + }; + let operation = self.slots[slot] + .operation + .as_mut() + .expect("cancelled deadline operation remains occupied"); + if !operation.driver.is_quiescent() { + operation.driver.register_quiescence_waker(cx); + return Poll::Pending; + } + Poll::Ready(Ok(self.consume_terminal(slot))) + } + Poll::Ready(Ok(())) => { + // Success beats an elapsed deadline. + let _ = self.finish_terminal( + id, + OperationStatus::Completed, + OperationOutcome::Completed, + ); + let slot = match self.location(id) { + Ok(slot) => slot, + Err(error) => return Poll::Ready(Err(error)), + }; + self.poll_terminal(slot, cx) + } + Poll::Ready(Err(error)) => { + // A driver failure beats an elapsed deadline. + let _ = self.finish_terminal( + id, + OperationStatus::Failed(error.clone()), + OperationOutcome::Failed(error), + ); + let slot = match self.location(id) { + Ok(slot) => slot, + Err(error) => return Poll::Ready(Err(error)), + }; + self.poll_terminal(slot, cx) + } + } + } + + /// Cancels one operation, forwarding the reason to its driver. + /// + /// The id is validated before any mutation, and the driver's + /// [`HostOperation::cancel`] is invoked while the operation is still + /// `Pending`. On success the operation finishes as `Cancelled` through the + /// central cleanup helper. An already-terminal operation returns + /// `Ok(false)` and preserves its first recorded reason; the driver is not + /// invoked again. + /// + /// A driver cancel failure is wrapped as `OperationDriverFailed`: the + /// terminal status becomes `Failed(first)`, the cleanup runs once with + /// that `Failed` outcome, and the driver error is returned (preserved as + /// the first error even if cleanup also fails). No false `Cancelled` state + /// is produced. + pub fn cancel( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> OperationResult { + self.cancel_with_wait(id, reason, false) + } + + fn cancel_with_wait( + &mut self, + id: OperationId, + reason: OperationCancelReason, + wait_for_worker: bool, + ) -> OperationResult { + let slot = self.location(id)?; + let pending = self.slots[slot] + .operation + .as_ref() + .is_some_and(|operation| matches!(operation.status, OperationStatus::Pending)); + if !pending { + return Ok(false); + } + + // Call the driver while still pending, before recording any status. + let driver_result = { + let operation = self.slots[slot].operation.as_mut().expect("pending above"); + if wait_for_worker { + operation.driver.cancel_and_wait(reason) + } else { + operation.driver.cancel(reason) + } + }; + match driver_result { + Ok(()) => { + // Finish as Cancelled through the central cleanup helper. + self.finish_terminal( + id, + OperationStatus::Cancelled(reason), + OperationOutcome::Cancelled(reason), + ) + .map(|_| true) + } + Err(error) => { + // The driver failed to cancel: record Failed(first) and run the + // cleanup once with that outcome. The driver error stays first + // even if cleanup also fails. + let first = driver_failure(error); + let cleanup = { + let operation = self.slots[slot].operation.as_mut().expect("pending above"); + operation.status = OperationStatus::Failed(first.clone()); + operation.cleanup.take() + }; + if let Some(cleanup) = cleanup { + let _ = cleanup(&OperationOutcome::Failed(first.clone())); + } + Err(first) + } + } + } + + /// Aborts a started operation that must never produce a guest-visible + /// result: cancels the driver exactly once if it is still pending, then + /// consumes/immediately releases the slot so the id becomes stale and + /// full registry capacity is restored (the same "cancel then consume" + /// sequence the batch drain helpers use). + /// + /// This is the rollback counterpart to [`start`](Self::start), for call + /// sites that register an operation and then hit a fallible handoff + /// before installing the pending-result adapter. + /// + /// - **Pending** — the driver is cancelled exactly once with `reason` + /// (first-reason-wins), the resulting terminal outcome is consumed and + /// the slot released, and `Ok(true)` is returned. If the driver's + /// `cancel` itself fails, that failure is recorded as the first + /// `Failed` status, the cleanup runs once, the slot is still released, + /// and the driver error is returned — the slot is never left occupied + /// regardless of the cancel outcome. + /// - **Already terminal** — the terminal outcome is consumed, the slot + /// released, and `Ok(false)` returned (the driver is not invoked again). + /// - **Stale / foreign / out-of-range** — rejected with the usual typed + /// error and **no** registry mutation. + /// + /// After a successful abort the id is stale under an incremented slot + /// generation, so a later `poll`, `status`, `take_outcome`, `remove` or + /// second `abort` on it all report `OperationStale`. + pub fn abort( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> OperationResult { + // Validate fully before any mutation; an unresolvable id is rejected + // without touching cancel/consume state. + let _slot = self.location(id)?; + let cancel_result = self.cancel_with_wait(id, reason, true); + // Whether the driver cancelled cleanly, the driver's cancel failed + // (the entry is now terminal `Failed`), or the entry was already + // terminal before this call, consuming the outcome releases the slot + // and makes the id stale exactly once. Preserve the first transition + // error, while still surfacing an outcome-consumption error when the + // cancellation itself succeeded. + let take_result = self.take_outcome(id); + match (cancel_result, take_result) { + (Err(error), _) | (Ok(_), Err(error)) => Err(error), + (Ok(cancelled), Ok(_)) => Ok(cancelled), + } + } + + /// Cancels every pending operation and records the outcome in a + /// [`OperationCancelSummary`]. This is intentionally *cancel-only*: it + /// records the first cancellation reason on each still-pending driver + /// (and marks a failing driver's cancellation `Failed`), but it does **not** + /// release any slot. A cancellation-aware worker may keep its terminal slot + /// until a later [`poll_quiescence`](Self::poll_quiescence) call drives the + /// driver to a terminal, quiescent state — the scope close driver relies on + /// that to avoid claiming quiescence while a detached worker still owns + /// resources. + pub fn cancel_all(&mut self, reason: OperationCancelReason) -> OperationCancelSummary { + let mut summary = OperationCancelSummary::default(); + for id in self.occupied_ids() { + let is_pending = self + .location(id) + .ok() + .and_then(|slot| self.slots[slot].operation.as_ref()) + .is_some_and(|operation| matches!(operation.status, OperationStatus::Pending)); + if !is_pending { + // A pre-existing terminal operation is not matched; it is + // drained later by `poll_quiescence`. + continue; + } + let result = self.cancel(id, reason); + summary.record(result); + } + summary + } + + /// Polls cancellation-owned workers without blocking the VM thread. A + /// terminal operation is released only after its driver reports actual + /// quiescence. The driver owns the completion signal and wakes the scope + /// through `register_quiescence_waker` when the transition occurs. + pub fn poll_quiescence(&mut self, cx: &mut Context<'_>) -> bool { + for id in self.occupied_ids() { + let Ok(slot) = self.location(id) else { + continue; + }; + let Some(operation) = self.slots[slot].operation.as_mut() else { + continue; + }; + if !operation.status.is_terminal() { + continue; + } + if operation.driver.is_quiescent() { + let _ = self.consume_terminal(slot); + } else { + operation.driver.register_quiescence_waker(cx); + } + } + self.is_empty() + } + + /// Marks an operation completed out-of-band (e.g. a host future resolved + /// without a poll). The result stays terminal until + /// [`take_outcome`](Self::take_outcome) or [`remove`](Self::remove) is + /// called. Returns `Ok(false)` if already terminal; a cleanup failure + /// returns `Err` while the status becomes `Failed`. + pub fn complete(&mut self, id: OperationId) -> OperationResult { + self.finish_terminal(id, OperationStatus::Completed, OperationOutcome::Completed) + } + + /// Marks an operation failed out-of-band. The result stays terminal until + /// [`take_outcome`](Self::take_outcome) or [`remove`](Self::remove) is + /// called. Returns `Ok(false)` if already terminal; a cleanup failure + /// returns `Err` while the status becomes `Failed`. + pub fn fail(&mut self, id: OperationId, error: OperationError) -> OperationResult { + self.finish_terminal( + id, + OperationStatus::Failed(error.clone()), + OperationOutcome::Failed(error), + ) + } + + /// Removes a single operation, returning its status and releasing its slot + /// for reuse. + /// + /// This is an explicit *terminal-state* discard: only an already-terminal + /// operation is removed and its slot released. A still-`Pending` + /// operation returns `OperationPending` and is left completely untouched — + /// its driver is not cancelled, no cleanup runs, and its slot generation + /// and free-list membership are unchanged. Drive a task with + /// [`poll`](Self::poll) (or [`cancel`](Self::cancel)) to reach a terminal + /// state before removing it. + pub fn remove(&mut self, id: OperationId) -> OperationResult { + let index = self.location(id)?; + let terminal = self.slots[index] + .operation + .as_ref() + .is_some_and(|operation| { + operation.status.is_terminal() && operation.driver.is_quiescent() + }); + if !terminal { + return Err(pending_outcome(id)); + } + let status = { + let slot = &mut self.slots[index]; + match slot.operation.take() { + Some(operation) => operation.status, + None => return Err(operation_stale(id)), + } + }; + self.release_slot(index); + Ok(status) + } + + /// Installs a requested terminal status and runs the (once) cleanup hook. + /// No-op (returns `Ok(false)`) when the operation is already terminal. + /// + /// A cleanup failure is wrapped as `OperationCleanupFailed`, replaces the + /// terminal status with `Failed(wrapped)`, leaves the operation terminal, + /// and returns the wrapped error. + fn finish_terminal( + &mut self, + id: OperationId, + status: OperationStatus, + outcome: OperationOutcome, + ) -> OperationResult { + let slot = self.location(id)?; + let cleanup = { + let operation = match self.slots[slot].operation.as_mut() { + Some(operation) => operation, + None => return Ok(false), + }; + if operation.status.is_terminal() { + return Ok(false); + } + operation.status = status; + operation.cleanup.take() + }; + if let Some(cleanup) = cleanup { + self.run_cleanup(slot, cleanup, outcome)?; + } + Ok(true) + } + + /// Runs an already-taken cleanup exactly once with the terminal outcome. + /// A failure wraps the error as `OperationCleanupFailed`, overrides the + /// operation's status to `Failed(wrapped)`, and returns the wrapped error. + fn run_cleanup( + &mut self, + slot: usize, + cleanup: OperationCleanup, + outcome: OperationOutcome, + ) -> OperationResult<()> { + match cleanup(&outcome) { + Ok(()) => Ok(()), + Err(error) => { + let wrapped = OperationError::new( + OperationErrorCode::OperationCleanupFailed, + "vm::operation", + error.to_string(), + ); + if let Some(operation) = self.slots[slot].operation.as_mut() { + operation.status = OperationStatus::Failed(wrapped.clone()); + } + Err(wrapped) + } + } + } + + fn poll_terminal( + &mut self, + slot: usize, + cx: &mut Context<'_>, + ) -> Poll> { + let quiescent = { + let operation = self.slots[slot] + .operation + .as_mut() + .expect("terminal slot remains occupied"); + if operation.driver.is_quiescent() { + true + } else { + operation.driver.register_quiescence_waker(cx); + false + } + }; + if quiescent { + Poll::Ready(Ok(self.consume_terminal(slot))) + } else { + Poll::Pending + } + } + + /// Reads and releases a terminal slot in one step, delivering its outcome. + /// Caller must have validated an occupied terminal slot. + fn consume_terminal(&mut self, slot: usize) -> OperationOutcome { + let status = self.slots[slot] + .operation + .as_ref() + .expect("terminal slot remains occupied") + .status + .clone(); + let outcome = status + .terminal_outcome() + .expect("terminal status has an outcome"); + self.release_slot(slot); + outcome + } + + /// Ids of every occupied slot (pending and terminal), in ascending slot + /// order. Used by [`cancel_all`](Self::cancel_all) to snapshot all + /// occupants before draining. + fn occupied_ids(&self) -> Vec { + self.slots + .iter() + .enumerate() + .filter_map(|(index, slot)| { + slot.operation + .as_ref() + .map(|_| self.id_at(index, slot.generation)) + }) + .collect() + } + + /// Resolves a caller-supplied id to a slot index, validating it fully + /// against this registry before any status/driver/cleanup/free-list + /// mutation is allowed to proceed. + fn location(&self, id: OperationId) -> OperationResult { + if id.registry_tag() != self.tag { + return Err(operation_wrong_registry(id)); + } + let slot_index = id.slot_index(); + if slot_index >= self.slots.len() { + return Err(operation_not_found(id)); + } + let slot = &self.slots[slot_index]; + if id.generation() > slot.generation { + // A future generation means the occupant does not exist yet. + return Err(operation_not_found(id)); + } + if id.generation() < slot.generation || slot.operation.is_none() { + // Older generation or vacant (released) slot: the operation moved on. + return Err(operation_stale(id)); + } + Ok(slot_index) + } + + fn operation(&self, id: OperationId) -> OperationResult<&Operation> { + let slot = self.location(id)?; + self.slots[slot] + .operation + .as_ref() + .ok_or_else(|| operation_stale(id)) + } + + /// Reconstructs the packed id for an occupied slot at its current + /// generation. + fn id_at(&self, slot_index: usize, generation: u64) -> OperationId { + encode(self.tag, slot_index, generation).expect("occupied slot encodes a registry id") + } + + /// Acquires a reusable slot for a new operation: pops an index from the + /// free list, or grows the arena by one new slot up to `MAX_SLOT_IDENTITY`. + fn acquire_slot(&mut self) -> OperationResult { + if let Some(index) = self.free.pop() { + return Ok(index); + } + if self.slots.len() >= MAX_SLOT_IDENTITY as usize { + return Err(OperationError::new( + OperationErrorCode::OperationIdExhausted, + "vm::operation", + "operation slot identity space exhausted", + )); + } + self.slots.push(OperationSlot { + generation: 1, + operation: None, + }); + Ok(self.slots.len() - 1) + } + + /// Releases an occupied slot: drops the occupant, increments the + /// generation, and recycles the slot for reuse — unless the generation is + /// at `MAX_GENERATION`, in which case the slot retires permanently. + fn release_slot(&mut self, index: usize) { + let slot = &mut self.slots[index]; + slot.operation = None; + if slot.generation < MAX_GENERATION { + slot.generation += 1; + self.free.push(index); + } + } +} + +impl Drop for OperationRegistry { + fn drop(&mut self) { + // Best-effort teardown: cancel pending operations so the owning + // drivers can release resources. The summary is intentionally ignored; + // counting failures is irrelevant while the registry is being dropped. + let _ = self.cancel_all(OperationCancelReason::VmReset); + } +} + +/// Aggregate result of cancelling a batch of operations. +/// +/// Each attempted *pending* operation counts toward `matched`; only an +/// operation that actually reaches `Cancelled` counts toward `cancelled`; +/// a driver or cleanup failure counts toward `failed` with the first error +/// stored. A failure never increases `cancelled`, so there is no false +/// success in a batch. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OperationCancelSummary { + matched: usize, + cancelled: usize, + failed: usize, + first_error: Option, +} + +impl OperationCancelSummary { + /// Number of pending operations the batch attempted to cancel. + pub fn matched(&self) -> usize { + self.matched + } + + /// Number of operations that successfully reached `Cancelled`. + pub fn cancelled(&self) -> usize { + self.cancelled + } + + /// Number of operations where cancellation (driver) or cleanup failed. + pub fn failed(&self) -> usize { + self.failed + } + + /// The first driver or cleanup error encountered, if any. + pub fn first_error(&self) -> Option<&OperationError> { + self.first_error.as_ref() + } + + /// Records the outcome of one attempted cancellation. + fn record(&mut self, result: OperationResult) { + self.matched += 1; + match result { + Ok(true) => self.cancelled += 1, + Ok(false) => { + // An attempted pending operation did not transition; it is + // neither cancelled nor counted as a driver/cleanup failure. + } + Err(error) => { + self.failed += 1; + if self.first_error.is_none() { + self.first_error = Some(error); + } + } + } + } +} + +fn operation_not_found(id: OperationId) -> OperationError { + OperationError::new( + OperationErrorCode::OperationNotFound, + "vm::operation", + format!("operation {} is not registered", id.raw()), + ) + .with_value(id.raw()) +} + +fn operation_wrong_registry(id: OperationId) -> OperationError { + OperationError::new( + OperationErrorCode::OperationWrongRegistry, + "vm::operation", + format!("operation {} belongs to a different registry", id.raw()), + ) + .with_value(id.raw()) +} + +fn operation_stale(id: OperationId) -> OperationError { + OperationError::new( + OperationErrorCode::OperationStale, + "vm::operation", + format!("operation {} refers to a stale slot generation", id.raw()), + ) + .with_value(id.raw()) +} + +fn pending_outcome(id: OperationId) -> OperationError { + OperationError::new( + OperationErrorCode::OperationPending, + "vm::operation", + format!( + "operation {} is still pending and has no terminal outcome", + id.raw() + ), + ) + .with_value(id.raw()) +} + +/// Wraps a driver cancel failure into the `OperationDriverFailed` category so +/// a failed driver action never produces a false success or a false +/// `Cancelled` state. +fn driver_failure(error: OperationError) -> OperationError { + OperationError::new( + OperationErrorCode::OperationDriverFailed, + "vm::operation", + error.to_string(), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll, Waker}; + use std::time::{Duration, Instant}; + + use super::{OperationRegistry, OperationStatus}; + use crate::vm::operation::driver::{HostOperation, OperationOutcome, OperationSpec}; + use crate::vm::operation::error::{OperationError, OperationErrorCode, OperationResult}; + use crate::vm::operation::id::{MAX_REGISTRY_TAG, encode}; + use crate::vm::operation::reason::OperationCancelReason; + + #[test] + fn default_capacity_registry_reports_tag_exhaustion_without_panicking() { + static COUNTER: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(MAX_REGISTRY_TAG + 1); + let _source = + crate::vm::operation::id::test_seam::ScopedRegistryTagSource::install(&COUNTER); + + let error = match OperationRegistry::new() { + Ok(_) => panic!("tag exhaustion must be fallible"), + Err(error) => error, + }; + assert_eq!( + error.code(), + OperationErrorCode::OperationRegistryTagExhausted + ); + assert_eq!(error.limit(), Some(MAX_REGISTRY_TAG)); + assert_eq!( + COUNTER.load(Ordering::SeqCst), + MAX_REGISTRY_TAG + 1, + "failed construction must not advance the exhausted source" + ); + } + + struct TestWake(Arc); + impl std::task::Wake for TestWake { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn test_waker() -> (Waker, Arc) { + let wakes = Arc::new(AtomicUsize::new(0)); + let waker = Waker::from(Arc::new(TestWake(Arc::clone(&wakes)))); + (waker, wakes) + } + + /// Driver that completes immediately. + struct RecordingDriver { + polls: Arc, + cancels: Arc>>, + completes: bool, + } + + impl RecordingDriver { + fn completed() -> Self { + Self { + polls: Arc::new(AtomicUsize::new(0)), + cancels: Arc::new(Mutex::new(Vec::new())), + completes: true, + } + } + + fn pending() -> Self { + Self { + polls: Arc::new(AtomicUsize::new(0)), + cancels: Arc::new(Mutex::new(Vec::new())), + completes: false, + } + } + } + + impl HostOperation for RecordingDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + if self.completes { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancels.lock().unwrap().push(reason); + Ok(()) + } + + fn is_quiescent(&self) -> bool { + self.completes + } + } + + /// Driver that stays pending until a shared gate releases it, recording + /// every cancellation reason. + struct PendingDriver { + release: Arc>, + cancels: Arc>>, + } + + impl HostOperation for PendingDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + if *self.release.lock().unwrap() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancels.lock().unwrap().push(reason); + Ok(()) + } + + fn cancel_and_wait(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancel(reason)?; + *self.release.lock().unwrap() = true; + Ok(()) + } + + fn is_quiescent(&self) -> bool { + *self.release.lock().unwrap() + } + } + + /// Driver whose cancel fails with a typed error. + struct CancelFailDriver; + + impl HostOperation for CancelFailDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "test", + "driver refused to cancel", + )) + } + + fn is_quiescent(&self) -> bool { + true + } + } + + #[test] + fn start_assigns_distinct_ids_and_capacity_is_bounded() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let a = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("first start"); + let b = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("second start"); + assert_ne!(a, b, "ids must be distinct"); + + let error = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect_err("capacity reached"); + assert_eq!(error.code(), OperationErrorCode::OperationLimitExceeded); + assert_eq!(error.limit(), Some(2)); + } + + #[test] + fn complete_then_take_releases_slot_for_reuse_with_higher_generation() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let first = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("start"); + assert!(registry.complete(first).expect("complete")); + assert_eq!( + registry.take_outcome(first).expect("outcome"), + OperationOutcome::Completed + ); + assert_eq!( + registry.status(first).expect_err("stale").code(), + OperationErrorCode::OperationStale + ); + + // The slot is reused under an incremented generation. + let second = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("reuse"); + assert_ne!(first, second, "reuse must mint a fresh id"); + assert!(registry.complete(second).expect("complete second")); + assert_eq!( + registry.take_outcome(second).expect("second outcome"), + OperationOutcome::Completed + ); + } + + #[test] + fn take_outcome_on_pending_is_a_noop() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(RecordingDriver::pending())) + .expect("start"); + let error = registry + .take_outcome(id) + .expect_err("pending has no outcome"); + assert_eq!(error.code(), OperationErrorCode::OperationPending); + assert_eq!(registry.active_count(), 1); + assert_eq!(registry.len(), 1); + } + + #[test] + fn poll_drives_pending_to_completed_and_releases_slot() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("start"); + assert_eq!(registry.active_count(), 1); + + let (waker, _) = test_waker(); + let mut cx = Context::from_waker(&waker); + assert_eq!( + registry.poll(id, &mut cx), + Poll::Ready(Ok(OperationOutcome::Completed)) + ); + assert_eq!(registry.active_count(), 0); + assert_eq!(registry.len(), 0); + assert_eq!( + registry.status(id).expect_err("stale").code(), + OperationErrorCode::OperationStale + ); + } + + #[test] + fn cancel_is_typed_and_first_reason_wins() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let cancels = Arc::new(Mutex::new(Vec::new())); + let id = registry + .start(OperationSpec::new(PendingDriver { + release: Arc::new(Mutex::new(false)), + cancels: Arc::clone(&cancels), + })) + .expect("start"); + + assert!( + registry + .cancel(id, OperationCancelReason::Requested) + .expect("first cancel") + ); + assert_eq!( + cancels.lock().unwrap()[..], + [OperationCancelReason::Requested] + ); + // Second cancel is a no-op and preserves the first reason. + assert!( + !registry + .cancel(id, OperationCancelReason::Deadline) + .expect("terminal cancel is a no-op") + ); + assert_eq!(cancels.lock().unwrap().len(), 1); + assert_eq!( + registry.status(id).expect("status"), + OperationStatus::Cancelled(OperationCancelReason::Requested) + ); + } + + #[test] + fn cancel_all_mixed_summary_counts_and_first_error_is_deterministic() { + let mut registry = OperationRegistry::with_limit(8).expect("registry"); + let cancels = Arc::new(Mutex::new(Vec::new())); + let _clean = registry + .start(OperationSpec::new(PendingDriver { + release: Arc::new(Mutex::new(false)), + cancels: Arc::clone(&cancels), + })) + .expect("clean pending"); + let _failing = registry + .start(OperationSpec::new(CancelFailDriver)) + .expect("failing cancel"); + let _terminal = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("terminal"); + registry.complete(_terminal).expect("complete terminal"); + + let summary = registry.cancel_all(OperationCancelReason::VmReset); + assert_eq!(summary.matched(), 2, "only pending ops are matched"); + assert_eq!(summary.cancelled(), 1, "one clean cancellation"); + assert_eq!(summary.failed(), 1, "one failing cancellation"); + let first = summary.first_error().expect("first error"); + assert_eq!(first.code(), OperationErrorCode::OperationDriverFailed); + // Cancel-only: terminal slots stay occupied until quiescence drains + // the drivers. + assert_eq!(registry.len(), 3, "all slots remain occupied after cancel"); + + // Quiescence drains the pre-existing terminal and the cancellation + // failure (whose driver has no worker); the still-running worker keeps + // its slot. + let (waker, _) = test_waker(); + let mut cx = Context::from_waker(&waker); + registry.poll_quiescence(&mut cx); + assert_eq!(registry.len(), 1, "only the running worker remains"); + } + + #[test] + fn cancel_all_forwards_the_same_reason_to_every_driver() { + let mut registry = OperationRegistry::with_limit(8).expect("registry"); + let cancels = Arc::new(Mutex::new(Vec::new())); + for _ in 0..3 { + registry + .start(OperationSpec::new(PendingDriver { + release: Arc::new(Mutex::new(false)), + cancels: Arc::clone(&cancels), + })) + .expect("start"); + } + let summary = registry.cancel_all(OperationCancelReason::Deadline); + assert_eq!(summary.cancelled(), 3); + let recorded = cancels.lock().unwrap(); + assert_eq!(recorded.len(), 3); + assert!( + recorded + .iter() + .all(|reason| *reason == OperationCancelReason::Deadline) + ); + } + + #[test] + fn abort_cancels_driver_once_releases_slot_and_frees_capacity() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let cancels = Arc::new(Mutex::new(Vec::new())); + let id = registry + .start(OperationSpec::new(PendingDriver { + release: Arc::new(Mutex::new(false)), + cancels: Arc::clone(&cancels), + })) + .expect("start"); + assert!( + registry + .abort(id, OperationCancelReason::VmReset) + .expect("abort") + ); + assert_eq!( + cancels.lock().unwrap()[..], + [OperationCancelReason::VmReset] + ); + assert_eq!(registry.len(), 0); + assert_eq!( + registry.status(id).expect_err("stale").code(), + OperationErrorCode::OperationStale + ); + // Capacity restored. + registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("capacity restored"); + } + + #[test] + fn abort_releases_slot_even_when_driver_cancel_fails() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(CancelFailDriver)) + .expect("start"); + let error = registry + .abort(id, OperationCancelReason::VmReset) + .expect_err("driver cancel failure surfaces"); + assert_eq!(error.code(), OperationErrorCode::OperationDriverFailed); + assert_eq!(registry.len(), 0, "slot is still released"); + // Capacity restored even though cancellation failed. + registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("capacity restored"); + } + + #[test] + fn abort_on_already_terminal_removes_without_cancelling_again() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("start"); + assert!(registry.complete(id).expect("complete")); + assert!( + !registry + .abort(id, OperationCancelReason::VmReset) + .expect("terminal abort returns false") + ); + assert_eq!(registry.len(), 0); + } + + #[test] + fn abort_on_stale_id_is_rejected_without_mutation() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("start"); + let foreign = encode(MAX_REGISTRY_TAG, 0, 1).expect("foreign id"); + let error = registry + .abort(foreign, OperationCancelReason::VmReset) + .expect_err("foreign id rejected"); + assert_eq!(error.code(), OperationErrorCode::OperationWrongRegistry); + // The real operation is untouched. + assert_eq!(registry.len(), 1); + assert_eq!( + registry.status(id).expect("status"), + OperationStatus::Pending + ); + } + + #[test] + fn deadline_cancels_pending_operation_with_deadline_reason() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let release = Arc::new(Mutex::new(false)); + let id = registry + .start( + OperationSpec::new(PendingDriver { + release: Arc::clone(&release), + cancels: Arc::new(Mutex::new(Vec::new())), + }) + .with_deadline(Instant::now() - Duration::from_millis(1)), + ) + .expect("start with elapsed deadline"); + + let (waker, _) = test_waker(); + let mut cx = Context::from_waker(&waker); + assert!(matches!(registry.poll(id, &mut cx), Poll::Pending)); + *release.lock().unwrap() = true; + match registry.poll(id, &mut cx) { + Poll::Ready(Ok(OperationOutcome::Cancelled(OperationCancelReason::Deadline))) => {} + other => panic!("expected deadline cancellation, got {other:?}"), + } + assert_eq!(registry.len(), 0); + } + + #[test] + fn cleanup_runs_exactly_once_on_terminal_transition() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let cleanups = Arc::new(AtomicUsize::new(0)); + let cleanups_for_hook = Arc::clone(&cleanups); + let id = registry + .start( + OperationSpec::new(RecordingDriver::pending()).with_cleanup(Box::new(move |_| { + cleanups_for_hook.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("start with cleanup"); + + assert!( + registry + .cancel(id, OperationCancelReason::Requested) + .expect("cancel") + ); + assert_eq!(cleanups.load(Ordering::SeqCst), 1, "cleanup ran once"); + // A second terminal transition is suppressed. + assert!(!registry.complete(id).expect("second terminal is a no-op")); + assert_eq!(cleanups.load(Ordering::SeqCst), 1); + } + + #[test] + fn remove_rejects_pending_and_removes_terminal() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let pending = registry + .start(OperationSpec::new(RecordingDriver::pending())) + .expect("pending"); + let error = registry.remove(pending).expect_err("pending not removable"); + assert_eq!(error.code(), OperationErrorCode::OperationPending); + + let terminal = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect("terminal"); + registry.complete(terminal).expect("complete"); + assert_eq!( + registry.remove(terminal).expect("remove terminal"), + OperationStatus::Completed + ); + assert_eq!(registry.len(), 1, "pending slot remains"); + } + + #[test] + fn sealed_registry_rejects_new_starts() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let id = registry + .start(OperationSpec::new(RecordingDriver::pending())) + .expect("start before seal"); + registry.seal(); + assert!(registry.is_sealed()); + let error = registry + .start(OperationSpec::new(RecordingDriver::completed())) + .expect_err("sealed rejects start"); + assert_eq!(error.code(), OperationErrorCode::OperationRegistrySealed); + // Existing operations remain queryable. + assert_eq!( + registry.status(id).expect("status"), + OperationStatus::Pending + ); + } +} diff --git a/src/vm/resource/close.rs b/src/vm/resource/close.rs new file mode 100644 index 00000000..98ff87d7 --- /dev/null +++ b/src/vm/resource/close.rs @@ -0,0 +1,54 @@ +//! Poll-based close contract for host resources. +//! +//! Concrete resource types implement [`HostResource`] to own their cancellation +//! and teardown. The core table never dispatches on a concrete class; it only +//! records opaque cleanup errors and drives the two-phase close below. + +use std::any::Any; +use std::task::{Context, Poll}; + +use super::error::ResourceResult; +use super::reason::ResourceCloseReason; + +/// Outcome of synchronously beginning a close. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseProgress { + /// The resource finished closing synchronously; no further polling needed. + Ready, + /// The resource is now closing asynchronously; call [`poll_close`](HostResource::poll_close). + Pending, +} + +/// Object-safe resource owned (erased) by a [`ResourceTable`](super::table::ResourceTable). +/// +/// Concrete resources are never enumerated by the core. They implement this +/// trait and the core invokes the begin/poll close state machine generically. +/// +/// Contract: +/// - [`begin_close`](HostResource::begin_close) must be idempotent and must +/// synchronously issue any cancel/close request. +/// - [`poll_close`](HostResource::poll_close) is called only after +/// `begin_close` returns [`CloseProgress::Pending`]. +/// - A concrete `Drop` remains the last-resort guard, but the VM may only reuse +/// a resource and its slot once `poll_close` completes. +/// +/// The `Any` supertrait lets the table reconnect each erased value to its +/// concrete `TypeId` without ever naming a concrete class. +pub trait HostResource: Any + Send + 'static { + /// Begins closing the resource, emitting a synchronous cancel/close request. + /// + /// The default is a synchronous no-op close. + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + let _ = reason; + Ok(CloseProgress::Ready) + } + + /// Polls an in-progress close to completion. + /// + /// Only invoked after `begin_close` returned [`CloseProgress::Pending`]. + /// The default completes synchronously. An `Err` is a cleanup failure + /// recorded by the table as a generic close error. + fn poll_close(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} diff --git a/src/vm/resource/error.rs b/src/vm/resource/error.rs new file mode 100644 index 00000000..2cc73191 --- /dev/null +++ b/src/vm/resource/error.rs @@ -0,0 +1,174 @@ +//! Host-agnostic, typed resource errors. +//! +//! Carries a stable machine-readable category, the operation name, and an +//! optional limit/value payload. The raw resource handle can be stored in +//! [`ResourceError::value`] when a particular handle is implicated in a +//! failure. +//! +//! This module stays in the resource domain on purpose: no builtin or domain +//! type is referenced here, so it can be reused by the resource table, host +//! resource adapters, and later resource-facing VM layers without pulling in +//! the core crate's builtin registry. + +use std::fmt; + +/// Result type used by the generic resource modules. +pub type ResourceResult = Result; + +/// Stable, machine-readable categories for resource capability failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ResourceErrorCode { + /// The resource configuration was invalid (e.g. a zero or oversized + /// capacity). + InvalidConfiguration, + /// The configured resource capacity for the scope was reached. + ResourceLimitExceeded, + /// A raw handle token did not parse into a valid resource handle. + InvalidResourceHandle, + /// A handle was valid but belonged to a different table (arena). + ResourceHandleWrongTable, + /// A resource token named a concrete type that did not match the live + /// resource's actual type. + ResourceTypeMismatch, + /// A handle referred to a slot generation that had moved on (stale). + ResourceStale, + /// The resource was already closed or is in the middle of closing. + ResourceAlreadyClosed, + /// The resource identity space (slots, generations, arenas) is exhausted. + ResourceIdExhausted, + /// A resource slot is already borrowed by an active guard. + ResourceAccessConflict, + /// The [`ResourceTable`](crate::vm::resource::table::ResourceTable) + /// process-unique arena identity space is exhausted: no new table can be + /// constructed because the bounded arena id space has been fully handed + /// out. + /// + /// This is the typed, stable discriminator for ResourceTable arena-ID + /// identity exhaustion and is deliberately distinct from + /// [`ResourceIdExhausted`](Self::ResourceIdExhausted), which keeps covering + /// ordinary resource slot/id exhaustion inside an existing table. + ResourceTableArenaExhausted, + /// Best-effort cleanup of a closing resource reported a failure. + ResourceCleanupFailed, + /// `poll_close` was called on a resource that is not in the closing state. + ResourceNotClosing, + /// A close-all sweep is already in progress and a conflicting reason was + /// supplied; the in-flight sweep keeps its original reason. + ResourceCloseInProgress, + /// A best-effort synchronous close-all could not drive every resource to + /// quiescence (at least one remains pending) and so must not claim + /// success. + ResourceClosePending, +} + +impl ResourceErrorCode { + /// Stable string form for machine-readable messages / logs. + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidConfiguration => "invalid_configuration", + Self::ResourceLimitExceeded => "resource_limit_exceeded", + Self::InvalidResourceHandle => "invalid_resource_handle", + Self::ResourceHandleWrongTable => "resource_handle_wrong_table", + Self::ResourceTypeMismatch => "resource_type_mismatch", + Self::ResourceStale => "resource_stale", + Self::ResourceAlreadyClosed => "resource_already_closed", + Self::ResourceIdExhausted => "resource_id_exhausted", + Self::ResourceAccessConflict => "resource_access_conflict", + Self::ResourceTableArenaExhausted => "resource_arena_id_exhausted", + Self::ResourceCleanupFailed => "resource_cleanup_failed", + Self::ResourceNotClosing => "resource_not_closing", + Self::ResourceCloseInProgress => "resource_close_in_progress", + Self::ResourceClosePending => "resource_close_pending", + } + } +} + +/// A structured, human- and machine-readable resource error. +/// +/// `code` is the stable machine category, `operation` is the VM scope name the +/// failure occurred in, and `limit` / `value` are optional numeric payloads +/// (e.g. the capacity reached and the offending handle's raw token). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResourceError { + code: ResourceErrorCode, + operation: &'static str, + message: String, + limit: Option, + value: Option, +} + +impl ResourceError { + /// Builds a resource error without an optional numeric payload. + pub fn new( + code: ResourceErrorCode, + operation: &'static str, + message: impl Into, + ) -> Self { + Self { + code, + operation, + message: message.into(), + limit: None, + value: None, + } + } + + /// The stable machine-readable category. + pub fn code(&self) -> ResourceErrorCode { + self.code + } + + /// The operation scope this error occurred in. + pub fn operation(&self) -> &'static str { + self.operation + } + + /// The human-readable detail message. + pub fn message(&self) -> &str { + &self.message + } + + /// The optional capacity/limit payload, if one was attached. + pub fn limit(&self) -> Option { + self.limit + } + + /// The optional numeric payload, when a value is implicated. + pub fn value(&self) -> Option { + self.value + } + + /// Attaches an optional capacity/limit payload. + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + /// Attaches an optional numeric value payload. + pub fn with_value(mut self, value: u64) -> Self { + self.value = Some(value); + self + } +} + +impl fmt::Display for ResourceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "resource error [{}] in {}: {}", + self.code.as_str(), + self.operation, + self.message + )?; + if let Some(limit) = self.limit { + write!(f, " (limit: {limit})")?; + } + if let Some(value) = self.value { + write!(f, " (value: {value})")?; + } + Ok(()) + } +} + +impl std::error::Error for ResourceError {} diff --git a/src/vm/resource/handle.rs b/src/vm/resource/handle.rs new file mode 100644 index 00000000..cfa88100 --- /dev/null +++ b/src/vm/resource/handle.rs @@ -0,0 +1,342 @@ +//! Typed, host-agnostic resource handles. +//! +//! A [`ResourceHandle`] is an opaque token that encodes exactly three +//! identities, with no domain resource class information: +//! +//! ```text +//! arena / scope identity | slot index | generation +//! ``` +//! +//! The arena identity binds a handle to one [`ResourceTable`](super::table::ResourceTable) +//! (and therefore to the execution scope that owns that table). The slot index +//! locates the entry, and the generation rejects handles that outlive a +//! slot-reuse. Concrete resource type is checked at borrow time with a +//! [`std::any::TypeId`], never by discarding space in the handle. +//! +//! [`Resource`] is a type-marked token that host code keeps while it talks +//! about a particular resource. It is `Copy`, but it is only a capability +//! token: duplicating the token duplicates the name, not ownership of the +//! underlying resource, whose lifetime is governed by the table. + +use std::cell::{Ref, RefMut}; +use std::marker::PhantomData; + +use super::error::{ResourceError, ResourceErrorCode, ResourceResult}; + +/// Default bounded capacity of a resource table. +pub const DEFAULT_MAX_RESOURCES: usize = 1024; + +const HANDLE_GENERATION_BITS: u64 = 25; +const HANDLE_SLOT_BITS: u64 = 18; +const HANDLE_ARENA_BITS: u64 = 63 - HANDLE_GENERATION_BITS - HANDLE_SLOT_BITS; + +const HANDLE_GENERATION_SHIFT: u64 = 0; +const HANDLE_SLOT_SHIFT: u64 = HANDLE_GENERATION_SHIFT + HANDLE_GENERATION_BITS; +const HANDLE_ARENA_SHIFT: u64 = HANDLE_SLOT_SHIFT + HANDLE_SLOT_BITS; + +const HANDLE_GENERATION_MASK: u64 = (1 << HANDLE_GENERATION_BITS) - 1; +const HANDLE_SLOT_MASK: u64 = (1 << HANDLE_SLOT_BITS) - 1; +const HANDLE_ARENA_MASK: u64 = (1 << HANDLE_ARENA_BITS) - 1; + +/// Hard ceiling on resident slots, derived from the handle encoding. +pub(crate) const MAX_RESOURCE_SLOTS: usize = HANDLE_SLOT_MASK as usize; + +/// Largest valid arena identity. +pub(crate) const MAX_HANDLE_ARENA_ID: u64 = HANDLE_ARENA_MASK; + +/// Largest valid slot generation. +pub(crate) const MAX_HANDLE_GENERATION: u64 = HANDLE_GENERATION_MASK; + +/// Raw opaque resource token passed across the host boundary. +/// +/// The token is a positive signed VM integer. Zero and any encoding field +/// being zero are invalid, so the token space never aliases a reserved value. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct ResourceHandle(u64); + +impl ResourceHandle { + /// The raw `u64` encoding. + pub const fn raw(self) -> u64 { + self.0 + } + + /// Rebuilds a handle from the raw encoding, validating that no reserved or + /// truncated component leaked through. + /// + /// Rejects a zero raw value, a set sign bit, a zero arena identity, a zero + /// slot identity, and a zero generation. + pub fn from_raw(raw: u64) -> ResourceResult { + if raw == 0 || raw > i64::MAX as u64 { + return Err(invalid_handle( + "resource handle token must be a positive signed integer", + )); + } + let handle = Self(raw); + if handle.arena_id() == 0 || handle.slot_identity() == 0 || handle.generation() == 0 { + return Err(invalid_handle( + "resource handle token has an invalid encoding", + )); + } + Ok(handle) + } + + /// Process-unique arena / scope identity, never recycled. + pub(crate) const fn arena_id(self) -> u64 { + (self.0 >> HANDLE_ARENA_SHIFT) & HANDLE_ARENA_MASK + } + + /// Generation for the slot, advanced on every reuse. + pub fn generation(self) -> u64 { + (self.0 >> HANDLE_GENERATION_SHIFT) & HANDLE_GENERATION_MASK + } + + /// Zero-based slot index. + pub fn slot_index(self) -> ResourceResult { + usize::try_from(self.slot_identity() - 1) + .map_err(|_| invalid_handle("resource handle slot is out of range")) + } + + const fn slot_identity(self) -> u64 { + (self.0 >> HANDLE_SLOT_SHIFT) & HANDLE_SLOT_MASK + } + + pub(crate) fn encode(arena_id: u64, slot_index: usize, generation: u64) -> Option { + let slot_identity = u64::try_from(slot_index).ok()?.checked_add(1)?; + if arena_id == 0 + || arena_id > HANDLE_ARENA_MASK + || slot_identity == 0 + || slot_identity > HANDLE_SLOT_MASK + || generation == 0 + || generation > HANDLE_GENERATION_MASK + { + return None; + } + Some(Self( + (arena_id << HANDLE_ARENA_SHIFT) + | (slot_identity << HANDLE_SLOT_SHIFT) + | (generation << HANDLE_GENERATION_SHIFT), + )) + } +} + +/// A type-marked capability token over one resource. +/// +/// `Resource` is `Copy` and cheap; it is a key into a table, not an owner. +/// The `PhantomData T>` marker keeps the token covariant and lets it be +/// `Copy`/`Send`/`Sync` *regardless* of whether `T` itself is, while still +/// carrying the concrete type for borrow-time validation. The trait impls are +/// hand-written (instead of derived) precisely so no `T: Copy`/`T: Clone` etc. +/// bound leaks onto the token. +pub struct Resource { + raw: ResourceHandle, + marker: PhantomData T>, +} + +impl Resource { + /// Builds a typed token over a validated raw handle (crate-private). + /// + /// Safe typed recovery from an arbitrary raw handle must go through + /// [`ResourceTable::typed`](super::table::ResourceTable::typed), which + /// validates the arena, slot, generation, open state, and `TypeId` before + /// returning a token. This unchecked constructor is intentionally not part + /// of the public surface so nothing can mint a `Resource` over a random + /// handle or a mismatched `TypeId`. + pub(crate) fn from_handle(raw: ResourceHandle) -> Self { + Self { + raw, + marker: PhantomData, + } + } + + /// The underlying opaque handle. + pub fn handle(&self) -> ResourceHandle { + self.raw + } + + /// Consumes the token and returns the raw handle. + pub const fn into_handle(self) -> ResourceHandle { + self.raw + } +} + +#[allow(clippy::non_canonical_clone_impl)] +impl Clone for Resource { + fn clone(&self) -> Self { + Self { + raw: self.raw, + marker: PhantomData, + } + } +} + +impl Copy for Resource {} + +impl PartialEq for Resource { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} + +impl Eq for Resource {} + +impl PartialOrd for Resource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Resource { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.raw.cmp(&other.raw) + } +} + +impl core::hash::Hash for Resource { + fn hash(&self, state: &mut H) { + self.raw.hash(state); + } +} + +impl core::fmt::Debug for Resource { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_tuple("Resource").field(&self.raw).finish() + } +} + +/// The handle makes the association explicit and the `Ref` guard keeps the +/// table borrow alive for a controlled duration. It is not meant to live +/// across a yield or poll boundary. +pub struct ResourceRef<'a, T> { + handle: ResourceHandle, + value: Ref<'a, T>, +} + +impl<'a, T> ResourceRef<'a, T> { + pub(crate) fn new(handle: ResourceHandle, value: Ref<'a, T>) -> Self { + Self { handle, value } + } + + pub fn handle(&self) -> ResourceHandle { + self.handle + } + + pub fn get(&self) -> &T { + &self.value + } +} + +impl Clone for ResourceRef<'_, T> { + fn clone(&self) -> Self { + Self { + handle: self.handle, + value: Ref::clone(&self.value), + } + } +} + +impl core::fmt::Debug for ResourceRef<'_, T> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ResourceRef") + .field("handle", &self.handle) + .finish_non_exhaustive() + } +} + +impl core::ops::Deref for ResourceRef<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + &self.value + } +} + +/// A mutable borrow of a [`Resource`], scoped to a single host call. +pub struct ResourceMut<'a, T> { + handle: ResourceHandle, + value: RefMut<'a, T>, +} + +impl<'a, T> ResourceMut<'a, T> { + pub(crate) fn new(handle: ResourceHandle, value: RefMut<'a, T>) -> Self { + Self { handle, value } + } + + pub fn handle(&self) -> ResourceHandle { + self.handle + } + + pub fn get(&mut self) -> &mut T { + &mut self.value + } +} + +impl core::fmt::Debug for ResourceMut<'_, T> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ResourceMut") + .field("handle", &self.handle) + .finish_non_exhaustive() + } +} + +impl core::ops::Deref for ResourceMut<'_, T> { + type Target = T; + + fn deref(&self) -> &T { + &self.value + } +} + +impl core::ops::DerefMut for ResourceMut<'_, T> { + fn deref_mut(&mut self) -> &mut T { + &mut self.value + } +} + +fn invalid_handle(message: &'static str) -> ResourceError { + ResourceError::new( + ResourceErrorCode::InvalidResourceHandle, + "resource::handle", + message, + ) +} + +#[cfg(test)] +mod tests { + use super::{MAX_HANDLE_ARENA_ID, ResourceHandle}; + + fn pack(arena: u64, slot_identity: u64, generation: u64) -> u64 { + (arena << 43) | (slot_identity << 25) | generation + } + + #[test] + fn minimum_handle_round_trips_and_is_positive() { + let h = ResourceHandle::encode(1, 0, 1).expect("valid"); + assert_eq!(h.raw(), pack(1, 1, 1)); + assert!((h.raw() as i64) > 0); + assert_eq!(ResourceHandle::from_raw(h.raw()).expect("decodes"), h); + assert_eq!(h.generation(), 1); + assert_eq!(h.slot_index().expect("slot"), 0); + } + + #[test] + fn decode_rejects_invalid_encodings() { + assert!(ResourceHandle::from_raw(0).is_err()); + assert!(ResourceHandle::from_raw(pack(0, 1, 1)).is_err()); + assert!(ResourceHandle::from_raw(pack(1, 0, 1)).is_err()); + assert!(ResourceHandle::from_raw(pack(1, 1, 0)).is_err()); + assert!( + (ResourceHandle::from_raw(pack(MAX_HANDLE_ARENA_ID, 1, 1)) + .is_ok() + .then_some(()) + .is_some()) + ); + } + + #[test] + fn encode_rejects_out_of_range_fields() { + assert!(ResourceHandle::encode(0, 0, 1).is_none()); + assert!(ResourceHandle::encode(MAX_HANDLE_ARENA_ID + 1, 0, 1).is_none()); + assert!(ResourceHandle::encode(1, (1 << 18) as usize, 1).is_none()); + assert!(ResourceHandle::encode(1, 0, 0).is_none()); + } +} diff --git a/src/vm/resource/mod.rs b/src/vm/resource/mod.rs new file mode 100644 index 00000000..17b1eacb --- /dev/null +++ b/src/vm/resource/mod.rs @@ -0,0 +1,35 @@ +//! Host-agnostic typed generational resource SDK. +//! +//! This module is the public surface host crates use to allocate, borrow, and +//! close VM resources without reaching into VM private state. It is generic +//! over the concrete resource type: the concrete class is validated at borrow +//! time with [`std::any::TypeId`] and never enumerated by the core. +//! +//! # Ownership model +//! +//! - [`ResourceTable`] is the single owner of every live resource in one +//! execution scope. A table is `Send + !Sync` and is moved under the sole +//! mutating owner. +//! - A [`Resource`] is a cheap, `Copy` capability token keyed by a +//! [`ResourceHandle`]. Duplicating the token does not duplicate ownership of +//! the underlying resource. +//! - Host functions borrow a resource for the duration of one call through +//! [`ResourceTable::get`] / [`ResourceTable::get_mut`], returning +//! [`ResourceRef`] / [`ResourceMut`], which must not outlive the call. +//! - Close is poll-based: [`HostResource::begin_close`] issues the synchronous +//! cancel/close request, then [`ResourceTable::poll_close`] drives a single +//! resource to completion and [`ResourceTable::poll_close_all`] drives the +//! whole table to quiescence using the caller's waker. Stale handles and +//! slot reuse after close are rejected by the generation in the handle. + +pub mod close; +pub mod error; +pub mod handle; +pub mod reason; +pub mod table; + +pub use self::close::{CloseProgress, HostResource}; +pub use self::error::{ResourceError, ResourceErrorCode, ResourceResult}; +pub use self::handle::{Resource, ResourceHandle, ResourceMut, ResourceRef}; +pub use self::reason::ResourceCloseReason; +pub use table::{CloseAllReport, ResourceTable}; diff --git a/src/vm/resource/reason.rs b/src/vm/resource/reason.rs new file mode 100644 index 00000000..fb06d3c9 --- /dev/null +++ b/src/vm/resource/reason.rs @@ -0,0 +1,186 @@ +//! Generic, host-agnostic lifecycle reasons for closing VM resources. +//! +//! This mirrors the runtime cancellation-reason vocabulary but stays in the +//! resource domain so no builtin or domain type leaks into this support +//! module. The variants are stable and machine-readable; later layers (e.g. +//! the operation registry) map them onto their own lifecycle semantics. + +use std::fmt; + +/// Numeric, stable reason a resource is being closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(u8)] +pub enum ResourceCloseReason { + Requested = 1, + Deadline = 2, + VmReset = 3, + Parent = 4, + ResourceClosed = 5, + /// The `Vm` itself is being dropped. Scope shutdown begun here must + /// synchronously cancel/begin-close every live resource with this reason + /// (child first), as far as the nonblocking Drop contract permits. + VmDrop = 6, +} + +impl ResourceCloseReason { + /// Stable string form used for machine-readable messages / logs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::Deadline => "deadline", + Self::VmReset => "vm_reset", + Self::Parent => "parent", + Self::ResourceClosed => "resource_closed", + Self::VmDrop => "vm_drop", + } + } + + /// Decodes a raw numeric reason into a variant, returning `None` for any + /// encoding that is not one of the stable reason values. + pub const fn from_raw(raw: u8) -> Option { + match raw { + 1 => Some(Self::Requested), + 2 => Some(Self::Deadline), + 3 => Some(Self::VmReset), + 4 => Some(Self::Parent), + 5 => Some(Self::ResourceClosed), + 6 => Some(Self::VmDrop), + _ => None, + } + } + + /// The raw numeric encoding, for machine-readable payloads. + pub const fn raw(self) -> u8 { + self as u8 + } +} + +impl fmt::Display for ResourceCloseReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::ResourceCloseReason; + + #[test] + fn reasons_cover_lifecycle_vocabulary_with_raw_and_string_round_trip() { + for (reason, raw, text) in [ + (ResourceCloseReason::Requested, 1u8, "requested"), + (ResourceCloseReason::Deadline, 2, "deadline"), + (ResourceCloseReason::VmReset, 3, "vm_reset"), + (ResourceCloseReason::Parent, 4, "parent"), + (ResourceCloseReason::ResourceClosed, 5, "resource_closed"), + (ResourceCloseReason::VmDrop, 6, "vm_drop"), + ] { + assert_eq!(reason.raw(), raw, "raw encoding of {reason:?}"); + assert_eq!( + ResourceCloseReason::from_raw(raw), + Some(reason), + "decoding raw {raw}" + ); + assert_eq!( + ResourceCloseReason::from_raw(reason.raw()), + Some(reason), + "raw round-trip for {reason:?}" + ); + assert_eq!(reason.as_str(), text, "string form of {reason:?}"); + assert_eq!(reason.to_string(), text, "Display matches string form"); + } + // Unknown encodings decode to None. + assert!(ResourceCloseReason::from_raw(0).is_none()); + assert!(ResourceCloseReason::from_raw(7).is_none()); + assert!(ResourceCloseReason::from_raw(u8::MAX).is_none()); + } +} + +/// Architecture guard: the resource support modules must stay free of +/// `crate::builtins` (and comment-only noise) so they can be reused without +/// pulling in the core crate's builtin registry. The scan is dynamic: every +/// production `.rs` file directly under `src/vm/resource/` is enumerated at +/// test time, so any future module is covered automatically without editing +/// this test. +#[cfg(test)] +mod architecture_tests { + use std::fs; + use std::path::PathBuf; + + /// Removes `//` line comments (including `//!` / `///`) and `/* ... */` + /// block comments so the guard only inspects real code, not doc text. + fn strip_comments(source: &str) -> String { + let mut out = String::new(); + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index..].starts_with(b"//") { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } else if bytes[index..].starts_with(b"/*") { + index += 2; + while index < bytes.len() && !bytes[index..].starts_with(b"*/") { + index += 1; + } + index += 2; + } else { + out.push(bytes[index] as char); + index += 1; + } + } + out + } + + /// Built via `join` so the guard never matches its own source. + fn forbidden_builtins() -> String { + ["crate", "::builtins"].join("") + } + + /// Any remaining direct reference to a builtin registry entry. + fn forbidden_builtins_path() -> String { + ["::", "builtins", "::"].join("") + } + + /// Every production `.rs` file directly under `src/vm/resource`. + fn production_sources() -> Vec { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/vm/resource"); + let mut files: Vec = fs::read_dir(&dir) + .expect("src/vm/resource must exist") + .map(|entry| entry.expect("readable directory entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .collect(); + files.sort(); + files + } + + #[test] + fn resource_production_sources_reject_core_and_domain_imports() { + let sources = production_sources(); + assert!( + !sources.is_empty(), + "dynamic enumeration must find production sources under src/vm/resource" + ); + let forbidden = [forbidden_builtins(), forbidden_builtins_path()]; + for path in &sources { + let source = fs::read_to_string(path).expect("read production source"); + let code = strip_comments(&source); + for needle in &forbidden { + assert!( + !code.contains(needle), + "{} must stay decoupled from the core crate builtin registry / domain modules: found `{needle}`", + path.display(), + ); + } + // Explicit external domain coupling is forbidden; this module + // family must stay host- and domain-agnostic. Built via join so + // the guarded token cannot accidentally appear in this very test. + let external_domain = ["rus", "qlite"].join(""); + assert!( + !code.contains(&external_domain), + "{} must not import an external domain dependency", + path.display(), + ); + } + } +} diff --git a/src/vm/resource/table.rs b/src/vm/resource/table.rs new file mode 100644 index 00000000..ac3a93ca --- /dev/null +++ b/src/vm/resource/table.rs @@ -0,0 +1,1118 @@ +//! Host-agnostic typed generational resource table. +//! +//! The table is the single owner of every erased [`HostResource`] for one +//! execution scope. It manages: +//! +//! - a bounded [`ResourceHandle`] space (arena + slot + generation), +//! - [`std::any::TypeId`] based borrow-time type validation, +//! - poll-based two-phase close with deterministic shutdown. +//! +//! The table holds no concrete resource type: host crates register resources +//! through [`HostResource`] and the core never dispatches on a class. The table +//! is `Send + !Sync`: it is moved under the sole mutating VM/scope owner. + +use std::any::{Any, TypeId}; +use std::cell::{Cell, Ref, RefCell, RefMut}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context, Poll}; + +use super::close::{CloseProgress, HostResource}; +use super::error::{ResourceError, ResourceErrorCode, ResourceResult}; +use super::handle::{ + DEFAULT_MAX_RESOURCES, MAX_HANDLE_ARENA_ID, MAX_HANDLE_GENERATION, MAX_RESOURCE_SLOTS, + Resource, ResourceHandle, ResourceMut, ResourceRef, +}; +use super::reason::ResourceCloseReason; + +/// Process-unique arena identity source, never recycled. +/// +/// An arena id therefore binds a handle to one table (and the scope that owns +/// it) for the lifetime of the process. +static NEXT_ARENA_ID: AtomicU64 = AtomicU64::new(1); + +/// Test-only, per-thread arena-id source override. +/// +/// Exhaustion is a *process-global* property: the real `NEXT_ARENA_ID` counter +/// can only reach `MAX_HANDLE_ARENA_ID` after ~1,048,575 tables have been +/// created in one process, which no test suite can (or should) reproduce +/// deterministically. Exhaustion tests therefore install a private counter for +/// their own thread; `with_limit` hands out arena ids from that counter while +/// it is installed, and every other thread keeps allocating from the real +/// process-global source. This keeps exhaustion deterministic, order- +/// independent, and parallel-safe, and never mutates the real global +/// allocator. +#[cfg(test)] +pub(crate) mod test_seam { + use std::cell::Cell; + use std::sync::atomic::AtomicU64; + + thread_local! { + static ARENA_SOURCE: Cell> = const { Cell::new(None) }; + } + + /// The arena-id source installed for the current thread, if any. + pub(crate) fn source() -> Option<&'static AtomicU64> { + ARENA_SOURCE.with(|cell| cell.get()) + } + + /// RAII guard installing `counter` as this thread's arena-id source for + /// the duration of the guard. Restores the previous source on drop. + /// + /// Kept as a test seam for a deterministic arena-exhaustion test. No + /// current de-scoped test constructs it (the process-global counter cannot + /// be exhausted in practice), so it is allowed dead in the test build. + #[allow(dead_code)] + pub(crate) struct ScopedArenaSource; + + #[allow(dead_code)] + impl ScopedArenaSource { + pub(crate) fn install(counter: &'static AtomicU64) -> Self { + ARENA_SOURCE.with(|cell| { + assert!( + cell.get().is_none(), + "nested arena source override is unsupported" + ); + cell.set(Some(counter)); + }); + Self + } + } + + #[allow(dead_code)] + impl Drop for ScopedArenaSource { + fn drop(&mut self) { + ARENA_SOURCE.with(|cell| cell.set(None)); + } + } +} + +/// Lifecycle of one slot. +enum SlotState { + Vacant, + Open(Box), + /// `begin_close` returned [`CloseProgress::Pending`]; the resource is being + /// polled to completion and its generation is not yet reusable. + Closing(Box), +} + +struct ResourceSlot { + /// Advanced on every reuse. + generation: Cell, + /// Concrete type of the current occupant; borrow-time validation only. + type_id: TypeId, + /// The resource state is independently guarded so distinct frame requests + /// may hold disjoint borrows without an aliased `&mut ResourceTable`. + state: RefCell, +} + +/// Cumulative state persisted across [`ResourceTable::poll_close_all`] polls +/// until the table is quiescent. +struct CloseAllState { + reason: ResourceCloseReason, + closed: usize, + /// Total number of cleanup failures observed across the sweep. + failed: usize, + first_error: Option, +} + +/// Terminal report of one fully-driven close-all sweep. +/// +/// Returned once the table is quiescent; carries the cumulative closed count, +/// the total failure count, and the first (earliest) cleanup failure, so the +/// caller can size the blast radius instead of only seeing one error. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CloseAllReport { + /// Cumulative number of resources closed across the whole sweep. + pub closed: usize, + /// Total number of cleanup failures observed (begin and poll closes), + /// including the one in `first_error`. + pub failed: usize, + /// Earliest cleanup failure observed during the sweep, if any + /// (first-error-wins). + pub first_error: Option, +} + +/// Bounded arena of erased resources owned by one execution scope. +/// +/// `Send + !Sync` by construction: it must never be shared; the owning scope +/// moves it and mutates it single-threaded. +pub struct ResourceTable { + arena_id: u64, + max_entries: usize, + slots: Vec, + /// Indices of reusable physical slots. Interior mutability lets the + /// `&self`-based take path return a consumed slot to the pool immediately. + vacant_slots: RefCell>, + active_entries: Cell, + /// In-flight `poll_close_all` sweep, if one is active. + close_all: Option, + /// Arena-owned typed scope state, keyed by [`TypeId`] and erased as + /// `Box`. + /// + /// This map lives *directly on* the resource table so the owning scope can + /// carry per-type state while its (possibly many) ordinary resource slots + /// stay free for actual resources. It is deliberately separate from the + /// handle/slot arena: an ordinary resource whose payload happens to be `T` + /// can never collide with a scope-state entry also keyed by + /// [`TypeId::of::`]. Entries are dropped exactly once, when the table's + /// terminal close reaches quiescence (see + /// [`poll_close_all_report`](Self::poll_close_all_report)). + scope_states: HashMap>, +} + +/// Hands out the next process-unique arena identity, or a typed +/// [`ResourceErrorCode::ResourceTableArenaExhausted`] once the identity space +/// is exhausted. +/// +/// Allocation is atomic and monotonic: the counter is advanced exactly once +/// per successful handout (via `fetch_update`), never on failure, and ids are +/// never recycled or wrapped. Under `#[cfg(test)]`, the current thread's +/// [`test_seam`] override (if installed) replaces the process-global +/// `NEXT_ARENA_ID` so exhaustion tests are deterministic and never consume the +/// real global allocator. +fn allocate_arena_id() -> Result { + #[cfg(test)] + let source = test_seam::source().unwrap_or(&NEXT_ARENA_ID); + #[cfg(not(test))] + let source = &NEXT_ARENA_ID; + source + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |arena_id| { + (arena_id <= MAX_HANDLE_ARENA_ID).then_some(arena_id + 1) + }) + .map_err(|_| { + ResourceError::new( + ResourceErrorCode::ResourceTableArenaExhausted, + "resource::table", + "resource table arena identity space is exhausted", + ) + }) +} + +impl ResourceTable { + /// Creates an empty table with a fresh arena identity and capacity limit. + pub fn with_limit(max_entries: usize) -> ResourceResult { + if max_entries == 0 || max_entries > MAX_RESOURCE_SLOTS { + return Err(ResourceError::new( + ResourceErrorCode::InvalidConfiguration, + "resource::table", + format!("resource table capacity must be between 1 and {MAX_RESOURCE_SLOTS}"), + ) + .with_limit(MAX_RESOURCE_SLOTS)); + } + let arena_id = allocate_arena_id()?; + Ok(Self { + arena_id, + max_entries, + slots: Vec::new(), + vacant_slots: RefCell::new(Vec::new()), + active_entries: Cell::new(0), + close_all: None, + scope_states: HashMap::new(), + }) + } + + /// Creates a table with the default [`DEFAULT_MAX_RESOURCES`] capacity. + /// + /// Fallible: arena identity allocation can fail with a typed + /// [`ResourceErrorCode::ResourceTableArenaExhausted`] once the + /// process-unique arena space is exhausted. Embeddings and pools must + /// propagate this error instead of panicking. + pub fn new() -> ResourceResult { + Self::with_limit(DEFAULT_MAX_RESOURCES) + } + + pub fn len(&self) -> usize { + self.active_entries.get() + } + + /// Whether the table currently holds no live resources. + pub fn is_empty(&self) -> bool { + self.active_entries.get() == 0 + } + + /// Number of physical slot entries ever carved out of the arena. + /// + /// Test-only: proves that close/reuse cycles return slots to the vacant + /// pool instead of growing physical identity usage without bound. + #[cfg(test)] + fn slots_len(&self) -> usize { + self.slots.len() + } + + /// Inserts a root resource and returns its typed token. + pub fn push(&mut self, value: T) -> ResourceResult> { + let handle = self.allocate(value)?; + Ok(Resource::from_handle(handle)) + } + + /// Validates a raw [`ResourceHandle`] and recovers a typed token. + /// + /// This is the only public way to lift an arbitrary raw handle into a + /// typed [`Resource`]. It rejects the handle if it belongs to a + /// different table (arena), refers to a stale slot generation, names the + /// wrong concrete `TypeId`, or points at a resource that is no longer + /// `Open`: + /// + /// - foreign arena → [`ResourceErrorCode::ResourceHandleWrongTable`] + /// - stale generation → [`ResourceErrorCode::ResourceStale`] + /// - wrong type → [`ResourceErrorCode::ResourceTypeMismatch`] + /// - closed/closing → [`ResourceErrorCode::ResourceAlreadyClosed`] + /// + /// A rejected recovery is purely read-only: no slot, generation, or type + /// state is mutated. + pub fn typed(&self, handle: ResourceHandle) -> ResourceResult> { + self.validate_active::(handle)?; + Ok(Resource::from_handle(handle)) + } + + /// Immutably borrows one live resource for the duration of a host call. + pub fn get( + &self, + resource: &Resource, + ) -> ResourceResult> { + let handle = resource.handle(); + let slot_index = self.validate_active::(handle)?; + self.borrow_open_ref(handle, slot_index) + } + + /// Mutably borrows one live resource for the duration of a host call. + pub fn get_mut( + &mut self, + resource: &Resource, + ) -> ResourceResult> { + let handle = resource.handle(); + let slot_index = self.validate_active::(handle)?; + self.borrow_open_mut(handle, slot_index) + } + + fn borrow_open_ref( + &self, + handle: ResourceHandle, + slot_index: usize, + ) -> ResourceResult> { + let state = self.slots[slot_index] + .state + .try_borrow() + .map_err(|_| resource_borrow_conflict_error(handle))?; + let value = Ref::map(state, |state| match state { + SlotState::Open(resource) => (resource.as_ref() as &dyn Any) + .downcast_ref::() + .expect("validated resource TypeId must match downcast type"), + SlotState::Closing(_) | SlotState::Vacant => { + unreachable!("validated open resource changed state during shared borrow") + } + }); + Ok(ResourceRef::new(handle, value)) + } + + fn borrow_open_mut( + &self, + handle: ResourceHandle, + slot_index: usize, + ) -> ResourceResult> { + let state = self.slots[slot_index] + .state + .try_borrow_mut() + .map_err(|_| resource_borrow_conflict_error(handle))?; + let value = RefMut::map(state, |state| match state { + SlotState::Open(resource) => (resource.as_mut() as &mut dyn Any) + .downcast_mut::() + .expect("validated resource TypeId must match downcast type"), + SlotState::Closing(_) | SlotState::Vacant => { + unreachable!("validated open resource changed state during mutable borrow") + } + }); + Ok(ResourceMut::new(handle, value)) + } + + /// Begins closing a resource. + /// + /// Properties: + /// - An already-closing resource returns [`CloseProgress::Pending`] + /// (idempotent); the generation is held until close finishes. + /// - `CloseProgress::Ready` means the slot is already vacant again and the + /// generation advanced. + pub fn begin_close( + &mut self, + resource: Resource, + reason: ResourceCloseReason, + ) -> ResourceResult { + let handle = resource.handle(); + let slot_index = self.resolve_index(handle)?; + self.check_type::(slot_index, handle)?; + self.close_open_slot(slot_index, handle, reason) + } + + /// Polls one in-progress close to completion. + /// + /// Returns `Ready(Ok(()))` on a clean finish, `Ready(Err(_))` on a cleanup + /// failure (the slot is still reclaimed), or `Pending` while the resource + /// needs more time. + pub fn poll_close( + &mut self, + resource: Resource, + cx: &mut Context<'_>, + ) -> Poll> { + let handle = resource.handle(); + let slot_index = self.resolve_index(handle)?; + self.check_type::(slot_index, handle)?; + + let state = self.replace_slot_state(slot_index, SlotState::Vacant); + match state { + SlotState::Closing(mut resource) => match resource.poll_close(cx) { + Poll::Ready(result) => { + self.reclaim(slot_index); + Poll::Ready(result) + } + Poll::Pending => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + Poll::Pending + } + }, + SlotState::Open(resource) => { + // Not closing: restore the open resource and report the precise + // wrong-state error (distinct from an invalid handle). + self.put_slot_state(slot_index, SlotState::Open(resource)); + Poll::Ready(Err(not_closing_error(handle))) + } + SlotState::Vacant => Poll::Ready(Err(already_closed_error(handle))), + } + } + + /// Drives a caller-context close of every live resource. + /// + /// This is the event-driven close-all: unlike a synchronous sweep it can + /// wait on genuinely `Pending` resources using the caller's waker. A + /// cleanup failure does not stop the remaining best-effort closes: every + /// resource close is attempted and the first failure is retained until the + /// whole sweep finishes. + /// + /// Contract: + /// - Returns [`Poll::Ready`] **only** once the table is quiescent + /// ([`len`](ResourceTable::len) `== 0`). `Ready(Ok(n))` reports the + /// cumulative number of resources closed across all polls; `Ready(Err)` + /// reports the first cleanup failure once every resource has finished. + /// - Returns [`Poll::Pending`] whenever any Open or Closing resource + /// remains. The cumulative closed count, the first cleanup error, and the + /// initial `reason` are persisted across Pending polls. + /// - The `reason` is bound on the first poll of a sweep. Supplying a + /// conflicting reason is rejected deterministically with + /// [`ResourceErrorCode::ResourceCloseInProgress`] and leaves the in-flight + /// sweep (and its original reason) untouched. + pub fn poll_close_all( + &mut self, + reason: ResourceCloseReason, + cx: &mut Context<'_>, + ) -> Poll> { + match self.poll_close_all_report(reason, cx) { + Poll::Pending => Poll::Pending, + // Preserve the legacy error surface: a sweep that finished with + // cleanup failures reports `Err(first_error)` here, while the + // report-based variant carries the full failure count. + Poll::Ready(Ok(report)) => match report.first_error { + Some(error) => Poll::Ready(Err(error)), + None => Poll::Ready(Ok(report.closed)), + }, + Poll::Ready(Err(error)) => Poll::Ready(Err(error)), + } + } + + /// Drives a caller-context close of every live resource and reports the + /// full sweep result (closed count, failure count, first failure) exactly + /// once the table is quiescent. + /// + /// Same contract and sweep as [`poll_close_all`](Self::poll_close_all), + /// but the terminal [`CloseAllReport`] carries the cumulative closed + /// count, the total failure count, and the earliest failure instead of + /// only the first error. This is the report the execution scope consumes + /// so its own terminal outcome can carry the failure count. + pub fn poll_close_all_report( + &mut self, + reason: ResourceCloseReason, + cx: &mut Context<'_>, + ) -> Poll> { + // Deterministically reject a conflicting reason. The in-flight sweep + // keeps the reason it started with; we do not mutate any state here. + if self + .close_all + .as_ref() + .is_some_and(|state| state.reason != reason) + { + let in_progress = self.close_all.as_ref().expect("checked above").reason; + return Poll::Ready(Err(close_in_progress_error(reason, in_progress))); + } + if self.close_all.is_none() { + self.close_all = Some(CloseAllState { + reason, + closed: 0, + failed: 0, + first_error: None, + }); + } + let reason = self.close_all.as_ref().unwrap().reason; + let mut closed = self.close_all.as_ref().unwrap().closed; + let mut failed = self.close_all.as_ref().unwrap().failed; + let mut first_error = self.close_all.as_ref().unwrap().first_error.clone(); + + // Sweep until a full pass makes no progress: every current open + // resource is begun, every Closing resource is polled, and both repeat + // until the state stabilizes. Genuinely-Pending resources stay in + // `Closing` and are re-polled on a later `poll_close_all` call with the + // real waker. + let mut progressed = true; + while progressed { + progressed = false; + let open_indices = self.open_indices()?; + for slot_index in open_indices { + progressed |= self.try_begin_close( + slot_index, + reason, + &mut closed, + &mut failed, + &mut first_error, + ); + } + let closing_indices = self.closing_indices()?; + for slot_index in closing_indices { + progressed |= + self.try_poll_close(slot_index, cx, &mut closed, &mut failed, &mut first_error); + } + } + + // Persist cumulative progress across Pending polls. + let state = self.close_all.as_mut().unwrap(); + state.closed = closed; + state.failed = failed; + state.first_error = first_error; + + if self.is_empty() { + // Quiescent: this, and only this, warrants a Ready completion. + let state = self.close_all.take().unwrap(); + // Terminal resource close: drop the typed scope-state arena exactly + // once, only after every ordinary resource has reached quiescence. + // The map's `Drop` also runs naturally if the table itself is + // dropped without ever reaching this path. + self.clear_scope_states(); + Poll::Ready(Ok(CloseAllReport { + closed: state.closed, + failed: state.failed, + first_error: state.first_error, + })) + } else { + Poll::Pending + } + } + + /// Drop-only, nonblocking close launch for every remaining open resource. + /// + /// Unlike the reusable close/reset sweep, this phase does not wait for a + /// pending resource to become quiescent before continuing. It invokes + /// `begin_close` once for each still-open slot, retains closing slots in + /// `Closing`, and never reports table quiescence. Already-closing slots are + /// left untouched, preserving exactly-once begin semantics. + pub(crate) fn begin_close_remaining_for_drop( + &mut self, + reason: ResourceCloseReason, + ) -> ResourceResult<()> { + let indices = self.live_indices()?; + let mut first_error = None; + + for slot_index in indices { + let state = self.replace_slot_state(slot_index, SlotState::Vacant); + let SlotState::Open(mut resource) = state else { + self.put_slot_state(slot_index, state); + continue; + }; + match resource.begin_close(reason) { + Ok(CloseProgress::Ready) => self.reclaim(slot_index), + Ok(CloseProgress::Pending) => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + } + Err(error) => { + self.put_slot_state(slot_index, SlotState::Open(resource)); + first_error.get_or_insert(error); + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + /// Best-effort synchronous child-first close of every live resource. + /// + /// Drives a single [`poll_close_all`](ResourceTable::poll_close_all) sweep + /// with a no-op waker and returns only once the table is quiescent: + /// - `Ready(Ok(n))` is reported exactly when [`len`](ResourceTable::len) + /// reached zero and every close succeeded; + /// - `Ready(Err(_))` is reported when every resource finished but the first + /// cleanup failed; + /// - [`ResourceErrorCode::ResourceClosePending`] is returned (never + /// success) when at least one resource remains pending at the end of the + /// single no-op sweep, because such a resource needs an external waker + /// that a synchronous no-op driver cannot provide. + pub fn close_all(&mut self, reason: ResourceCloseReason) -> ResourceResult { + let mut cx = noop_context(); + match self.poll_close_all(reason, &mut cx) { + Poll::Ready(result) => result, + Poll::Pending => Err(ResourceError::new( + ResourceErrorCode::ResourceClosePending, + "resource::close_all", + "synchronous close-all cannot drive pending resources to quiescence", + )), + } + } + + /// Returns the process-unique arena identity of this table. + pub fn arena_id(&self) -> u64 { + self.arena_id + } + + // ---- typed scope-state arena ------------------------------------------------- + + /// Returns a mutable handle to the `T`-typed scope state, creating it with + /// `init` on first access. + /// + /// The entry lives in the arena-owned map separate from the ordinary + /// resource slots, so an ordinary resource whose payload is also `T` can + /// never collide with it. The reference is valid for as long as `&mut self` + /// because the erased [`Box`] lives directly in the table. + pub fn scope_state_or_insert_with T>( + &mut self, + init: F, + ) -> &mut T { + self.scope_states + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(init()) as Box) + .downcast_mut::() + .expect("state keyed by TypeId::of:: must downcast to T") + } + + /// Borrows the `T`-typed scope state, if present. + pub fn scope_state(&self) -> Option<&T> { + self.scope_states + .get(&TypeId::of::()) + .and_then(|erased| erased.downcast_ref::()) + } + + /// Mutably borrows the `T`-typed scope state, if present. + pub fn scope_state_mut(&mut self) -> Option<&mut T> { + self.scope_states + .get_mut(&TypeId::of::()) + .and_then(|erased| erased.downcast_mut::()) + } + + /// Removes and returns the `T`-typed scope state, if present. + pub fn take_scope_state(&mut self) -> Option { + self.scope_states + .remove(&TypeId::of::()) + .and_then(|erased| erased.downcast::().ok()) + .map(|boxed| *boxed) + } + + /// Drops every typed scope-state entry. + /// + /// Private: only the table's own terminal close path calls it, exactly + /// once, so the per-type payloads are dropped deterministically rather than + /// on arbitrary scope teardown. + fn clear_scope_states(&mut self) { + self.scope_states.clear(); + } + + // ---- internal close machinery ------------------------------------------------- + + fn replace_slot_state(&mut self, slot_index: usize, state: SlotState) -> SlotState { + std::mem::replace(self.slots[slot_index].state.get_mut(), state) + } + + fn put_slot_state(&mut self, slot_index: usize, state: SlotState) { + *self.slots[slot_index].state.get_mut() = state; + } + + fn close_open_slot( + &mut self, + slot_index: usize, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> ResourceResult { + let state = self.replace_slot_state(slot_index, SlotState::Vacant); + match state { + SlotState::Open(mut resource) => match resource.begin_close(reason) { + Ok(CloseProgress::Ready) => { + self.reclaim(slot_index); + Ok(CloseProgress::Ready) + } + Ok(CloseProgress::Pending) => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + Ok(CloseProgress::Pending) + } + Err(error) => { + // Explicit-close failure stays local: the resource is + // left Open so a later shutdown sweep retries the + // idempotent close request. The failure is returned to + // the caller (which records it in the scope latch); + // the resource is NOT dropped or reclaimed here. + self.put_slot_state(slot_index, SlotState::Open(resource)); + Err(error) + } + }, + SlotState::Closing(resource) => { + // Idempotent: the close is already in flight; keep holding the + // generation until the outer caller drives poll_close. + self.put_slot_state(slot_index, SlotState::Closing(resource)); + Ok(CloseProgress::Pending) + } + SlotState::Vacant => Err(already_closed_error(handle)), + } + } + + fn try_begin_close( + &mut self, + slot_index: usize, + reason: ResourceCloseReason, + closed: &mut usize, + failed: &mut usize, + first_error: &mut Option, + ) -> bool { + let state = self.replace_slot_state(slot_index, SlotState::Vacant); + let SlotState::Open(mut resource) = state else { + // Not open (e.g. already closing); restore and report no progress. + self.put_slot_state(slot_index, state); + return false; + }; + match resource.begin_close(reason) { + Ok(CloseProgress::Ready) => { + self.reclaim(slot_index); + *closed += 1; + true + } + Ok(CloseProgress::Pending) => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + true + } + Err(error) => { + self.reclaim(slot_index); + *closed += 1; + *failed += 1; + first_error.get_or_insert(error); + true + } + } + } + + fn try_poll_close( + &mut self, + slot_index: usize, + cx: &mut Context<'_>, + closed: &mut usize, + failed: &mut usize, + first_error: &mut Option, + ) -> bool { + let state = self.replace_slot_state(slot_index, SlotState::Vacant); + let SlotState::Closing(mut resource) = state else { + self.put_slot_state(slot_index, state); + return false; + }; + match resource.poll_close(cx) { + Poll::Ready(result) => { + self.reclaim(slot_index); + *closed += 1; + if let Err(error) = result { + *failed += 1; + first_error.get_or_insert(error); + } + true + } + Poll::Pending => { + self.put_slot_state(slot_index, SlotState::Closing(resource)); + false + } + } + } + + fn reclaim(&mut self, slot_index: usize) { + self.put_slot_state(slot_index, SlotState::Vacant); + if u64::from(self.slots[slot_index].generation.get()) < MAX_HANDLE_GENERATION { + self.vacant_slots.get_mut().push(slot_index); + } + self.active_entries.set(self.active_entries.get() - 1); + } + + /// Indices of slots currently in [`SlotState::Open`]. + fn open_indices(&self) -> ResourceResult> { + let mut indices = Vec::new(); + for (index, slot) in self.slots.iter().enumerate() { + let state = slot + .state + .try_borrow() + .map_err(|_| resource_borrow_conflict_error_for_slot(slot))?; + if matches!(&*state, SlotState::Open(_)) { + indices.push(index); + } + } + Ok(indices) + } + + /// Indices of slots currently in [`SlotState::Closing`]. + fn closing_indices(&self) -> ResourceResult> { + let mut indices = Vec::new(); + for (index, slot) in self.slots.iter().enumerate() { + let state = slot + .state + .try_borrow() + .map_err(|_| resource_borrow_conflict_error_for_slot(slot))?; + if matches!(&*state, SlotState::Closing(_)) { + indices.push(index); + } + } + Ok(indices) + } + + fn live_indices(&mut self) -> ResourceResult> { + let mut indices = Vec::new(); + for slot_index in 0..self.slots.len() { + if !matches!(self.slots[slot_index].state.get_mut(), SlotState::Vacant) { + indices.push(slot_index); + } + } + Ok(indices) + } + + // ---- allocation --------------------------------------------------------------- + + fn allocate(&mut self, value: T) -> Result { + if self.active_entries.get() >= self.max_entries { + return Err(ResourceError::new( + ResourceErrorCode::ResourceLimitExceeded, + "resource::push", + "resource table capacity has been reached", + ) + .with_limit(self.max_entries)); + } + + let type_id = TypeId::of::(); + let value: Box = Box::new(value); + + let (slot_index, generation) = if let Some(slot_index) = self.vacant_slots.get_mut().pop() { + let generation = self.slots[slot_index] + .generation + .get() + .checked_add(1) + .filter(|generation| u64::from(*generation) <= MAX_HANDLE_GENERATION) + .expect("only reusable generations enter the vacant list"); + self.slots[slot_index].generation.set(generation); + self.slots[slot_index].type_id = type_id; + *self.slots[slot_index].state.get_mut() = SlotState::Open(value); + (slot_index, generation) + } else { + if self.slots.len() >= MAX_RESOURCE_SLOTS { + return Err(ResourceError::new( + ResourceErrorCode::ResourceIdExhausted, + "resource::push", + "resource table slot space is exhausted", + )); + } + let slot_index = self.slots.len(); + let generation = 1u32; + self.slots.push(ResourceSlot { + generation: Cell::new(generation), + type_id, + state: RefCell::new(SlotState::Open(value)), + }); + (slot_index, generation) + }; + self.active_entries.set(self.active_entries.get() + 1); + ResourceHandle::encode(self.arena_id, slot_index, u64::from(generation)).ok_or_else(|| { + ResourceError::new( + ResourceErrorCode::ResourceIdExhausted, + "resource::push", + "resource handle encoding overflowed", + ) + }) + } + + fn resolve_index(&self, handle: ResourceHandle) -> ResourceResult { + if handle.arena_id() != self.arena_id { + return Err(wrong_arena_error(handle)); + } + let slot_index = handle.slot_index()?; + if slot_index >= self.slots.len() { + return Err(stale_handle_error(handle)); + } + self.check_generation(slot_index, handle)?; + Ok(slot_index) + } + + fn check_generation(&self, slot_index: usize, handle: ResourceHandle) -> ResourceResult<()> { + if u64::from(self.slots[slot_index].generation.get()) != handle.generation() { + return Err(stale_handle_error(handle)); + } + Ok(()) + } + + fn check_type( + &self, + slot_index: usize, + handle: ResourceHandle, + ) -> ResourceResult<()> { + if self.slots[slot_index].type_id != TypeId::of::() { + return Err(type_mismatch(handle, TypeId::of::())); + } + Ok(()) + } + + /// Validates that the handle points at a live, open resource of the given + /// concrete type. + fn validate_active(&self, handle: ResourceHandle) -> ResourceResult { + let slot_index = self.resolve_index(handle)?; + self.check_type::(slot_index, handle)?; + let state = self.slots[slot_index] + .state + .try_borrow() + .map_err(|_| resource_borrow_conflict_error(handle))?; + if !matches!(&*state, SlotState::Open(_)) { + return Err(already_closed_error(handle)); + } + Ok(slot_index) + } +} + +impl Drop for ResourceTable { + fn drop(&mut self) { + // Best-effort last-resort cleanup with a no-op waker. This performs at + // most one synchronous sweep; it explicitly does NOT claim quiescence. + // In the intended flow the owning scope drives poll-based close to + // quiescence via `poll_close_all` before dropping the table, so this + // path only catches resources whose close was never driven. Genuinely + // event-driven Pending resources may remain live here and are released + // by their own `Drop` guards. + let _ = self.close_all(ResourceCloseReason::VmReset); + } +} + +// ---- error constructors ------------------------------------------------------------ + +fn resource_borrow_conflict_error(handle: ResourceHandle) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceAccessConflict, + "resource::access", + "resource slot is already borrowed", + ) + .with_value(handle.raw()) +} + +fn resource_borrow_conflict_error_for_slot(_slot: &ResourceSlot) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceAccessConflict, + "resource::access", + "resource slot is already borrowed", + ) +} + +fn wrong_arena_error(handle: ResourceHandle) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceHandleWrongTable, + "resource::table", + "resource handle does not belong to this table's arena", + ) + .with_value(handle.raw()) +} + +fn stale_handle_error(handle: ResourceHandle) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceStale, + "resource::table", + "resource handle refers to a stale slot generation", + ) + .with_value(handle.raw()) +} + +fn already_closed_error(handle: ResourceHandle) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceAlreadyClosed, + "resource::table", + "resource is already closed or closing", + ) + .with_value(handle.raw()) +} + +fn type_mismatch(handle: ResourceHandle, expected: TypeId) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceTypeMismatch, + "resource::table", + format!("resource type does not match expected type {:?}", expected), + ) + .with_value(handle.raw()) +} + +fn not_closing_error(handle: ResourceHandle) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceNotClosing, + "resource::table", + "resource is not in the closing state", + ) + .with_value(handle.raw()) +} + +fn close_in_progress_error( + reason: ResourceCloseReason, + in_progress: ResourceCloseReason, +) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceCloseInProgress, + "resource::poll_close_all", + format!( + "a close-all sweep is already in progress with reason `{in_progress}`; \ + requested reason `{reason}` was rejected" + ), + ) +} + +// ---- noop waker for synchronous poll driving --------------------------------------- + +/// A `'static` context with a no-op waker, used to drive poll-based close to +/// completion inside the synchronous `close_all` sweep. Resources closed in +/// this path are expected to complete without external wakeup. +fn noop_context() -> Context<'static> { + Context::from_waker(core::task::Waker::noop()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const REASON: ResourceCloseReason = ResourceCloseReason::ResourceClosed; + + /// A resource that counts synchronous closes. + #[derive(Debug)] + struct UnitRes(Arc); + + impl UnitRes { + fn new() -> (Self, Arc) { + let closes = Arc::new(AtomicUsize::new(0)); + (Self(closes.clone()), closes) + } + } + + impl HostResource for UnitRes { + fn begin_close(&mut self, _reason: ResourceCloseReason) -> ResourceResult { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(CloseProgress::Ready) + } + } + + /// A distinct inert type used to mint a mismatched `Resource`. + struct OtherRes; + + impl HostResource for OtherRes {} + + #[test] + fn typed_recovery_and_borrow_validate_type_and_state() { + let mut table = ResourceTable::new().expect("table"); + let (res, closes) = UnitRes::new(); + let token = table.push(res).unwrap(); + + // Public validated recovery returns an equivalent token. + let recovered = table.typed::(token.handle()).expect("recovery"); + assert_eq!(recovered.handle(), token.handle()); + table.get(&recovered).expect("recovered token borrows"); + + // The crate-private constructor is only reachable inside this crate; + // constructing a mismatched token here exercises rejection logic. + let wrong: Resource = Resource::from_handle(token.handle()); + assert_eq!( + table.get(&wrong).unwrap_err().code(), + ResourceErrorCode::ResourceTypeMismatch + ); + assert_eq!( + table.get_mut(&wrong).unwrap_err().code(), + ResourceErrorCode::ResourceTypeMismatch + ); + assert_eq!(table.len(), 1); + assert_eq!(closes.load(Ordering::SeqCst), 0); + table.get(&token).expect("real token unaffected"); + } + + #[test] + fn begin_close_is_exact_once_and_stales_the_handle() { + let mut table = ResourceTable::new().expect("table"); + let (res, closes) = UnitRes::new(); + let token = table.push(res).unwrap(); + table + .begin_close(token, REASON) + .expect("first close succeeds"); + assert_eq!(closes.load(Ordering::SeqCst), 1); + // A second close of the same token is already-closed. + assert_eq!( + table + .begin_close(token, REASON) + .expect_err("second close rejected") + .code(), + ResourceErrorCode::ResourceAlreadyClosed + ); + assert_eq!(closes.load(Ordering::SeqCst), 1); + assert_eq!(table.len(), 0); + } + + #[test] + fn stale_and_foreign_handles_are_rejected_with_typed_errors() { + let mut table = ResourceTable::new().expect("table"); + let (res, _) = UnitRes::new(); + let token = table.push(res).unwrap(); + let handle = token.handle(); + table.begin_close(token, REASON).unwrap(); + + // Immediately after a close the live generation is vacant: the same + // handle reports AlreadyClosed (precise closed-state error). + assert_eq!( + table.typed::(handle).expect_err("closed").code(), + ResourceErrorCode::ResourceAlreadyClosed + ); + // Reusing the slot advances its generation, so the old closed handle + // becomes a normal stale handle. + let _reused = table.push(UnitRes::new().0).unwrap(); + assert_eq!( + table.typed::(handle).expect_err("stale").code(), + ResourceErrorCode::ResourceStale + ); + // Foreign arena. + let other = ResourceTable::new().expect("other table"); + assert_eq!( + other.typed::(handle).expect_err("foreign").code(), + ResourceErrorCode::ResourceHandleWrongTable + ); + } + + #[test] + fn table_capacity_is_bounded_and_close_restores_it() { + let mut table = ResourceTable::with_limit(2).expect("table"); + let (a, _) = UnitRes::new(); + let (b, _) = UnitRes::new(); + table.push(a).unwrap(); + table.push(b).unwrap(); + let (c, _) = UnitRes::new(); + let error = table.push(c).expect_err("capacity reached"); + assert_eq!(error.code(), ResourceErrorCode::ResourceLimitExceeded); + + // Closing a resource restores capacity (slot reused). + table.close_all(REASON).expect("close all"); + assert_eq!(table.len(), 0); + // Reuse stays bounded: many close/re-push cycles never exceed the + // physical slot arena nor the configured capacity. + for _ in 0..4 { + let (res, _) = UnitRes::new(); + let token = table.push(res).expect("re-push after close"); + let _ = table.begin_close(token, REASON).expect("begin_close"); + } + assert_eq!(table.len(), 0); + assert!( + table.slots_len() <= 2, + "slot arena must stay bounded by the configured capacity" + ); + } +} diff --git a/tests/vm/execution_scope_tests.rs b/tests/vm/execution_scope_tests.rs new file mode 100644 index 00000000..858f3629 --- /dev/null +++ b/tests/vm/execution_scope_tests.rs @@ -0,0 +1,721 @@ +//! Focused TDD tests for the generic, host-agnostic execution-scope lifecycle. +//! +//! These exercise the *feature-neutral* surface added by PR16 commit 2: one +//! [`ExecutionScope`] owning one resource registry and one operation registry, +//! typed generational handles, exact-once close, bounded admission, direct +//! typed cancellation, reset/drop cleanup and the slim first-reason run flag. +//! +//! Only the public, host-agnostic API is used here; constructor-dependent +//! internals (handle encoding, type mismatch through a crate-private +//! constructor) are covered by unit tests inside the crate modules. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +use vm::execution_scope::{ExecutionScope, ExecutionScopeError, ScopeCloseOutcome, ScopeState}; +use vm::operation::driver::{HostOperation, OperationOutcome, OperationSpec}; +use vm::operation::error::{OperationErrorCode, OperationResult}; +use vm::operation::{OperationCancelReason, OperationRegistry}; +use vm::resource::ResourceCloseReason; +use vm::resource::ResourceTable; +use vm::resource::close::{CloseProgress, HostResource}; +use vm::resource::error::{ResourceErrorCode, ResourceResult}; + +// ---------------------------------------------------------------- helpers + +fn cx() -> Context<'static> { + Context::from_waker(Waker::noop()) +} + +/// Minimal sync resource that counts close cycles. +#[derive(Debug)] +struct Counted(Arc); +impl Counted { + fn new() -> (Self, Arc) { + let closes = Arc::new(AtomicUsize::new(0)); + (Self(closes.clone()), closes) + } +} +impl HostResource for Counted { + fn begin_close(&mut self, _reason: ResourceCloseReason) -> ResourceResult { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(CloseProgress::Ready) + } +} + +/// Driver that completes immediately. +struct DoneDriver; +impl HostOperation for DoneDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Ok(()) + } + + fn is_quiescent(&self) -> bool { + true + } +} + +/// Driver that stays pending until released, recording every cancel. +struct PendingDriver { + release: Arc>, + cancels: Arc>>, +} +impl HostOperation for PendingDriver { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + if *self.release.lock().unwrap() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancels.lock().unwrap().push(reason); + Ok(()) + } + + fn is_quiescent(&self) -> bool { + *self.release.lock().unwrap() + } +} + +/// Driver that reports a terminal cancellation before its background worker +/// has finished. The registry must wait for `done` before releasing the slot. +struct CancelAwareWorker { + cancelled: Arc, + done: Arc, + quiescence_waker: Arc>>, +} + +impl HostOperation for CancelAwareWorker { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + if self.cancelled.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + self.cancelled.store(true, Ordering::SeqCst); + Ok(()) + } + + fn is_quiescent(&self) -> bool { + self.done.load(Ordering::SeqCst) + } + + fn register_quiescence_waker(&mut self, cx: &Context<'_>) { + *self.quiescence_waker.lock().unwrap() = Some(cx.waker().clone()); + } +} + +// ------------------------------------------------------------------ scope + +#[test] +fn scope_begins_active_and_exposes_registries() { + let scope = ExecutionScope::new().expect("scope"); + assert!(scope.is_active()); + assert!(!scope.is_closing()); + assert!(!scope.is_quiescent()); + assert_eq!(scope.state(), ScopeState::Active); + assert_eq!(scope.resources().len(), 0); + assert!(scope.resources().is_empty()); + assert!(scope.operations().is_empty()); + assert!(scope.terminal().is_none()); + assert!(scope.close_reason().is_none()); +} + +#[test] +fn close_is_first_reason_wins_and_rejects_conflict() { + let mut scope = ExecutionScope::new().expect("scope"); + // First transition succeeds. + assert!( + scope + .begin_close(ResourceCloseReason::Requested) + .expect("first close must begin") + ); + assert!(scope.is_closing()); + assert_eq!(scope.close_reason(), Some(ResourceCloseReason::Requested)); + // Repeat with the bound reason is a no-op. + assert!( + !scope + .begin_close(ResourceCloseReason::Requested) + .expect("repeat with same reason is idempotent") + ); + // A conflicting reason is rejected and the first reason preserved. + let error = scope + .begin_close(ResourceCloseReason::Deadline) + .expect_err("conflicting reason must be rejected"); + let ExecutionScopeError::CloseAlreadyInProgress { current, requested } = error else { + panic!("expected CloseAlreadyInProgress, got {error:?}"); + }; + assert_eq!(current, Some(ResourceCloseReason::Requested)); + assert_eq!(requested, ResourceCloseReason::Deadline); + assert_eq!(scope.close_reason(), Some(ResourceCloseReason::Requested)); +} + +#[test] +fn closed_scope_rejects_new_inserts() { + let mut scope = ExecutionScope::new().expect("scope"); + scope + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + let (res, _) = Counted::new(); + let error = scope + .push_resource(res) + .expect_err("closed scope rejects push"); + assert_eq!(error, ExecutionScopeError::ScopeClosing); + assert!( + scope + .start_operation(OperationSpec::new(DoneDriver)) + .is_err() + ); +} + +#[test] +fn empty_scope_quiesces_cleanly() { + let mut scope = ExecutionScope::new().expect("scope"); + scope + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + match scope.poll_close(&mut cx()) { + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => {} + other => panic!("expected clean quiescence, got {other:?}"), + } + assert!(scope.is_quiescent()); + assert_eq!(scope.state(), ScopeState::Quiescent); + // Idempotent terminal read. + match scope.poll_close(&mut cx()) { + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => {} + other => panic!("terminal poll must be idempotent, got {other:?}"), + } +} + +#[test] +fn poll_close_stays_pending_until_operation_worker_quiesces() { + let mut scope = ExecutionScope::new().expect("scope"); + let cancels = Arc::new(Mutex::new(Vec::new())); + let release = Arc::new(Mutex::new(false)); + scope + .start_operation(OperationSpec::new(PendingDriver { + release: Arc::clone(&release), + cancels: Arc::clone(&cancels), + })) + .expect("start"); + scope + .begin_close(ResourceCloseReason::Deadline) + .expect("close"); + + // The pending operation blocks quiescence; poll_close must keep returning + // Pending (the cancel is recorded but the worker has not quiesced). + assert!(matches!(scope.poll_close(&mut cx()), Poll::Pending)); + assert_eq!( + cancels.lock().unwrap()[..], + [OperationCancelReason::Deadline] + ); + assert!(scope.is_closing()); + + // Release the worker; the next poll drives the terminal slot and quiesces. + *release.lock().unwrap() = true; + let mut quiesced = false; + for _ in 0..4 { + if let Poll::Ready(Ok(outcome)) = scope.poll_close(&mut cx()) { + match outcome { + ScopeCloseOutcome::Success => { + quiesced = true; + break; + } + other => panic!("expected clean quiescence, got {other:?}"), + } + } + } + assert!(quiesced, "scope must quiesce after the worker releases"); + assert!(scope.is_quiescent()); +} + +#[test] +fn canceled_terminal_operation_waits_for_worker_before_cleanup() { + let mut scope = ExecutionScope::new().expect("scope"); + let (resource, closes) = Counted::new(); + scope.push_resource(resource).expect("resource"); + let cancelled = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicBool::new(false)); + let quiescence_waker = Arc::new(Mutex::new(None)); + let operation = scope + .start_operation(OperationSpec::new(CancelAwareWorker { + cancelled: Arc::clone(&cancelled), + done: Arc::clone(&done), + quiescence_waker: Arc::clone(&quiescence_waker), + })) + .expect("start"); + scope + .begin_close(ResourceCloseReason::VmReset) + .expect("close"); + + assert!(matches!(scope.poll_close(&mut cx()), Poll::Pending)); + assert!(!scope.is_quiescent()); + assert_eq!(closes.load(Ordering::SeqCst), 0); + assert!(scope.operations().status(operation).is_ok()); + + done.store(true, Ordering::SeqCst); + if let Some(waker) = quiescence_waker.lock().unwrap().take() { + waker.wake(); + } + assert!(matches!( + scope.poll_close(&mut cx()), + Poll::Ready(Ok(ScopeCloseOutcome::Success)) + )); + assert_eq!(closes.load(Ordering::SeqCst), 1); + assert!(scope.operations().status(operation).is_err()); +} + +// ------------------------------------------------------------------ resources + +#[test] +fn push_and_close_round_trip_a_typed_resource() { + let mut table = ResourceTable::new().expect("table"); + let (res, closes) = Counted::new(); + let token = table.push(res).expect("push"); + assert_eq!(table.len(), 1); + table + .begin_close(token, ResourceCloseReason::Requested) + .expect("begin_close"); + assert_eq!(closes.load(Ordering::SeqCst), 1); + assert_eq!(table.len(), 0); + // Re-close of the same token is an exact-once no-op (already closed). + let error = table + .begin_close(token, ResourceCloseReason::Requested) + .expect_err("already closed"); + assert_eq!(error.code(), ResourceErrorCode::ResourceAlreadyClosed); + assert_eq!(closes.load(Ordering::SeqCst), 1); +} + +#[test] +fn resource_close_is_exact_once_through_scope_close() { + let mut scope = ExecutionScope::new().expect("scope"); + let (res, closes) = Counted::new(); + let token = scope.push_resource(res).expect("push"); + scope + .begin_close(ResourceCloseReason::VmReset) + .expect("close"); + match scope.poll_close(&mut cx()) { + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => {} + other => panic!("expected clean quiescence, got {other:?}"), + } + assert_eq!(closes.load(Ordering::SeqCst), 1); + // The handle is now closed: closing it again is rejected with the precise + // closed-state error (distinct from a stale handle after slot reuse). + let error = scope + .close_resource::(token.handle(), ResourceCloseReason::Requested) + .expect_err("closed handle rejected"); + assert!(matches!( + error, + ExecutionScopeError::Resource(ref resource_error) + if resource_error.code() == ResourceErrorCode::ResourceAlreadyClosed + )); +} + +#[test] +fn handle_from_other_scope_is_rejected_cross_vm() { + let mut scope_a = ExecutionScope::new().expect("scope a"); + let mut scope_b = ExecutionScope::new().expect("scope b"); + let (res, _) = Counted::new(); + let token = scope_a.push_resource(res).expect("push into a"); + let foreign = token.handle(); + let error = scope_b + .close_resource::(foreign, ResourceCloseReason::Requested) + .expect_err("foreign handle must be rejected"); + match error { + ExecutionScopeError::Resource(resource_error) => { + assert_eq!( + resource_error.code(), + ResourceErrorCode::ResourceHandleWrongTable + ); + } + other => panic!("expected resource wrong-table error, got {other:?}"), + } +} + +// ------------------------------------------------------------------ operations + +#[test] +fn operation_direct_cancellation_is_typed_and_once() { + let mut scope = ExecutionScope::new().expect("scope"); + let cancels = Arc::new(Mutex::new(Vec::new())); + let id = scope + .start_operation(OperationSpec::new(PendingDriver { + release: Arc::new(Mutex::new(false)), + cancels: Arc::clone(&cancels), + })) + .expect("start"); + + assert!( + scope + .cancel_operation(id, OperationCancelReason::Requested) + .expect("cancel must succeed") + ); + assert_eq!( + cancels.lock().unwrap()[..], + [OperationCancelReason::Requested] + ); + // Second cancel on the now-terminal operation is a no-op (false). + assert!( + !scope + .cancel_operation(id, OperationCancelReason::Requested) + .expect("terminal cancel returns false") + ); + assert_eq!(cancels.lock().unwrap().len(), 1); +} + +#[test] +fn abort_releases_slot_and_stales_id() { + let mut scope = ExecutionScope::new().expect("scope"); + let id = scope + .start_operation(OperationSpec::new(DoneDriver)) + .expect("start"); + assert!( + scope + .abort_operation(id, OperationCancelReason::VmReset) + .expect("abort") + ); + assert_eq!( + scope.operations().status(id).expect_err("stale").code(), + OperationErrorCode::OperationStale + ); +} + +#[test] +fn take_outcome_delivers_terminal_exactly_once() { + let mut scope = ExecutionScope::new().expect("scope"); + // Pending operation has no terminal outcome yet. + let id = scope + .start_operation(OperationSpec::new(DoneDriver)) + .expect("start"); + assert_eq!( + scope + .take_operation_outcome(id) + .expect_err("pending has no outcome") + .into_operation_error() + .expect("pending maps to an operation error") + .code(), + OperationErrorCode::OperationPending + ); + // Complete out-of-band then consume exactly once. + assert!(scope.complete_operation(id).expect("complete")); + assert_eq!( + scope.take_operation_outcome(id).expect("terminal outcome"), + OperationOutcome::Completed + ); + // Consumed: id is stale now. + assert_eq!( + scope + .operations() + .status(id) + .expect_err("stale after take") + .code(), + OperationErrorCode::OperationStale + ); +} + +#[test] +fn bounded_admission_rejects_over_capacity() { + let mut registry = OperationRegistry::with_limit(2).expect("registry"); + let _a = registry + .start(OperationSpec::new(DoneDriver)) + .expect("first"); + let _b = registry + .start(OperationSpec::new(DoneDriver)) + .expect("second"); + let error = registry + .start(OperationSpec::new(DoneDriver)) + .expect_err("capacity reached"); + assert_eq!(error.code(), OperationErrorCode::OperationLimitExceeded); + // Consuming a terminal restores capacity. + let _ = registry.poll(_a, &mut cx()); + let _c = registry + .start(OperationSpec::new(DoneDriver)) + .expect("capacity restored"); + assert_eq!(registry.active_count(), 2); +} + +#[test] +fn resource_bounded_admission_rejects_over_capacity() { + let mut table = ResourceTable::with_limit(2).expect("table"); + let (a, _) = Counted::new(); + let (b, _) = Counted::new(); + let a_token = table.push(a).expect("first"); + let b_token = table.push(b).expect("second"); + let (c, _) = Counted::new(); + let error = table.push(c).expect_err("capacity reached"); + assert_eq!(error.code(), ResourceErrorCode::ResourceLimitExceeded); + + // Closing restores capacity: both slots return to the vacant pool. + let _ = table.begin_close(a_token, ResourceCloseReason::Requested); + let _ = table.begin_close(b_token, ResourceCloseReason::Requested); + assert_eq!(table.len(), 0); + + // Reuse stays bounded: many close/re-push cycles never exceed the + // configured capacity (the same physical slots are recycled). + for _ in 0..4 { + let (res, _) = Counted::new(); + let token = table.push(res).expect("re-push after close"); + let _ = table.begin_close(token, ResourceCloseReason::Requested); + } + assert!(table.len() <= 2); +} + +// ------------------------------------------------------------ scope state arena + +/// Payload that counts drops, used to prove the typed scope-state arena is +/// cleared exactly once at terminal resource close. +#[derive(Debug)] +struct DropCounting(Arc); + +impl DropCounting { + fn new_counted() -> (Self, Arc) { + let drops = Arc::new(AtomicUsize::new(0)); + (Self(Arc::clone(&drops)), drops) + } +} + +impl Drop for DropCounting { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +impl HostResource for DropCounting {} + +#[test] +fn scope_state_is_lazy_and_one_instance_per_type() { + let mut scope = ExecutionScope::new().expect("scope"); + // First access creates the single typed instance. + { + let state = scope + .scope_state_or_insert_with(|| 5u32) + .expect("active scope accepts state"); + *state += 1; + } + assert_eq!(scope.scope_state::(), Some(&6)); + // Repeated access reuses the same instance; the init closure never runs + // again (a fresh init would have produced 99, not 6). + { + let state = scope + .scope_state_or_insert_with(|| 99u32) + .expect("active scope accepts state"); + *state += 1; + } + assert_eq!(scope.scope_state::(), Some(&7)); + // A different type gets its own independent arena entry. + { + let state = scope + .scope_state_or_insert_with(|| String::from("x")) + .expect("active scope accepts state"); + state.push('!'); + } + assert_eq!(scope.scope_state::(), Some(&7)); + assert_eq!(scope.scope_state::(), Some(&String::from("x!"))); +} + +#[test] +fn scope_state_is_dropped_exactly_once_at_terminal_close() { + let mut scope = ExecutionScope::new().expect("scope"); + let drops = Arc::new(AtomicUsize::new(0)); + { + let _state = scope + .scope_state_or_insert_with(|| DropCounting(Arc::clone(&drops))) + .expect("active scope accepts state"); + } + assert_eq!( + drops.load(Ordering::SeqCst), + 0, + "no drop while scope is active" + ); + scope + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + match scope.poll_close(&mut cx()) { + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => {} + other => panic!("expected clean quiescence, got {other:?}"), + } + assert!(scope.is_quiescent()); + // The terminal resource close cleared the typed scope-state arena exactly + // once; the payload drop ran once, never twice. + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "scope state must be dropped exactly once at terminal close" + ); +} + +#[test] +fn scope_state_read_mut_and_take_are_typed() { + let mut scope = ExecutionScope::new().expect("scope"); + // Nothing yet for the type. + assert!(scope.scope_state::().is_none()); + assert!(scope.scope_state_mut::().is_none()); + assert!(scope.take_scope_state::().is_none()); + + scope + .scope_state_or_insert_with(|| 10u64) + .expect("active scope accepts state"); + + // Immutable read. + assert_eq!(scope.scope_state::(), Some(&10)); + // Mutable read mutates in place. + if let Some(value) = scope.scope_state_mut::() { + *value += 5; + } + assert_eq!(scope.scope_state::(), Some(&15)); + + // take removes eagerly (before terminal close) and returns the value. + assert_eq!(scope.take_scope_state::(), Some(15)); + assert!(scope.scope_state::().is_none()); + assert!(scope.take_scope_state::().is_none()); +} + +#[test] +fn scope_state_is_isolated_per_scope() { + let mut scope_a = ExecutionScope::new().expect("scope a"); + let mut scope_b = ExecutionScope::new().expect("scope b"); + scope_a + .scope_state_or_insert_with(|| 1u32) + .expect("a accepts state"); + scope_b + .scope_state_or_insert_with(|| 2u32) + .expect("b accepts state"); + // Same type, distinct arenas: each scope sees only its own entry. + assert_eq!(scope_a.scope_state::(), Some(&1)); + assert_eq!(scope_b.scope_state::(), Some(&2)); + // Mutating one never leaks into the other. + if let Some(value) = scope_a.scope_state_mut::() { + *value += 100; + } + assert_eq!(scope_a.scope_state::(), Some(&101)); + assert_eq!(scope_b.scope_state::(), Some(&2)); +} + +#[test] +fn scope_state_insert_is_rejected_after_close() { + let mut scope = ExecutionScope::new().expect("scope"); + scope + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + assert_eq!( + scope + .scope_state_or_insert_with(|| 0u32) + .expect_err("closing scope rejects new state"), + ExecutionScopeError::ScopeClosing + ); +} + +#[test] +fn scope_state_never_collides_with_an_ordinary_resource_of_same_type() { + let mut scope = ExecutionScope::new().expect("scope"); + // An ordinary resource whose payload is DropCounting lives in a slot... + let (res, _) = DropCounting::new_counted(); + let token = scope.push_resource(res).expect("resource"); + // ...while a scope-state entry with the SAME payload type lives in the + // separate arena-owned map keyed by TypeId. They must not collide. + let (state, drops) = DropCounting::new_counted(); + scope + .scope_state_or_insert_with(move || state) + .expect("active scope accepts state"); + assert!(scope.scope_state::().is_some()); + assert!(scope.resources().get(&token).is_ok()); + + // The handle slot count is independent of the state-arena entry count. + assert_eq!(scope.resources().len(), 1); + // Dropping the scope state (take) does not touch the ordinary resource. + scope + .take_scope_state::() + .expect("took state"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(scope.resources().get(&token).is_ok()); +} + +#[test] +fn fresh_scope_has_no_state_after_same_type_scope_closed_or_dropped() { + // The typed scope-state arena is per-scope, not process-global: closing or + // dropping a scope that held a `T` entry must not leak any `T` entry into + // a later, freshly constructed scope. + { + // Path 1: the previous scope was closed to quiescence, which cleared + // its arena at terminal resource close. + let mut closed = ExecutionScope::new().expect("scope"); + closed + .scope_state_or_insert_with(|| 7u32) + .expect("active scope accepts state"); + assert_eq!(closed.scope_state::(), Some(&7)); + closed + .begin_close(ResourceCloseReason::Requested) + .expect("close"); + match closed.poll_close(&mut cx()) { + Poll::Ready(Ok(ScopeCloseOutcome::Success)) => {} + other => panic!("expected clean quiescence, got {other:?}"), + } + assert!( + closed.scope_state::().is_none(), + "terminal close must clear the closed scope's own arena" + ); + + let mut fresh = ExecutionScope::new().expect("fresh scope"); + assert!( + fresh.scope_state::().is_none(), + "new scope must start without state for a type a closed scope held" + ); + // The fresh scope initializes its own independent entry. + fresh + .scope_state_or_insert_with(|| 11u32) + .expect("fresh scope accepts state"); + assert_eq!(fresh.scope_state::(), Some(&11)); + } + { + // Path 2: the previous scope was dropped without an explicit close. + let mut dropped = ExecutionScope::new().expect("scope"); + dropped + .scope_state_or_insert_with(|| 3u64) + .expect("active scope accepts state"); + assert_eq!(dropped.scope_state::(), Some(&3)); + drop(dropped); + + let fresh = ExecutionScope::new().expect("fresh scope"); + assert!( + fresh.scope_state::().is_none(), + "new scope must start without state for a type a dropped scope held" + ); + } +} + +#[test] +fn scope_state_insert_while_closing_skips_initializer_and_leaves_nothing() { + let mut scope = ExecutionScope::new().expect("scope"); + scope + .begin_close(ResourceCloseReason::Deadline) + .expect("close"); + assert!(scope.is_closing()); + + let initialized = Arc::new(AtomicBool::new(false)); + let error = scope + .scope_state_or_insert_with(|| { + initialized.store(true, Ordering::SeqCst); + 42u128 + }) + .expect_err("closing scope rejects new state"); + assert_eq!(error, ExecutionScopeError::ScopeClosing); + + // The admission guard must reject the insert BEFORE the initializer runs: + // a rejected insert must not construct, store, or drop a payload. + assert!( + !initialized.load(Ordering::SeqCst), + "initializer must not run for a rejected insert" + ); + // ...and must leave no state entry (arena map keyed by TypeId) and no + // ordinary resource slot/index behind. + assert!(scope.scope_state::().is_none()); + assert_eq!(scope.resources().len(), 0); +} diff --git a/tests/vm_tests.rs b/tests/vm_tests.rs index 47a3a179..2b8d5af8 100644 --- a/tests/vm_tests.rs +++ b/tests/vm_tests.rs @@ -4,6 +4,9 @@ #[path = "vm/drop_contract_tests.rs"] mod drop_contract_tests; +#[path = "vm/execution_scope_tests.rs"] +mod execution_scope_tests; + #[path = "vm/functional_parity_tests.rs"] mod functional_parity_tests;