From b24400744a9d22366f37ab993b9909f84b095085 Mon Sep 17 00:00:00 2001 From: fffonion Date: Wed, 12 Aug 2026 13:39:00 +0800 Subject: [PATCH] feat(compiler): allocate locals per script frame --- docs/callable-runtime.md | 35 +- pd-vm-nostd/README.md | 2 +- pd-vm-nostd/src/error.rs | 46 + pd-vm-nostd/src/program.rs | 8 + pd-vm-nostd/src/vm.rs | 145 +- pd-vm-nostd/src/vmbc.rs | 67 +- pd-vm-nostd/tests/call_script_tests.rs | 365 +++ pd-vm-nostd/tests/embedded_vmbc.rs | 16 +- plans/2026-08-09_architecture-plan-index.md | 10 + ...2026-08-11_frame-aware-local-allocation.md | 610 +++++ src/assembler.rs | 22 + src/bytecode.rs | 42 +- src/compiler/codegen.rs | 265 +- src/compiler/lifetime/liveness.rs | 430 +-- src/compiler/lifetime/mod.rs | 29 + src/compiler/materialization.rs | 2311 +++++++++++++++++ src/compiler/mod.rs | 26 + src/compiler/pipeline.rs | 299 ++- src/vm/aot/artifact.rs | 83 +- src/vm/aot/cfg.rs | 26 +- src/vm/aot/compile.rs | 66 +- src/vm/aot/ir.rs | 19 + src/vm/aot/ssa.rs | 70 +- src/vm/jit/inline.rs | 60 +- src/vm/jit/ir.rs | 21 +- src/vm/jit/native/lower.rs | 136 +- src/vm/jit/recorder.rs | 568 +++- src/vm/jit/region.rs | 3 +- src/vm/jit/trace.rs | 89 +- src/vm/mod.rs | 124 +- src/vm/native/bridge.rs | 93 +- src/vm/native/codegen.rs | 23 + src/vm/native/mod.rs | 30 +- src/vm/tests.rs | 56 + src/vmbc.rs | 82 +- tests/common/mod.rs | 4 + tests/compiler/compiler_common_tests.rs | 743 ++++++ tests/compiler/compiler_rustscript_tests.rs | 11 +- tests/compiler/diagnostics_tests.rs | 94 + tests/compiler/module_import_tests.rs | 105 + .../modules/frame_local_dispatch/chain_0.rss | 79 + .../modules/frame_local_dispatch/chain_1.rss | 79 + .../modules/frame_local_dispatch/chain_2.rss | 79 + .../modules/frame_local_dispatch/chain_3.rss | 79 + .../modules/frame_local_dispatch/chain_4.rss | 64 + .../modules/frame_local_dispatch/main.rss | 45 + tests/jit/jit_tests.rs | 1096 ++++++++ tests/vm/call_script_tests.rs | 423 +++ tests/vm/drop_contract_tests.rs | 138 + tests/vm_tests.rs | 3 + tests/wire/wire_tests.rs | 268 +- 51 files changed, 8965 insertions(+), 622 deletions(-) create mode 100644 pd-vm-nostd/tests/call_script_tests.rs create mode 100644 plans/2026-08-11_frame-aware-local-allocation.md create mode 100644 src/compiler/materialization.rs create mode 100644 tests/fixtures/modules/frame_local_dispatch/chain_0.rss create mode 100644 tests/fixtures/modules/frame_local_dispatch/chain_1.rss create mode 100644 tests/fixtures/modules/frame_local_dispatch/chain_2.rss create mode 100644 tests/fixtures/modules/frame_local_dispatch/chain_3.rss create mode 100644 tests/fixtures/modules/frame_local_dispatch/chain_4.rss create mode 100644 tests/fixtures/modules/frame_local_dispatch/main.rss create mode 100644 tests/vm/call_script_tests.rs diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 66aa519f..217c3668 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -1,15 +1,24 @@ # Script call frames and callable values -RustScript bytecode format version 11 (VMBC v11) introduces runtime script call frames, first-class callable values, and the static builtin ID catalog. +RustScript bytecode format version 12 (VMBC v12) carries runtime script call frames, first-class callable values, the static builtin ID catalog, and the direct script-call opcode. Version 11 introduced frames, callable values, and the static catalog; version 12 adds `callscript` for statically resolved named calls. ## Bytecode contract - `call ` remains the direct host/builtin operation; the `u16` operand is an explicit static builtin call index from the catalog (or a host-import slot) — never a count-derived offset. - `callvalue ` consumes a stack segment in `callee, arg0, ..., argN` order. +- `callscript ` calls a statically resolved named script function by prototype ID. It consumes only `argc` arguments; no callable value is taken from the stack, so environment-free named functions can be called without a hidden callable local. - callable environments are bound through the internal builtin call path; callable creation adds no bytecode opcode. - `ret` completes the active script frame. A nested frame leaves exactly one result at the caller segment base, using `null` when the body produced no value. Root `ret` keeps the historical program-result stack behavior. -VMBC v11 is a hard format boundary. Decoders reject all earlier versions (v10 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 7, ABI 7) use their corresponding bumped versions and include callable metadata in cache identity. +### Call ownership + +The three call opcodes differ in who owns the callee and what the frame must provide: + +- `call` — the callee is owned by the static builtin catalog (or the host-import slot). The frame contributes only `argc` arguments; there is no callable value anywhere in the program. +- `callvalue` — the callee is a `Value::Callable` owned by the caller operand stack at the call site, and remains the caller's responsibility after the call. This path carries environments, closures, and any callable whose identity or capture state is runtime-valued. +- `callscript` — the callee is owned by program callable metadata (the prototype table). The frame contributes only `argc` arguments and no callable value, but unlike `call` the callee is a script function rather than a builtin, so the call enters a new script frame with its own local base. + +VMBC v12 is a hard format boundary. Decoders reject all earlier versions (v11 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 8, ABI 8) use their corresponding bumped versions and include callable metadata in cache identity. ## Static builtin IDs @@ -18,7 +27,7 @@ Every VM-visible builtin (ordinary, internal, and special-call) has one explicit - **Immutable explicit IDs.** IDs never change once assigned. Adding or reordering catalog entries never renumbers existing entries; new builtins take the next free ID in their documented block (extension `0x0000..=0xFF8F` for future builtins and host imports, special-call `0xFF90..=0xFFA1`, ordinary `0xFFA2..=0xFFFF`). The reserved sentinel gap `0xFF90..=0xFF92` stays unassigned. - **Build-time validation.** The build fails on duplicate IDs, duplicate source names, duplicate Rust variants, out-of-block IDs, class/gate inconsistencies, a discovered runtime callable without an explicit ID, or a catalog entry without a runtime callable. - **Shared std/no-std IDs.** `pd-vm-nostd` dispatches on the same static indices through the checked-in generated mirror `pd-vm-nostd/src/generated_builtin_ids.rs`; the workspace test `static_builtin_ids_are_frozen` fails when the mirror drifts from the catalog. -- **One-time format break.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11). Older VMBC versions are rejected, never decoded. +- **Format breaks are permanent.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11); the `callscript` opcode break bumped both to v12. Versions below the current format are rejected, never decoded. ## Runtime model @@ -29,10 +38,24 @@ Each script invocation owns: - frame-local count; - active prototype and callable identity. -Arguments, captures, named callable bindings, and the self binding are installed before control moves to the function entry. Recursive calls therefore allocate independent local storage and are limited to 1,024 script frames. +Arguments, captures, hidden callable bindings for materialized named functions, and the self binding are installed before control moves to the function entry. Recursive calls therefore allocate independent local storage and are limited to 1,024 script frames. Branches are restricted to the active function region. Validation rejects cross-region targets before execution, and the interpreter repeats the check at runtime. +## Frame-local allocation and callable materialization + +Each script invocation frame is an independent local-address space with its own `local_base`. Locals that are live at the same time inside one frame interfere and receive distinct relative slot numbers; locals that belong to different frames never interfere and may reuse the same relative slot number, because the runtime frame bases already separate them. A statically resolved named call keeps the caller's argument slots and post-call values live in the caller frame, while the callee body's locals are analyzed inside the callee frame. + +Named functions receive a hidden callable slot only when runtime `Value::Callable` identity is actually required: + +- the function is exported under the `ExportedCallable { local_slot }` contract; +- the function is referenced as a value (stored, passed, or returned); +- the function captures an environment; +- a dynamic call site can target the function (invoked slot or argument flow into an invoked parameter); +- the function's runtime self identity is required by a capturing or dynamic recursion path. + +Functions that only receive plain direct calls — including non-capturing direct recursion — are lowered through `callscript` by prototype ID and consume no hidden callable local. The compiler reports the aggregate frame-local count (data slots plus materialized callable slots) in `FrameLocalLimitExceeded` diagnostics, so overflow reports real counts instead of a sentinel. Genuine same-frame pressure beyond 256 simultaneous locals keeps failing until wide local bytecode lands. + ## Callable identity and lifetime A callable contains its prototype ID, kind, and optional environment. The Program/Store owns the callable lifetime. Capture-free function items compare by prototype identity inside that Program; closures compare by runtime environment identity. Callable constants are forbidden; functions are initialized from Program metadata and closures are materialized at their declaration site. @@ -57,8 +80,8 @@ Polling drives execution and provides backpressure: at most one event item is bu ## Optimized backends -Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. +Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding, native frame dispatch for `callvalue`, and prototype-direct native dispatch for `callscript`. Script-frame entry and return preserve frame-relative locals and typed continuations. ## Embedded runtime -`pd-vm-nostd` decodes the same VMBC v11 callable metadata and executes callable binding, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. +`pd-vm-nostd` decodes the same VMBC v12 callable metadata and executes callable binding, `callvalue`, `callscript`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. diff --git a/pd-vm-nostd/README.md b/pd-vm-nostd/README.md index 59a30900..5361e776 100644 --- a/pd-vm-nostd/README.md +++ b/pd-vm-nostd/README.md @@ -6,7 +6,7 @@ compiler, parser, CLI, debugger, JIT/AOT backends, filesystem support, and opera ## Runtime surface -- VMBC v11 decoding with script-call and callable metadata +- VMBC v12 decoding with environment-free `CallScript` direct script calls alongside dynamic callable calls - stack, local, and recursive script-frame execution for direct bytecode opcodes - instruction fuel with pause/resume support - synchronous named host bindings and dynamic host dispatch diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index 35caec48..fe5c3210 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -14,6 +14,12 @@ pub enum VmError { InvalidCall(u16), InvalidCallable, InvalidCallablePrototype(u32), + /// Frame metadata (root binding slots, parameter or capture slots) + /// does not match the script frame layout. + InvalidFrameState(&'static str), + /// `CallScript` targeted a prototype whose capture layout requires an + /// environment; a static script call can never supply one. + CallScriptRequiresEnvironment(u32), CallStackOverflow, InvalidCallStackLimit(usize), InvalidCallArity { @@ -52,6 +58,11 @@ impl fmt::Display for VmError { Self::InvalidCallablePrototype(index) => { write!(f, "invalid callable prototype: {index}") } + Self::InvalidFrameState(detail) => write!(f, "invalid frame state: {detail}"), + Self::CallScriptRequiresEnvironment(prototype_id) => write!( + f, + "callscript prototype {prototype_id} requires a callable environment" + ), Self::CallStackOverflow => f.write_str("script call stack overflow"), Self::InvalidCallStackLimit(limit) => { write!( @@ -96,6 +107,22 @@ pub enum WireError { InvalidDebugFlag(u8), InvalidValueType(u8), InvalidCaptureBindingMode(u8), + /// `CallScript` referenced a prototype id that is out of range or does + /// not target a script function. + InvalidCallScriptTarget { + prototype_id: u32, + }, + /// `CallScript` declared an argc that disagrees with the prototype arity. + InvalidCallScriptArity { + prototype_id: u32, + expected: u8, + got: u8, + }, + /// An instruction operand is truncated by the end of the code blob. + TruncatedOperand { + opcode: u8, + expected_bytes: usize, + }, InvalidUtf8, LengthTooLarge(&'static str, usize), SchemaTooDeep, @@ -119,6 +146,25 @@ impl fmt::Display for WireError { Self::InvalidCaptureBindingMode(value) => { write!(f, "invalid capture binding mode: {value}") } + Self::InvalidCallScriptTarget { prototype_id } => write!( + f, + "callscript prototype {prototype_id} does not target a script function" + ), + Self::InvalidCallScriptArity { + prototype_id, + expected, + got, + } => write!( + f, + "callscript prototype {prototype_id} arity mismatch: expected {expected}, got {got}" + ), + Self::TruncatedOperand { + opcode, + expected_bytes, + } => write!( + f, + "truncated operand for opcode {opcode:#04x}: expected {expected_bytes} bytes" + ), Self::InvalidUtf8 => f.write_str("invalid UTF-8 in VMBC string"), Self::LengthTooLarge(field, length) => { write!(f, "{field} length is too large: {length}") diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index 5c511b0a..f0984807 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -230,6 +230,12 @@ pub enum OpCode { Not = 0x17, Lshr = 0x18, CallValue = 0x19, + /// Static direct script-function call: `prototype_id:u32 LE, argc:u8`. + /// + /// Mirrors the std ISA contract (opcode 0x1A, five operand bytes); the + /// decoder validates the target prototype and arity against the callable + /// metadata so an environment-free script call is a supported operation. + CallScript = 0x1A, } impl OpCode { @@ -238,6 +244,7 @@ impl OpCode { Self::Ldc | Self::Br | Self::Brfalse => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, + Self::CallScript => 5, _ => 0, } } @@ -274,6 +281,7 @@ impl TryFrom for OpCode { 0x17 => Ok(Self::Not), 0x18 => Ok(Self::Lshr), 0x19 => Ok(Self::CallValue), + 0x1a => Ok(Self::CallScript), _ => Err(()), } } diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 0f7f73a4..35426100 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -238,21 +238,26 @@ impl Vm { Value::Null, ); for binding in self.program.root_callable_bindings() { - if let Some(binding_prototype) = self + // Mirror the interpreter's `enter_script_frame`: every + // root binding must fit the callee frame and reference a + // known prototype; a malformed program errors instead of + // silently skipping the slot. + let binding_prototype = self .program .callable_prototypes() .get(binding.prototype_id as usize) - { - let slot = binding.local_slot as usize; - if slot < prototype.frame_local_count { - self.locals[local_base + slot] = - Value::Callable(Rc::new(CallableValue { - prototype_id: binding.prototype_id, - kind: binding_prototype.kind, - env: None, - })); - } + .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; + let slot = binding.local_slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidFrameState( + "root callable binding is outside the script frame", + )); } + self.locals[local_base + slot] = Value::Callable(Rc::new(CallableValue { + prototype_id: binding.prototype_id, + kind: binding_prototype.kind, + env: None, + })); } for (slot, value) in inherited { if slot < prototype.frame_local_count { @@ -299,6 +304,119 @@ impl Vm { } } + /// Execute a static `CallScript(prototype_id, argc)` instruction. + /// + /// Mirrors [`Self::call_value`] but resolves the callee from the static + /// prototype metadata: no runtime callable value exists, so + /// capture- or self-requiring prototypes fail with + /// [`VmError::CallScriptRequiresEnvironment`] and host-import prototypes + /// are never routed to the host path. + fn call_script(&mut self, prototype_id: u32, argc: u8) -> VmResult<()> { + // Mirror the interpreter contract: the operand underflow check comes + // before any prototype-driven rejection so a malformed call with a + // short stack reports `StackUnderflow`, not an environment error. + let operand_count = argc as usize; + if self.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let prototype = self + .program + .callable_prototypes() + .get(prototype_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + // A static script call can never supply a callable environment. + if !prototype.capture_slots.is_empty() || prototype.self_slot.is_some() { + return Err(VmError::CallScriptRequiresEnvironment(prototype_id)); + } + let stack_base = self.stack.len() - operand_count; + let operands = self.stack.split_off(stack_base); + if prototype.arity != argc || prototype.parameter_slots.len() != operands.len() { + return Err(VmError::InvalidCallArity { + import: String::from("script call"), + expected: prototype.arity, + got: argc, + }); + } + let CallableTarget::ScriptFunction(function_id) = prototype.target else { + // `CallScript` is a static script-function call and must never + // route a host-import prototype to the host path. + return Err(VmError::InvalidCallablePrototype(prototype_id)); + }; + if self.frames.len() >= self.max_script_call_depth { + return Err(VmError::CallStackOverflow); + } + let function = self + .program + .script_functions() + .get(function_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + let inherited = { + let base = self.active_local_base(); + let count = self + .frames + .last() + .map_or(self.locals.len(), |frame| frame.local_count); + self.locals[base..base.saturating_add(count)] + .iter() + .enumerate() + .filter_map(|(slot, value)| match value { + Value::Callable(_) => Some((slot, value.clone())), + _ => None, + }) + .collect::>() + }; + let local_base = self.locals.len(); + self.locals.resize( + local_base.saturating_add(prototype.frame_local_count), + Value::Null, + ); + for binding in self.program.root_callable_bindings() { + // Mirror the interpreter's `enter_script_frame`: every root + // binding must fit the callee frame and reference a known + // prototype; a malformed program errors instead of silently + // skipping the slot. + let binding_prototype = self + .program + .callable_prototypes() + .get(binding.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; + let slot = binding.local_slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidFrameState( + "root callable binding is outside the script frame", + )); + } + self.locals[local_base + slot] = Value::Callable(Rc::new(CallableValue { + prototype_id: binding.prototype_id, + kind: binding_prototype.kind, + env: None, + })); + } + for (slot, value) in inherited { + if slot < prototype.frame_local_count { + self.locals[local_base + slot] = value; + } + } + for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { + let slot = *slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidCallablePrototype(prototype_id)); + } + self.locals[local_base + slot] = argument; + } + self.frames.push(ExecutionFrame { + return_ip: self.ip, + operand_stack_base: stack_base, + local_base, + local_count: prototype.frame_local_count, + prototype_id, + }); + self.ip = function.entry_ip as usize; + Ok(()) + } + fn return_from_frame(&mut self) -> VmResult { let Some(frame) = self.frames.pop() else { return Ok(false); @@ -409,6 +527,11 @@ impl Vm { let arity = self.read_u8()?; self.call_value(arity)?; } + OpCode::CallScript => { + let prototype_id = self.read_u32()?; + let arity = self.read_u8()?; + self.call_script(prototype_id, arity)?; + } OpCode::Shl => { let rhs = self.pop_shift()?; diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 77d8b9b2..d3ede42a 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -3,12 +3,12 @@ use alloc::vec::Vec; use super::{ CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, - FunctionRegion, HostImport, Program, RootCallableBinding, ScriptFunction, Value, ValueType, - WireError, + FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, Value, + ValueType, WireError, }; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; const MAX_CONSTANT_DEPTH: usize = 64; @@ -57,7 +57,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { + if version != VERSION_V12 { return Err(WireError::UnsupportedVersion(version)); } let flags = cursor.read_u16()?; @@ -96,6 +96,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { if !cursor.is_empty() { return Err(WireError::TrailingBytes); } + validate_call_script_operands(&code, &callable_prototypes)?; let program = Program::new(constants, code, imports); let program = match encoded_local_count { @@ -349,6 +350,64 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result Result<(), WireError> { + let mut ip = 0usize; + while ip < code.len() { + let opcode_byte = code[ip]; + let Ok(opcode) = OpCode::try_from(opcode_byte) else { + // Unknown opcodes surface as `InvalidOpcode` at run time; skip a + // single byte so the walk stays aligned for the opcodes that + // follow. + ip = ip.saturating_add(1); + continue; + }; + let operand_len = opcode.operand_len(); + let operands_start = ip.saturating_add(1); + let operands_end = operands_start + .checked_add(operand_len) + .ok_or(WireError::LengthTooLarge("code", code.len()))?; + if operands_end > code.len() { + return Err(WireError::TruncatedOperand { + opcode: opcode_byte, + expected_bytes: operand_len, + }); + } + if matches!(opcode, OpCode::CallScript) { + let prototype_id = u32::from_le_bytes( + code[operands_start..operands_start + 4] + .try_into() + .expect("operand width validated above"), + ); + let argc = code[operands_start + 4]; + let Some(prototype) = prototypes.get(prototype_id as usize) else { + return Err(WireError::InvalidCallScriptTarget { prototype_id }); + }; + // `CallScript` is a static script-function call: a host-import + // prototype must never be routed to the host path, so reject it + // deterministically here as well. + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(WireError::InvalidCallScriptTarget { prototype_id }); + } + if argc != prototype.arity { + return Err(WireError::InvalidCallScriptArity { + prototype_id, + expected: prototype.arity, + got: argc, + }); + } + } + ip = operands_end; + } + Ok(()) +} + fn skip_debug_info(cursor: &mut Cursor<'_>) -> Result<(), WireError> { match cursor.read_u8()? { 0 => Ok(()), diff --git a/pd-vm-nostd/tests/call_script_tests.rs b/pd-vm-nostd/tests/call_script_tests.rs new file mode 100644 index 00000000..3154a4df --- /dev/null +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -0,0 +1,365 @@ +//! Milestone 7: `CallScript` parity in the no_std + alloc runtime. +//! +//! Programs are produced by the std VMBC encoder (V12) or hand-built with +//! `CallScript` bytecode (0x1A, prototype_id:u32 LE, argc:u8) so the wire +//! contract and the typed validation/execution failures are pinned +//! independently of the compiler. + +use pd_vm_nostd::{ + Value as EmbeddedValue, Vm as EmbeddedVm, VmError, VmStatus as EmbeddedVmStatus, WireError, + decode_program, +}; +use vm::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, OpCode, Program, + ScriptFunction, compile_source, encode_program, +}; + +/// Build a main-crate program whose root code is `code` with one script +/// function (entry at `code.len()`) described by `prototype`. +fn raw_call_script_program(code: Vec, prototype: CallablePrototype) -> Program { + let function_entry = code.len() as u32; + let function_end = function_entry + 1; + let mut code = code; + code.push(OpCode::Ret as u8); + Program::new(Vec::new(), code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![prototype], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +fn function_item_prototype( + target: CallableTarget, + arity: u8, + capture_slots: Vec, + self_slot: Option, +) -> CallablePrototype { + CallablePrototype { + kind: CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + } +} + +#[test] +fn call_script_executes_direct_call() { + let compiled = compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("direct call program should encode as VMBC v12"); + let program = decode_program(&bytes).expect("no-std should decode VMBC v12"); + assert!( + program.code().windows(2).any(|pair| pair[0] == 0x1A), + "compiler output should contain CallScript" + ); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("direct call should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(42)]); +} + +#[test] +fn call_script_executes_nested_direct_calls() { + let compiled = compile_source( + "fn add2(value: int) -> int { value + 2 } fn add5(value: int) -> int { add2(value) + 3 } add5(0);", + ) + .expect("nested call source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("nested call program should encode"); + let program = decode_program(&bytes).expect("no-std should decode nested call program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("nested direct calls should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(5)]); +} + +#[test] +fn call_script_recursion() { + let compiled = compile_source( + "fn fact(n: int) -> int { if n <= 1 => { 1 } else => { n * fact(n - 1) } } fact(10);", + ) + .expect("recursion source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("recursion program should encode"); + let program = decode_program(&bytes).expect("no-std should decode recursion program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("recursion should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(3_628_800)]); +} + +#[test] +fn call_script_preserves_callee_local_isolation() { + let compiled = compile_source( + "fn set(value: int) -> int { let mut y = value; y = y + 1; y } let mut z = 10; z = set(z); z;", + ) + .expect("local isolation source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("local isolation program should encode"); + let program = decode_program(&bytes).expect("no-std should decode local isolation program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("local isolation should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(11)]); +} + +#[test] +fn call_script_depth_limit() { + let compiled = + compile_source("fn f() -> int { f() } f();").expect("recursion source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("recursion program should encode"); + let program = decode_program(&bytes).expect("no-std should decode recursion program"); + + let mut vm = EmbeddedVm::new(program); + vm.set_max_script_call_depth(4) + .expect("depth limit should be accepted"); + let err = vm + .run() + .expect_err("unbounded recursion should hit the depth limit"); + assert!( + matches!(err, VmError::CallStackOverflow), + "expected CallStackOverflow, got {err:?}" + ); +} + +#[test] +fn call_script_capture_prototype_fails_typed() { + // A script prototype that requires captures is wire-valid (runtime + // concern), but `CallScript` can never supply an environment: the no-std + // runtime must fail with the same typed error as the std interpreter. + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, vec![0], None), + ); + let bytes = encode_program(&program).expect("capture program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode capture program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("capture-requiring prototype should fail through CallScript"); + assert!( + matches!(err, VmError::CallScriptRequiresEnvironment(0)), + "expected CallScriptRequiresEnvironment(0), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_out_of_range_prototype() { + let code = vec![OpCode::CallScript as u8, 7, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("out-of-range prototype should be rejected"); + assert!( + matches!(err, WireError::InvalidCallScriptTarget { prototype_id: 7 }), + "expected InvalidCallScriptTarget(7), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_arity_mismatch() { + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 1]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("arity mismatch should be rejected"); + assert!( + matches!( + err, + WireError::InvalidCallScriptArity { + prototype_id: 0, + expected: 0, + got: 1 + } + ), + "expected InvalidCallScriptArity, got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_host_import_prototype() { + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::HostImport(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("host-import target should be rejected"); + assert!( + matches!(err, WireError::InvalidCallScriptTarget { prototype_id: 0 }), + "expected InvalidCallScriptTarget(0), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_truncated_operands() { + // 0x1A followed by only two operand bytes. + let code = vec![OpCode::CallScript as u8, 1, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 1, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("truncated CallScript operands should be rejected"); + assert!( + matches!(err, WireError::TruncatedOperand { .. }), + "expected TruncatedOperand, got {err:?}" + ); +} + +#[test] +fn call_script_rejects_v11_wire_version() { + let compiled = compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("direct call program should encode"); + bytes[4..6].copy_from_slice(&11u16.to_le_bytes()); + let err = decode_program(&bytes).expect_err("VMBC v11 must be rejected"); + assert!( + matches!(err, WireError::UnsupportedVersion(11)), + "expected UnsupportedVersion(11), got {err:?}" + ); +} + +#[test] +fn call_script_fuel_interruption() { + let compiled = compile_source( + "fn bump(value: int) -> int { value + 1 } let mut i = 0; let mut total = 0; while i < 1000 { total = bump(total); i = i + 1; } total;", + ) + .expect("fuel source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("fuel program should encode"); + let program = decode_program(&bytes).expect("no-std should decode fuel program"); + + let mut vm = EmbeddedVm::new(program); + vm.set_fuel(64); + let err = vm + .run() + .expect_err("fuel should interrupt the direct call loop"); + assert!( + matches!(err, VmError::OutOfFuel { .. }), + "expected OutOfFuel, got {err:?}" + ); +} + +#[test] +fn call_script_stack_underflow_precedes_environment_rejection() { + // The interpreter checks operand underflow before prototype-driven + // rejection: a malformed `CallScript` with argc > 0 and an empty stack + // must report `StackUnderflow`, not `CallScriptRequiresEnvironment`, + // even when the target prototype requires captures. + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 1]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 1, vec![0], None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("short operand stack must fail with StackUnderflow"); + assert!( + matches!(err, VmError::StackUnderflow), + "expected StackUnderflow, got {err:?}" + ); +} + +#[test] +fn call_script_binding_outside_frame_fails_typed() { + // A root callable binding whose slot lies outside the callee frame is + // invalid frame state: the no-std runtime must report the same typed + // error as the std interpreter instead of silently skipping the slot. + let mut code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let function_entry = code.len() as u32; + code.push(OpCode::Ret as u8); + let function_end = code.len() as u32; + let program = Program::new(Vec::new(), code) + .with_local_count(2) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![function_item_prototype( + CallableTarget::ScriptFunction(0), + 0, + Vec::new(), + None, + )], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![vm::RootCallableBinding { + local_slot: 1, + prototype_id: 0, + }], + ); + let bytes = encode_program(&program).expect("program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("out-of-frame root binding must fail on frame entry"); + assert!( + matches!( + err, + VmError::InvalidFrameState("root callable binding is outside the script frame") + ), + "expected InvalidFrameState for the out-of-frame binding, got {err:?}" + ); +} diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 8bd2b5eb..1b29744f 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -29,9 +29,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v11() { +fn embedded_decoder_reads_host_generated_v12() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v12"); assert_eq!( program.code(), @@ -182,7 +182,13 @@ fn embedded_runtime_executes_compiler_generated_capturing_callable() { } #[test] -fn removed_callable_creation_opcode_is_rejected() { - assert!(OpCode::try_from(0x1a).is_err()); - assert!(EmbeddedOpCode::try_from(0x1a).is_err()); +fn call_script_opcode_is_0x1a_in_both_crates() { + // The historical callable-creation opcode slot (0x1A) is now the static + // script-call opcode in both the std and embedded opcode tables. + assert_eq!(OpCode::try_from(0x1a), Ok(OpCode::CallScript)); + assert_eq!( + EmbeddedOpCode::try_from(0x1a), + Ok(EmbeddedOpCode::CallScript) + ); + assert!(EmbeddedOpCode::try_from(0x7f).is_err()); } diff --git a/plans/2026-08-09_architecture-plan-index.md b/plans/2026-08-09_architecture-plan-index.md index 03a289f6..732dca44 100644 --- a/plans/2026-08-09_architecture-plan-index.md +++ b/plans/2026-08-09_architecture-plan-index.md @@ -15,6 +15,7 @@ | Wire/ABI | Count-derived builtin indices change existing call IDs | `2026-08-09_static-builtin-id.md` | none | | Compiler correctness | UTF-8 rewrite, parent-path normalization, nested source diagnostics, public entry-point parity | `2026-08-09_nested-module-correctness.md` | none | | Compiler architecture | Text rewrite, synthetic preludes, flat global symbols, basename identity, source ownership | `2026-08-09_semantic-module-system.md` | nested correctness | +| Compiler/runtime correctness | Named script calls count separate callee frames as caller-live locals; aggregate overflow reports a sentinel; direct-only functions reserve hidden callable slots | `2026-08-11_frame-aware-local-allocation.md` | real script call frames; semantic module identities for cross-module use classification | | VM ownership | Monolithic VM mixes engine/program/instance/run/host state | `2026-08-09_vm-runtime-decomposition.md` | static IDs | | Host lifecycle | Generic resource/operation code unused; IO/HTTP/SQLite duplicate lifecycle/cancellation | `2026-08-09_unified-host-lifecycle.md` | VM decomposition | | Execution contract | Return/event ambiguity, buffered-only events, string errors, fragmented terminal state | `2026-08-09_run-outcome-event-error-contract.md` | RunContext; host lifecycle for final cancellation integration | @@ -68,6 +69,15 @@ Can run in parallel after their dependencies: 3. Backend semantic convergence. 4. Agent run lifecycle and durable state integration. +### Local-slot correction route + +This route is ordered independently of the host-lifecycle waves: + +1. Execute `2026-08-11_frame-aware-local-allocation.md` first. Land named-call frame-aware liveness and real-count diagnostics before direct-call/selective callable materialization. +2. Verify the storage-shaped dispatch fixture stays below the short-bytecode ceiling and the true same-frame 257-local control still fails for the declared capacity reason. + +Exit gate: separate frames reuse relative slots, direct-only named functions do not require hidden callable locals, and aggregate diagnostics report actual counts. + ## Scope boundary - No plan adds compatibility decoding for pre-static-ID VMBC. diff --git a/plans/2026-08-11_frame-aware-local-allocation.md b/plans/2026-08-11_frame-aware-local-allocation.md new file mode 100644 index 00000000..92ed2b7c --- /dev/null +++ b/plans/2026-08-11_frame-aware-local-allocation.md @@ -0,0 +1,610 @@ +# Frame-Aware Local Allocation and Callable Slot Reduction Implementation Plan + +**Goal:** Correct named-script-call liveness for real call frames, report aggregate frame-local overflow with real counts, and stop reserving one hidden local for every directly called named function. + +**Architecture:** Treat each script invocation frame as a separate local-address space. Caller liveness includes argument evaluation and values used after the call, while callee body locals are analyzed inside the callee frame. Keep conservative ownership rules for dynamic callables and captures until their frame/environment behavior is proved separately. After correctness is established, lower eligible named calls through an additive direct-script-call opcode and materialize `Value::Callable` locals only where runtime identity or an environment is required. + +**Tech Stack:** Rust 2024, RustScript frontend/IR/lifetime passes, bytecode assembler, interpreter, VMBC, Trace JIT, AOT, debugger, wasm analyzer, and `pd-vm-nostd`. + +--- + +## Status and dependency + +- Status: proposed. +- Execute this plan before `2026-08-11_wide-local-bytecode.md`. +- The frame-aware correction and diagnostic milestones are correctness work and may land before direct-call optimization. +- The direct-call milestone depends on frame-aware allocation being verified independently. +- The agent storage program is a regression shape, not an owner of compiler policy. Core tests must use self-contained RustScript fixtures or generated sources. + +## Observed baseline + +The production-shaped storage source currently merges to: + +```text +frontend locals: 205 +named script function implementations: 77 +``` + +The existing compiler produces: + +```text +31 dispatch branches: 178 data slots + 77 callable slots = 255 frame slots +32 dispatch branches: 181 data slots + 77 callable slots = 258 frame slots +``` + +`Compiler::prepare_named_callables` rejects the second program because `Ldloc` and `Stloc` still use one-byte operands. It reports `LocalSlot::MAX` (`65535`) as a sentinel, hiding the actual total of 258. + +A diagnostic experiment showed: + +```text +remove only caller-live += callee-footprint: 19 data + 77 callable = 96 +also remove named-call cross-frame edges: 6 data + 77 callable = 83 +``` + +The experiment passed the complete `compiler_tests` and `vm_tests` integration targets, but it is evidence only. This plan requires dedicated ownership, capture, drop, recursion, module, JIT, AOT, and no-std coverage before changing production behavior. + +## Root cause to preserve in tests + +Real script frames were introduced in commit `0a8652c`. Runtime entry allocates a new `local_base`, resizes the locals array for the callee frame, copies parameters/captures into that frame, and restores the caller frame on return. + +The lifetime pipeline still carries two pre-frame assumptions for known named calls: + +1. `LivenessRewriter::add_expr_uses` unions the callee's transitive footprint into the caller live set. +2. `LocalSlotAllocator::collect_expr_constraints` adds caller/callee cross-live graph edges. + +Those assumptions make locals from separate frames interfere. Recursive call footprints can become `full_footprint`, magnifying the same problem. A separate cost comes from `prepare_named_callables`: every function implementation gets one hidden callable local, every call loads that local, and every runtime frame initializes all root callable bindings. + +## Semantic invariants + +The implementation must preserve all of the following: + +- Each execution frame has an independent relative local namespace and `local_base`. +- Arguments are fully evaluated in the caller before callee frame entry. +- Caller locals used after return remain live across the call. +- Callee locals are dropped according to the callee frame's own control flow. +- Copy, move, borrow, and borrow-mut capture cells retain existing alias and drop behavior. +- Capturing named functions and dynamic local callables retain environment identity. +- Recursion retains depth checks, self identity where required, and frame isolation. +- Exported callables remain resolvable through the public embedding API. +- Programs with more than 256 genuinely simultaneous locals in one frame continue to fail until the wide-local plan lands. +- Interpreter, JIT, AOT, no-std, VMBC, debugger, REPL, and wasm consumers remain behaviorally aligned. + +## Scope boundary + +### In scope + +- Frame-aware named-call liveness and interference constraints. +- Focused cleanup of named-call-only transitive footprint machinery after all callers are audited. +- Accurate aggregate frame-local diagnostics. +- Selective materialization of hidden named callable slots. +- An additive direct script call opcode for non-capturing statically resolved named calls. +- Required wire, interpreter, JIT, AOT, debugger, wasm, and no-std support for that opcode. +- Regression fixtures representing large dispatch across one file and semantic modules. +- Documentation of local allocation, frame ownership, and callable materialization. + +### Out of scope + +- `Ldloc` or `Stloc` operands wider than `u8`. +- More than 65,536 local slots. +- New language syntax. +- Changes to invocation item streams, host capabilities, agent lifecycle, or storage schemas. +- Rewriting dynamic `LocalCall` or closure-call conservatism without separate capture evidence. +- Inlining named functions as a substitute for frame-aware allocation. +- Agent-specific compiler exceptions, source-name checks, or compatibility wrappers. +- A generic register allocator or SSA rewrite. + +## Target architecture + +### Local pressure + +For each named script function and the root body: + +```text +same-frame live ranges -> one interference graph domain +caller arguments -> caller domain +callee body locals -> callee domain +capture environment -> explicit capture cells and capture metadata +``` + +The compiler may still assign one shared relative slot number to locals from different functions because runtime frame bases separate them. + +### Named call lowering + +Use two paths: + +```text +Direct non-capturing named call + arguments + CallScript(prototype_id, argc) + +Runtime-valued call + load/materialize Value::Callable + arguments + CallValue(argc) +``` + +A function requires callable materialization when any of these holds: + +- it is exported under the current `ExportedCallable { local_slot }` contract; +- it is referenced as a value; +- it captures an environment; +- a dynamic call site can target it; +- its runtime self identity is required by a capturing/dynamic recursion path. + +Plain direct calls, including non-capturing direct recursion, use `CallScript` and do not require a hidden local. + +## Milestone 1: Lock the failure shape and true-limit control + +**Objective:** Add RED tests that distinguish cross-frame over-allocation from genuine same-frame local pressure. + +**Files:** + +- Modify: `tests/compiler/compiler_common_tests.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` +- Modify: `tests/compiler/module_import_tests.rs` +- Create: `tests/fixtures/modules/frame_local_dispatch/main.rss` +- Create: module files under `tests/fixtures/modules/frame_local_dispatch/` + +**Steps:** + +1. Add a generated single-file program with roughly 77 named functions and a 32-branch dispatcher. Each callee owns two parameters and one local. The dispatcher must return a deterministic scalar so the test executes after compilation. +2. Add a semantic-module fixture with the same call graph split across multiple modules. This proves the result is independent of linker local-base assignment and import discovery order. +3. Assert both programs compile and execute under the interpreter. On the current tree, record RED as aggregate `LocalSlotOverflow(LocalSlot::MAX)`. +4. Assert `Program.local_count` stays bounded by per-frame pressure plus currently required callable slots. Before direct-call optimization, use an upper bound such as 100 rather than an exact coloring number. +5. Keep and strengthen the existing generated test with 257 values simultaneously live in one function. Assert it fails with a real frame-limit error after Milestone 3. +6. Add a 256-live boundary case that compiles and reads the highest short slot. + +**Focused RED command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_dispatch +``` + +Expected before implementation: the 32-branch cases fail at compile time; the true 257-live control continues to fail for the intended reason. + +**Commit after GREEN:** + +```bash +git add tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/compiler/module_import_tests.rs \ + tests/fixtures/modules/frame_local_dispatch/ +git commit -m "test(compiler): cover frame-local pressure across named calls" +``` + +Do not commit a branch on which the new success cases remain failing. + +## Milestone 2: Make named-call liveness frame-aware + +**Objective:** Stop treating a statically resolved callee body as live inside its caller frame. + +**Files:** + +- Modify: `src/compiler/lifetime/liveness.rs` +- Modify if comments/contracts require it: `src/compiler/lifetime/availability.rs` +- Test: `tests/compiler/compiler_common_tests.rs` +- Test: `tests/compiler/compiler_rustscript_tests.rs` +- Test: `tests/vm/drop_contract_tests.rs` through the `vm_tests` target + +**Steps:** + +1. In `LivenessRewriter::add_expr_uses`, classify `Expr::Call(index, ..., args)` using `function_impls.contains_key(index)`. +2. For a known named script call, add only caller-side argument uses. Do not union `function_footprint(index)` into the caller live set. +3. Continue analyzing each `FunctionImpl` body independently through `rewrite_function_impl` and `function_body_live_out`. +4. Preserve persistent capture sources and captured slots through `persistent_capture_slots`, function declaration rewriting, and closure environment metadata. +5. Leave `Expr::LocalCall` and unknown dynamic targets on their existing conservative path in this milestone. +6. Add runtime tests where: + - a caller local remains usable after a callee returns; + - caller and callee locals are assigned the same relative slot but retain different values; + - copy/move/borrow/borrow-mut captures observe existing behavior; + - direct and mutual recursion retain independent frame values; + - cancellation/yield in a callee resumes with caller locals intact; + - drop-contract counts do not double-drop or omit caller/callee heap values. +7. Run the large dispatch tests and inspect `Program.local_count`; expected data pressure should fall from 181 to approximately 19 even before removing the allocator cross-edge. +8. Remove `LivenessRewriter` footprint fields or methods only when repository search proves they have no remaining closure/dynamic-call use. Do not delete shared capture analysis merely because named calls no longer need it. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests named_function +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test vm_tests drop_contract +``` + +Each command must select at least one test. + +**Commit:** + +```bash +git add src/compiler/lifetime/liveness.rs \ + src/compiler/lifetime/availability.rs \ + tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/vm/drop_contract_tests.rs +git commit -m "fix(compiler): make named-call liveness frame-aware" +``` + +## Milestone 3: Remove stale named-call interference edges + +**Objective:** Make graph coloring match the runtime frame boundary without weakening dynamic callable safety. + +**Files:** + +- Modify: `src/compiler/lifetime/liveness.rs` +- Test: compiler and module tests from Milestone 1 + +**Steps:** + +1. In `LocalSlotAllocator::collect_expr_constraints`, stop adding caller-live versus callee-footprint edges for statically resolved named calls. +2. Continue collecting constraints for argument expressions in the caller. +3. Continue building cliques and def/live edges within each function body. +4. Preserve explicit capture-copy interference and persistent capture slots. +5. Preserve conservative `LocalCall` and closure-call handling until a separate test proves a narrower rule. +6. Add a test that two functions with disjoint execution frames reuse the same relative slots even when one calls the other recursively. +7. Add a negative control where two values truly overlap within one function and must receive different slots. +8. Assert the large storage-shaped fixture falls to a small per-frame data count, expected near 6. Avoid making the exact greedy-color result a public contract; assert a conservative upper bound such as 20. +9. Search for remaining named-call transitive-footprint use. Retain any helper still required by dynamic closures or capture lifetime analysis. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_slot_reuse +``` + +**Commit:** + +```bash +git add src/compiler/lifetime/liveness.rs \ + tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/compiler/module_import_tests.rs +git commit -m "fix(compiler): isolate named-call interference by frame" +``` + +## Milestone 4: Report real aggregate frame-local pressure + +**Objective:** Replace the `65535` sentinel diagnostic with actionable counts while preserving individual operand overflow errors. + +**Files:** + +- Modify: `src/compiler/mod.rs` +- Modify: `src/compiler/codegen.rs` +- Modify: `src/compiler/diagnostics.rs` +- Modify: `tests/common/mod.rs` +- Modify: `tests/compiler/diagnostics_tests.rs` +- Modify: `pd-vm-wasm/src/analyzer.rs` if it matches compiler errors exhaustively + +**Design:** + +Add a dedicated error shape, for example: + +```rust +CompileError::FrameLocalLimitExceeded { + data_slots: usize, + callable_slots: usize, + total_slots: usize, + max_slots: usize, +} +``` + +Keep `CompileError::LocalSlotOverflow(slot)` for a concrete local index that cannot be emitted by the current ISA. + +**Steps:** + +1. In `prepare_named_callables`, compute `data_slots`, materialized callable slots, total, and maximum before mutating callable metadata. +2. Return `FrameLocalLimitExceeded` with actual values when the aggregate exceeds 256. +3. Remove uses of `LocalSlot::MAX` as an aggregate sentinel. +4. Render a diagnostic such as: + +```text +frame requires 258 local slots (181 data + 77 callable); short bytecode supports 256 +``` + +5. Preserve source diagnostics where an owning function/source span is available; otherwise use a program-level diagnostic without pretending slot 65535 exists. +6. Update wasm/common error mappings and snapshot tests. +7. Add direct tests for arithmetic overflow separately from the ordinary 256 ceiling. +8. Confirm no error text parser is introduced in core or downstream tests. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_limit_diagnostic +``` + +**Commit:** + +```bash +git add src/compiler/mod.rs src/compiler/codegen.rs src/compiler/diagnostics.rs \ + tests/common/mod.rs tests/compiler/diagnostics_tests.rs \ + pd-vm-wasm/src/analyzer.rs +git commit -m "fix(compiler): report actual frame-local pressure" +``` + +## Milestone 5: Classify named functions that require runtime materialization + +**Objective:** Separate statically called script functions from named functions that need a `Value::Callable` identity. + +**Files:** + +- Modify: `src/compiler/ir.rs` +- Modify: `src/compiler/parser/` consumers only if use metadata is unavailable after parsing +- Modify: `src/compiler/linker.rs` +- Modify: `src/compiler/pipeline.rs` +- Modify: `src/compiler/codegen.rs` +- Test: compiler, module, exported callable, capture, and recursion tests + +**Steps:** + +1. Add a compiler-internal use classification keyed by function index or `SymbolId`. Suggested facts: + +```text +called_directly +referenced_as_value +exported +captures_environment +dynamic_target_required +runtime_self_required +``` + +2. Collect value references from `Expr::FunctionRef`, exported declarations, closure/capture metadata, and any dynamic callable assignment. +3. Carry the classification through semantic module merge using resolved function identity, never source names. +4. Define `requires_callable_slot` from the semantic facts. Do not infer it from call count or source spelling. +5. Keep one prototype for every script function. Allocate a hidden local only for `requires_callable_slot` functions. +6. Keep exported functions materialized under the current `ExportedCallable { local_slot }` API in this plan. On-demand exported prototype creation is a later API proposal. +7. Ensure capturing named functions retain declaration-time environment construction and cannot use an environment-free direct call path. +8. Add tests for: + - direct-only helper: no hidden slot; + - exported direct helper: hidden slot retained; + - function stored in a local/map/array: hidden slot retained; + - capturing named function: hidden slot and environment retained; + - non-capturing direct recursion: no hidden slot required after `CallScript` exists; + - capturing recursion: runtime self slot retained; + - same names in different modules: classification follows `SymbolId`. + +This milestone may introduce metadata and tests before changing call lowering, but every commit must remain executable. If the compiler cannot omit a slot until `CallScript` exists, keep allocation behavior unchanged and commit only the classification plus passing tests of the classification helper. + +**Commit:** + +```bash +git add src/compiler/ir.rs src/compiler/parser/ src/compiler/linker.rs \ + src/compiler/pipeline.rs src/compiler/codegen.rs \ + tests/compiler/ tests/wire/ +git commit -m "refactor(compiler): classify named callable materialization" +``` + +Stage explicit files rather than directory globs during execution. + +## Milestone 6: Add direct script-call bytecode and interpreter support + +**Objective:** Call eligible environment-free named functions by prototype ID without loading a hidden callable local. + +**Files:** + +- Modify: `src/bytecode.rs` +- Modify: `src/assembler.rs` +- Modify: `src/compiler/codegen.rs` +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/instance.rs` only if shared frame-entry logic belongs there +- Modify: `src/vmbc.rs` +- Modify: `src/debug_info.rs` +- Modify: debug-related bytecode scanners in `src/vmbc.rs` and `src/cli.rs` +- Modify: `src/cli.rs` +- Modify: `pd-vm-wasm/src/analyzer.rs` +- Test: compiler, VM, wire, debugger, REPL, wasm tests + +**ISA contract:** + +Reserve the next opcode after `CallValue`: + +```text +CallScript = 0x1A +operands = prototype_id:u32 little-endian, argc:u8 +length = 5 bytes +``` + +Do not repurpose `Call`, which remains host/builtin-only. Do not change `CallValue`. + +**Steps:** + +1. Add assembler emission and decoding for `CallScript`. +2. Add a shared VM helper that enters a script frame from `(prototype_id, optional environment, operands, continuation)`. +3. Route `CallValue` and `CallScript` through that helper. `CallScript` supplies no callable environment and must reject prototypes that require captures. +4. Preserve arity validation, depth limits, interruption ticks, return continuation, stack cleanup, and drop-contract behavior. +5. In `compile_function_call`, emit argument expressions followed by `CallScript` for an eligible function. Keep `Ldloc + CallValue` for materialized or capturing functions. +6. Omit hidden slots and root bindings for direct-only functions. Recompute `frame_local_count` from data slots plus materialized callable slots. +7. Retain `ExportedCallable.local_slot` and public `resolve_exported_callable` behavior. +8. Update opcode walkers, jump/region validation, debugger stepping, disassembly, and wasm analysis for the five-byte operand. +9. Bump VMBC from V11 to V12 and regenerate wire fixtures. The V12 decoder must reject malformed/truncated `CallScript` operands deterministically. Compatibility policy must follow the current release plan; do not silently decode V11 bytes under changed semantics. +10. Add tests proving: + - direct-only functions emit `CallScript` and no `Ldloc` target slot; + - environment-bearing functions still emit `CallValue`; + - direct recursion and mutual recursion work; + - exported function resolution remains unchanged; + - malformed prototype IDs and arity produce typed VM errors; + - short programs with no script calls retain unchanged instruction bytes apart from the declared VMBC version policy. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests direct_script_call +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test vm_tests call_script +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test wire_tests call_script +``` + +**Commit:** + +```bash +git add src/bytecode.rs src/assembler.rs src/compiler/codegen.rs \ + src/vm/mod.rs src/vm/instance.rs src/vmbc.rs src/debug_info.rs \ + src/cli.rs pd-vm-wasm/src/analyzer.rs tests/ +git commit -m "feat(vm): call static script functions by prototype" +``` + +During execution replace directory entries with the exact changed paths. + +## Milestone 7: Add no-std, Trace JIT, native, and AOT parity + +**Objective:** Ensure `CallScript` is a supported semantic operation across every execution backend. + +**Files:** + +- Modify: `pd-vm-nostd/src/program.rs` +- Modify: `pd-vm-nostd/src/vm.rs` +- Modify: `pd-vm-nostd/src/vmbc.rs` +- Modify: `pd-vm-nostd/src/error.rs` if new validation errors are needed +- Modify: `src/vm/jit/trace.rs` +- Modify: `src/vm/jit/recorder.rs` +- Modify: `src/vm/jit/inline.rs` +- Modify: `src/vm/jit/native/` lowering and runtime files +- Modify: `src/vm/native/bridge.rs` +- Modify: `src/vm/aot/cfg.rs` +- Modify: `src/vm/aot/ir.rs` +- Modify: `src/vm/aot/ssa.rs` +- Modify: `src/vm/aot/compile.rs` +- Modify: `src/vm/aot/runtime.rs` +- Modify: `src/vm/aot/artifact.rs` +- Test: no-std, JIT, native bridge, AOT, artifact, and backend parity tests + +**Steps:** + +1. Mirror the opcode and operand layout in no-std. Share semantic expectations through fixtures, not source-code dependency. +2. Teach every bytecode scanner to skip five operand bytes and preserve call boundaries. +3. Record `CallScript` with prototype identity and call-site IP. Reuse existing callable-frame JIT machinery instead of creating a second frame model. +4. Update inline candidate analysis to resolve the direct prototype without reading a source callable local. +5. Lower native/AOT direct calls through existing environment-free function-item paths. +6. Preserve deopt/exit restoration, frame keys, stack bases, return IPs, interruption checks, and typed call errors. +7. Increment `NATIVE_CALLABLE_ABI_VERSION`, AOT artifact version/ABI, and program/native cache revisions exactly once for the new opcode semantics. +8. Add parity tests for interpreter, trace/native JIT, AOT, and no-std using direct calls, recursion, nested calls, cancellation checks, and failure exits. Prefix the AOT-focused test names with `aot_call_script` so the verification command selects them explicitly. +9. Confirm JIT/AOT never reinterpret `CallScript` as host `Call` or dynamic `CallValue`. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test jit_tests call_script --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked aot_call_script --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked -p pd-vm-nostd call_script +``` + +**Commit:** + +```bash +git add pd-vm-nostd/src/ src/vm/jit/ src/vm/native/ src/vm/aot/ \ + tests/jit/ tests/wire/ pd-vm-nostd/tests/ +git commit -m "feat(vm): support direct script calls across backends" +``` + +Stage exact files during execution. + +## Milestone 8: Documentation and cleanup + +**Objective:** Remove obsolete assumptions and document the final pressure model. + +**Files:** + +- Modify: compiler lifetime module documentation +- Modify: callable/runtime documentation under `docs/` +- Modify: VMBC/opcode documentation +- Modify: `README.md` only if it states the old all-function hidden-slot model + +**Steps:** + +1. Document same-frame interference and cross-frame reuse. +2. Document which named functions receive hidden callable slots. +3. Document `Call`, `CallValue`, and `CallScript` ownership separately. +4. Remove dead named-call transitive-footprint caches and comments only after repository search proves no remaining use. +5. Remove temporary diagnostics and instrumentation. +6. Verify no agent/storage operation names appear in compiler logic. + +**Commit:** + +```bash +git add src/compiler/lifetime/ docs/ README.md +git commit -m "docs(compiler): define frame-local allocation boundaries" +``` + +Stage exact changed files only. + +## Verification matrix + +Use one isolated target directory for every Cargo command: + +```bash +export CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target +``` + +### Focused correctness + +```bash +cargo fmt --all -- --check +cargo test --locked --test compiler_tests frame_local +cargo test --locked --test compiler_tests named_function +cargo test --locked --test compiler_tests closure +cargo test --locked --test vm_tests call_script +cargo test --locked --test vm_tests drop_contract +cargo test --locked --test compiler_tests module_import +cargo test --locked --test wire_tests call_script +``` + +If `module_import_tests` is a module inside `compiler_tests` rather than a standalone Cargo target, run the corresponding `compiler_tests` filter and require at least one selected test. + +### Backend and target parity + +```bash +cargo test --locked --test jit_tests call_script --features cranelift-jit +cargo test --locked aot_call_script --features cranelift-jit +cargo test --locked -p pd-vm-nostd +cargo test --locked -p pd-vm-wasm +``` + +### Full gates + +```bash +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +### Required observations + +- Every filtered command reports at least one selected test. +- The 32-branch single-file and module fixtures compile and execute. +- The 257-simultaneous-local control still fails before wide locals land. +- Aggregate diagnostics print real data/callable/total counts. +- Direct-only named functions do not consume hidden callable slots. +- Exported and captured named functions retain runtime callable identity. +- No backend silently falls back because it cannot decode `CallScript`. +- Worktree is clean after each scoped commit. + +## Stop conditions + +Stop and report the exact blocker before continuing if any of these occurs: + +- Removing named-call footprint propagation breaks capture ownership that cannot be represented through existing frame/cell metadata. +- `CallScript` requires a second call-frame implementation instead of reusing the existing callable entry helper. +- A backend cannot preserve return/deopt/drop/cancellation behavior for direct calls without a private executor or compatibility side channel. +- Selective materialization changes exported callable identity or public embedding behavior without an explicit API decision. +- Intermediate commits cannot pass their focused default-feature gates. + +## Target criteria + +- Known named callees no longer inflate caller live sets with callee body locals. +- Locals from separate script frames can reuse relative slot numbers. +- Dynamic callable and capture paths retain conservative correctness. +- The storage-shaped 32-branch fixture remains below 256 slots without wide bytecode. +- Aggregate overflow reports actual counts, never slot 65535 as a placeholder. +- Direct-only non-capturing functions use `CallScript` and allocate no hidden callable local. +- Functions requiring value identity/environment/export remain materialized and use `CallValue` where appropriate. +- Interpreter, JIT, AOT, no-std, debugger, REPL, wasm, and VMBC agree on direct-call semantics. +- Genuine same-frame pressure beyond 256 remains rejected until the wide-local plan is implemented. diff --git a/src/assembler.rs b/src/assembler.rs index 3fd5572c..fabcf273 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -303,6 +303,11 @@ impl Assembler { self.emit_opcode(OpCode::CallValue); self.emit_u8(argc); } + pub fn call_script(&mut self, prototype_id: u32, argc: u8) { + self.emit_opcode(OpCode::CallScript); + self.emit_u32(prototype_id); + self.emit_u8(argc); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -451,6 +456,11 @@ impl BytecodeBuilder { self.emit_opcode(OpCode::CallValue); self.emit_u8(argc); } + pub fn call_script(&mut self, prototype_id: u32, argc: u8) { + self.emit_opcode(OpCode::CallScript); + self.emit_u32(prototype_id); + self.emit_u8(argc); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -748,6 +758,12 @@ pub fn assemble(source: &str) -> Result { let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; assembler.call_value(argc); } + OpCode::CallScript => { + let prototype_id = + parse_u32(next_token(&mut parts, line_no, "prototype id")?, line_no)?; + let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; + assembler.call_script(prototype_id, argc); + } OpCode::Shl => assembler.shl(), OpCode::Shr => assembler.shr(), OpCode::Lshr => assembler.lshr(), @@ -805,6 +821,12 @@ fn parse_u16(token: &str, line_no: usize) -> Result { message: format!("invalid u16 '{token}'"), }) } +fn parse_u32(token: &str, line_no: usize) -> Result { + token.parse::().map_err(|_| AsmParseError { + line: line_no, + message: format!("invalid u32 '{token}'"), + }) +} fn parse_f64(token: &str, line_no: usize, what: &str) -> Result { token.parse::().map_err(|_| AsmParseError { diff --git a/src/bytecode.rs b/src/bytecode.rs index 95551dff..8ee97807 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -7,8 +7,9 @@ use crate::compiler::TypeSchema; /// Bytecode ABI version used for VM-internal cache identity (JIT trace cache, /// program cache keys). The VMBC wire format version lives in `src/vmbc.rs` -/// (`VERSION_V11`); both were bumped together for the static builtin ID break. -pub const BYTECODE_ABI_VERSION: u16 = 11; +/// (`VERSION_V12`); both were bumped together for the static builtin ID break +/// and again for the direct script-call (`CallScript`) opcode break. +pub const BYTECODE_ABI_VERSION: u16 = 12; pub type SharedString = Arc; pub type SharedBytes = Arc>; @@ -814,6 +815,12 @@ pub enum OpCode { Dup = 0x0E, Ldloc = 0x0F, Stloc = 0x10, + /// Static builtin/host call. Operands: `import:u16` little-endian then + /// `argc:u8` (3 operand bytes). The `u16` operand is an explicit static + /// builtin call index from the catalog (or a host-import slot), never a + /// count-derived offset. Consumes `argc` arguments from the stack; the + /// callee is owned by the builtin catalog, so no callable value exists + /// in the frame. Call = 0x11, Shl = 0x12, Shr = 0x13, @@ -822,7 +829,18 @@ pub enum OpCode { Or = 0x16, Not = 0x17, Lshr = 0x18, + /// Dynamic callable-value call. Operand: `argc:u8` (1 operand byte). + /// Consumes a stack segment in `callee, arg0, ..., argN` order: the + /// callable value (including its environment, if any) is owned by the + /// caller operand stack at the call site and remains the caller's + /// responsibility. CallValue = 0x19, + /// Static script-function call by prototype id. Operands: `prototype_id: + /// u32` little-endian then `argc: u8` (5 operand bytes). The callee is + /// resolved through callable prototype metadata; no callable value is + /// consumed from the stack, so environment-free named functions can be + /// called without a hidden callable local. + CallScript = 0x1A, } impl TryFrom for OpCode { @@ -856,6 +874,7 @@ impl TryFrom for OpCode { x if x == Self::Not as u8 => Ok(Self::Not), x if x == Self::Lshr as u8 => Ok(Self::Lshr), x if x == Self::CallValue as u8 => Ok(Self::CallValue), + x if x == Self::CallScript as u8 => Ok(Self::CallScript), _ => Err(()), } } @@ -886,6 +905,7 @@ impl OpCode { Self::Ldc | Self::Br | Self::Brfalse => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, + Self::CallScript => 5, } } @@ -917,6 +937,7 @@ impl OpCode { OpCode::Not => "not", OpCode::Lshr => "lshr", Self::CallValue => "callvalue", + Self::CallScript => "callscript", } } @@ -948,6 +969,7 @@ impl OpCode { "not" => Some(OpCode::Not), "lshr" => Some(OpCode::Lshr), "callvalue" => Some(OpCode::CallValue), + "callscript" => Some(OpCode::CallScript), _ => None, } } @@ -1073,4 +1095,20 @@ mod tests { assert_eq!(map.remove(&Value::string("a")), Some(Value::Int(2))); assert_eq!(map.len(), 1); } + + #[test] + fn call_script_opcode_contract() { + // ISA contract: CallScript = 0x1A (immediately after CallValue), + // operands prototype_id:u32 LE + argc:u8, 5 operand bytes total. + assert_eq!(OpCode::CallScript as u8, 0x1A); + assert_eq!(OpCode::CallScript as u8, OpCode::CallValue as u8 + 1); + assert_eq!(OpCode::CallScript.operand_len(), 5); + assert_eq!(OpCode::CallScript.mnemonic(), "callscript"); + assert_eq!( + OpCode::parse_mnemonic("callscript"), + Some(OpCode::CallScript) + ); + assert_eq!(OpCode::try_from(0x1A), Ok(OpCode::CallScript)); + assert_eq!(OpCode::CallScript as u8, 0x1A); + } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 3b3fe564..84f3821c 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -11,6 +11,7 @@ use super::ir::{ ClosureExpr, Expr, FunctionDecl, FunctionImpl, LocalSlot, MatchPattern, MatchTypePattern, Stmt, StructDecl, TypeSchema, }; +use super::materialization::CallableUseFacts; use super::{CompileError, TypingMode, typing}; pub struct Compiler { @@ -33,7 +34,21 @@ pub struct Compiler { frame_local_count: usize, function_slots: HashMap, specialized_function_slots: Vec<(u16, Vec, LocalSlot)>, + /// Prototype-only specializations for direct generic calls: the same + /// function target as the base prototype but carrying the instantiated + /// concrete schema. Unlike [`Self::specialized_function_slots`] these + /// allocate no hidden local or root binding, so direct-only generic + /// calls stay slot-free. + specialized_direct_prototypes: Vec<(u16, Vec, u32)>, function_prototype_ids: HashMap, + /// Semantic use classification for every named script function, keyed + /// by resolved flat function index, delivered by the pipeline. Codegen + /// consumes `requires_callable_slot` when counting callable slots and + /// assigning hidden callable locals, so direct-only functions are + /// lowered by `CallScript` with no hidden slot. Direct `Compiler` users + /// (the public API) provide no facts; absent facts conservatively mean + /// full materialization (legacy behavior). + callable_use_facts: HashMap, script_functions: Vec, callable_prototypes: Vec, function_regions: Vec, @@ -82,7 +97,9 @@ impl Compiler { frame_local_count: 0, function_slots: HashMap::new(), specialized_function_slots: Vec::new(), + specialized_direct_prototypes: Vec::new(), function_prototype_ids: HashMap::new(), + callable_use_facts: HashMap::new(), script_functions: Vec::new(), callable_prototypes: Vec::new(), function_regions: Vec::new(), @@ -130,6 +147,13 @@ impl Compiler { self.function_decls = function_decls; } + pub(crate) fn set_callable_use_facts( + &mut self, + callable_use_facts: HashMap, + ) { + self.callable_use_facts = callable_use_facts; + } + pub fn set_struct_schemas(&mut self, struct_schemas: HashMap) { self.struct_schemas = struct_schemas; } @@ -248,17 +272,53 @@ impl Compiler { fn prepare_named_callables(&mut self) -> Result, CompileError> { let mut indices = self.function_impls.keys().copied().collect::>(); indices.sort_unstable(); - self.frame_local_count = self - .root_local_count - .checked_add(indices.len()) - .ok_or(CompileError::LocalSlotOverflow(LocalSlot::MAX))?; - if self.frame_local_count > usize::from(u8::MAX) + 1 { - return Err(CompileError::LocalSlotOverflow(LocalSlot::MAX)); + // Classification facts may be absent for direct `Compiler` users + // (the public API); the conservative default is full + // materialization, which is exactly the allocation performed below + // when no facts are present. The pipeline-delivered facts refine + // this decision: a function that only needs a prototype (direct + // calls, including non-capturing direct recursion) is lowered by + // `CallScript` and gets no hidden callable slot. + // + // Report the real aggregate before mutating callable metadata: data + // slots (compacted root frame) plus one hidden callable slot per + // materialized named function. A saturated add reports the + // saturated total rather than a fabricated slot number. + let data_slots = self.root_local_count; + let callable_slots = indices + .iter() + .filter(|index| { + self.callable_use_facts + .get(index) + .is_none_or(|facts| facts.requires_callable_slot()) + }) + .count(); + let total_slots = data_slots.saturating_add(callable_slots); + let max_slots = usize::from(u8::MAX) + 1; + if total_slots > max_slots { + return Err(CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + }); } + self.frame_local_count = total_slots; + let mut materialized_position = 0usize; for (position, function_index) in indices.iter().copied().enumerate() { - let hidden_slot = LocalSlot::try_from(self.root_local_count + position) - .map_err(|_| CompileError::LocalSlotOverflow(LocalSlot::MAX))?; + let requires_slot = self + .callable_use_facts + .get(&function_index) + .is_none_or(|facts| facts.requires_callable_slot()); + let hidden_slot = if requires_slot { + let slot = LocalSlot::try_from(self.root_local_count + materialized_position) + .map_err(|_| CompileError::LocalSlotOverflow(LocalSlot::MAX))?; + materialized_position += 1; + Some(slot) + } else { + None + }; let prototype_id = self.callable_prototypes.len() as u32; let script_function_id = self.script_functions.len() as u32 + position as u32; let function_impl = self @@ -266,7 +326,9 @@ impl Compiler { .get(&function_index) .expect("function index came from implementation map"); let decl = self.function_decls.get(&function_index); - self.function_slots.insert(function_index, hidden_slot); + if let Some(hidden_slot) = hidden_slot { + self.function_slots.insert(function_index, hidden_slot); + } self.function_prototype_ids .insert(function_index, prototype_id); self.callable_prototypes.push(CallablePrototype { @@ -296,7 +358,7 @@ impl Compiler { super::lifetime::function_capture_binding_mode(function_impl, *target) }) .collect(), - self_slot: Some(hidden_slot), + self_slot: hidden_slot, schema: decl.map(|decl| TypeSchema::Callable { params: decl .arg_schemas @@ -306,7 +368,9 @@ impl Compiler { result: Box::new(decl.return_schema.clone().unwrap_or(TypeSchema::Unknown)), }), }); - if function_impl.capture_copies.is_empty() { + if function_impl.capture_copies.is_empty() + && let Some(hidden_slot) = hidden_slot + { self.root_callable_bindings.push(RootCallableBinding { local_slot: hidden_slot, prototype_id, @@ -676,8 +740,8 @@ impl Compiler { | Expr::UnresolvedFunctionRef { .. } => { return Err(CompileError::UnresolvedModuleCall); } - Expr::Call(index, _, args) => { - self.compile_function_call(*index, args)?; + Expr::Call(index, type_args, args) => { + self.compile_function_call(*index, type_args, args)?; } Expr::Closure(closure) => { let _ = self.emit_closure_callable(closure)?; @@ -1330,6 +1394,17 @@ impl Compiler { let (target_index, arity) = if let Some(builtin) = BuiltinFunction::from_call_index(index) { (index, builtin.arity()) } else if let Some(decl) = self.function_decls.get(&index) { + if self.function_impls.contains_key(&index) { + // A script-function implementation reached the value domain + // without a materialized `function_slots` entry (a + // callable-use classifier miss on a direct-only function). + // Never synthesize a HostImport prototype for a script + // implementation: the frame-local budget and the script + // prototype were already fixed by + // `prepare_named_callables`, so a late slot allocation + // would silently corrupt the callable metadata. + return Err(CompileError::CallableUsedAsValue); + } ( self.call_index_remap.get(&index).copied().unwrap_or(index), decl.args.len() as u8, @@ -1364,6 +1439,40 @@ impl Compiler { } } + /// Resolve (or create) the prototype-only specialization for a direct + /// generic call: the same script-function target as the base prototype + /// but carrying the instantiated concrete schema. No hidden local or + /// root binding is allocated, so direct-only generic calls stay + /// slot-free. Falls back to the base prototype when the instantiated + /// schema is unavailable. + fn ensure_direct_specialized_prototype( + &mut self, + index: u16, + type_args: &[TypeSchema], + ) -> Result { + if let Some((_, _, prototype_id)) = self + .specialized_direct_prototypes + .iter() + .find(|(candidate, args, _)| *candidate == index && args == type_args) + { + return Ok(*prototype_id); + } + let base_prototype_id = *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + let Some(schema) = self.instantiated_callable_schema(index, type_args) else { + return Ok(base_prototype_id); + }; + let mut prototype = self.callable_prototypes[base_prototype_id as usize].clone(); + prototype.schema = Some(schema); + let prototype_id = self.callable_prototypes.len() as u32; + self.callable_prototypes.push(prototype); + self.specialized_direct_prototypes + .push((index, type_args.to_vec(), prototype_id)); + Ok(prototype_id) + } + fn ensure_specialized_function_slot( &mut self, index: u16, @@ -1477,8 +1586,49 @@ impl Compiler { .or_insert(hints); } - fn compile_function_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { + fn compile_function_call( + &mut self, + index: u16, + type_args: &[TypeSchema], + args: &[Expr], + ) -> Result<(), CompileError> { if self.function_impls.contains_key(&index) { + let direct_only = self + .callable_use_facts + .get(&index) + .is_some_and(|facts| !facts.requires_callable_slot()); + if direct_only { + // Direct script call: evaluate the arguments and call the + // function's prototype without loading a hidden callable + // local. The prototype was pre-created for every named + // function in `prepare_named_callables`; a direct generic + // call with explicit type arguments resolves the + // specialized prototype carrying the instantiated schema + // so the runtime schema check reflects the call-site + // types instead of the accept-all generic base. + let prototype_id = if type_args.is_empty() { + *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)? + } else { + self.ensure_direct_specialized_prototype(index, type_args)? + }; + let return_type = self + .function_decls + .get(&index) + .map(|decl| decl.return_type) + .unwrap_or(ValueType::Unknown); + for arg in args { + self.compile_scalar_expr(arg)?; + } + let argc = u8::try_from(args.len()).map_err(|_| CompileError::CallArityOverflow)?; + if return_type != ValueType::Unknown { + self.record_operand_types(ValueType::Callable, return_type); + } + self.assembler.call_script(prototype_id, argc); + return Ok(()); + } let slot = *self .function_slots .get(&index) @@ -2007,3 +2157,90 @@ fn eval_const_int_expr(expr: &Expr) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A script function classified as direct-only (script prototype + /// created, no hidden callable slot) reaches `ensure_function_value_slot` + /// through a callable-use classifier miss. The compiler must refuse + /// with a typed `CallableUsedAsValue` error instead of synthesizing a + /// host-import prototype for the script implementation. + #[test] + fn ensure_function_value_slot_rejects_script_impl_without_slot() { + let mut compiler = Compiler::new(); + compiler.function_decls.insert( + 0, + FunctionDecl { + name: "direct_only".to_string(), + arity: 0, + index: 0, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported: false, + return_type: ValueType::Int, + symbol: None, + }, + ); + compiler.function_impls.insert( + 0, + FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: Vec::new(), + body_expr: Expr::Null, + body_expr_line: 1, + }, + ); + // The pipeline classifier reports direct calls only, so + // `prepare_named_callables` created the script prototype but no + // `function_slots` entry and no root binding. + compiler + .callable_use_facts + .insert(0, CallableUseFacts::default()); + compiler.function_prototype_ids.insert(0, 0); + compiler.callable_prototypes.push(CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }); + + let prototypes = compiler.callable_prototypes.len(); + let bindings = compiler.root_callable_bindings.len(); + let frame_local_count = compiler.frame_local_count; + + let result = compiler.ensure_function_value_slot(0, &[]); + assert!( + matches!(result, Err(CompileError::CallableUsedAsValue)), + "classifier-miss value use must be a typed compile error, got {result:?}" + ); + assert_eq!( + compiler.callable_prototypes.len(), + prototypes, + "no host-import prototype may be synthesized for a script implementation" + ); + assert_eq!( + compiler.root_callable_bindings.len(), + bindings, + "no root callable binding may be allocated" + ); + assert!( + !compiler.function_slots.contains_key(&0), + "no hidden callable slot may be allocated" + ); + assert_eq!( + compiler.frame_local_count, frame_local_count, + "the frame local count must stay unchanged across the typed rejection" + ); + } +} diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 42f5fed8..7f873388 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::cmp::Reverse; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -16,10 +15,7 @@ struct DefInfo { pub(super) struct LivenessRewriter { local_count: usize, clearable_slots: Vec, - conservative_call_indices: HashSet, function_impls: HashMap, - function_footprint_cache: RefCell>, - full_footprint: LiveSet, } impl LivenessRewriter { @@ -32,19 +28,10 @@ impl LivenessRewriter { // inline-call parameters, and parser-generated temporaries, so excluding // them leaves stale values past their last use. let clearable_slots = vec![true; local_count]; - let conservative_call_indices = function_impls - .iter() - .filter_map(|(index, function_impl)| { - function_impl_uses_local_call(function_impl).then_some(*index) - }) - .collect::>(); Self { local_count, clearable_slots, - conservative_call_indices, function_impls: function_impls.clone(), - function_footprint_cache: RefCell::new(HashMap::new()), - full_footprint: vec![true; local_count], } } @@ -464,15 +451,15 @@ impl LivenessRewriter { self.add_expr_uses(value, live); self.add_expr_uses(fallback, live); } - Expr::Call(index, _, args) => { + Expr::Call(_, _, args) => { + // Known named script calls execute in a separate runtime frame + // with its own local_base: the callee body footprint is + // analyzed inside the callee frame and must not be unioned + // into the caller live set. Arguments and caller-after-call + // uses stay live in the caller. for arg in args { self.add_expr_uses(arg, live); } - if self.function_impls.contains_key(index) { - let mut stack = Vec::new(); - let footprint = self.function_footprint(*index, &mut stack); - self.union_inplace(live, &footprint); - } } // Resolved module calls (pre-merge only) contribute their // arguments' uses; the callee lives in another unit and its @@ -625,360 +612,7 @@ impl LivenessRewriter { } live_out } - - fn function_footprint(&self, index: u16, stack: &mut Vec) -> LiveSet { - if let Some(cached) = self.function_footprint_cache.borrow().get(&index).cloned() { - return cached; - } - if stack.contains(&index) || self.conservative_call_indices.contains(&index) { - return self.full_footprint.clone(); - } - let Some(function_impl) = self.function_impls.get(&index) else { - return self.empty_set(); - }; - - stack.push(index); - let mut footprint = self.empty_set(); - for slot in &function_impl.param_slots { - self.mark_live(&mut footprint, *slot); - } - for (_, captured_slot) in &function_impl.capture_copies { - self.mark_live(&mut footprint, *captured_slot); - } - for stmt in &function_impl.body_stmts { - self.collect_stmt_footprint(stmt, &mut footprint, stack); - } - self.collect_expr_footprint(&function_impl.body_expr, &mut footprint, stack); - stack.pop(); - - self.function_footprint_cache - .borrow_mut() - .insert(index, footprint.clone()); - footprint - } - - fn closure_footprint(&self, closure: &ClosureExpr, stack: &mut Vec) -> LiveSet { - if expr_contains_local_call(&closure.body) { - return self.full_footprint.clone(); - } - - let mut footprint = self.empty_set(); - for slot in &closure.param_slots { - self.mark_live(&mut footprint, *slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(&mut footprint, *source_slot); - self.mark_live(&mut footprint, *captured_slot); - } - self.collect_expr_footprint(&closure.body, &mut footprint, stack); - footprint - } - - fn collect_stmt_footprint(&self, stmt: &Stmt, footprint: &mut LiveSet, stack: &mut Vec) { - match stmt { - Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => {} - Stmt::FuncDecl { - index, has_impl, .. - } => { - if *has_impl && let Some(function_impl) = self.function_impls.get(index) { - for (source_slot, captured_slot) in &function_impl.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - } - Stmt::Drop { index, .. } => self.mark_live(footprint, *index), - Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { - self.mark_live(footprint, *index); - self.collect_expr_footprint(expr, footprint, stack); - } - Stmt::ClosureLet { closure, .. } => { - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - Stmt::Expr { expr, .. } => self.collect_expr_footprint(expr, footprint, stack), - Stmt::IfElse { - condition, - then_branch, - else_branch, - .. - } => { - self.collect_expr_footprint(condition, footprint, stack); - for nested in then_branch { - self.collect_stmt_footprint(nested, footprint, stack); - } - for nested in else_branch { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - Stmt::For { - init, - condition, - post, - body, - .. - } => { - self.collect_stmt_footprint(init, footprint, stack); - self.collect_expr_footprint(condition, footprint, stack); - self.collect_stmt_footprint(post, footprint, stack); - for nested in body { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - Stmt::While { - condition, body, .. - } => { - self.collect_expr_footprint(condition, footprint, stack); - for nested in body { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - } - } - - fn collect_expr_footprint(&self, expr: &Expr, footprint: &mut LiveSet, stack: &mut Vec) { - match expr { - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) - | Expr::ModuleFunctionRef(..) - | Expr::UnresolvedFunctionRef { .. } => {} - Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { - self.mark_live(footprint, *index); - } - Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { - self.mark_live(footprint, *root); - } - Expr::OptionalGet { - container, - key, - container_slot, - key_slot, - } => { - self.mark_live(footprint, *container_slot); - self.mark_live(footprint, *key_slot); - self.collect_expr_footprint(container, footprint, stack); - self.collect_expr_footprint(key, footprint, stack); - } - Expr::OptionUnwrapOr { - value, - value_slot, - fallback, - } => { - self.mark_live(footprint, *value_slot); - self.collect_expr_footprint(value, footprint, stack); - self.collect_expr_footprint(fallback, footprint, stack); - } - Expr::Call(index, _, args) => { - let called = self.function_footprint(*index, stack); - self.union_inplace(footprint, &called); - for arg in args { - self.collect_expr_footprint(arg, footprint, stack); - } - } - // Resolved module calls (pre-merge only) contribute their - // arguments' footprint; the callee lives in another unit and is - // folded in by the post-merge call lowering. - Expr::ModuleCall(_, _, args) => { - for arg in args { - self.collect_expr_footprint(arg, footprint, stack); - } - } - Expr::Closure(closure) => { - for slot in &closure.param_slots { - self.mark_live(footprint, *slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - Expr::ClosureCall(closure, args) => { - let called = self.closure_footprint(closure, stack); - self.union_inplace(footprint, &called); - for arg in args { - self.collect_expr_footprint(arg, footprint, stack); - } - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => { - self.collect_expr_footprint(lhs, footprint, stack); - self.collect_expr_footprint(rhs, footprint, stack); - } - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => self.collect_expr_footprint(inner, footprint, stack), - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - self.collect_expr_footprint(condition, footprint, stack); - self.collect_expr_footprint(then_expr, footprint, stack); - self.collect_expr_footprint(else_expr, footprint, stack); - } - Expr::Match { - value_slot, - result_slot, - value, - arms, - default, - } => { - self.mark_live(footprint, *value_slot); - self.mark_live(footprint, *result_slot); - self.collect_expr_footprint(value, footprint, stack); - for (pattern, arm_expr) in arms { - if let Some(binding_slot) = pattern.binding_slot() { - self.mark_live(footprint, binding_slot); - } - self.collect_expr_footprint(arm_expr, footprint, stack); - } - self.collect_expr_footprint(default, footprint, stack); - } - Expr::Block { stmts, expr } => { - for stmt in stmts { - self.collect_stmt_footprint(stmt, footprint, stack); - } - self.collect_expr_footprint(expr, footprint, stack); - } - } - } -} - -fn function_impl_uses_local_call(function_impl: &FunctionImpl) -> bool { - function_impl - .body_stmts - .iter() - .any(stmt_contains_local_call) - || expr_contains_local_call(&function_impl.body_expr) -} - -fn stmt_contains_local_call(stmt: &Stmt) -> bool { - match stmt { - Stmt::Noop { .. } - | Stmt::FuncDecl { .. } - | Stmt::Break { .. } - | Stmt::Continue { .. } - | Stmt::Drop { .. } => false, - Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { - expr_contains_local_call(expr) - } - Stmt::ClosureLet { closure, .. } => expr_contains_local_call(&closure.body), - Stmt::IfElse { - condition, - then_branch, - else_branch, - .. - } => { - expr_contains_local_call(condition) - || then_branch.iter().any(stmt_contains_local_call) - || else_branch.iter().any(stmt_contains_local_call) - } - Stmt::For { - init, - condition, - post, - body, - .. - } => { - stmt_contains_local_call(init) - || expr_contains_local_call(condition) - || stmt_contains_local_call(post) - || body.iter().any(stmt_contains_local_call) - } - Stmt::While { - condition, body, .. - } => expr_contains_local_call(condition) || body.iter().any(stmt_contains_local_call), - } -} - -fn expr_contains_local_call(expr: &Expr) -> bool { - match expr { - Expr::LocalCall(..) => true, - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) - | Expr::ModuleFunctionRef(..) - | Expr::UnresolvedFunctionRef { .. } - | Expr::Var(_) - | Expr::MoveVar(_) - | Expr::MoveField { .. } - | Expr::MoveIndex { .. } => false, - Expr::OptionalGet { container, key, .. } => { - expr_contains_local_call(container) || expr_contains_local_call(key) - } - Expr::OptionUnwrapOr { - value, fallback, .. - } => expr_contains_local_call(value) || expr_contains_local_call(fallback), - Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { - args.iter().any(expr_contains_local_call) - } - Expr::Closure(closure) => expr_contains_local_call(&closure.body), - Expr::ClosureCall(closure, args) => { - args.iter().any(expr_contains_local_call) || expr_contains_local_call(&closure.body) - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => expr_contains_local_call(lhs) || expr_contains_local_call(rhs), - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => expr_contains_local_call(inner), - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - expr_contains_local_call(condition) - || expr_contains_local_call(then_expr) - || expr_contains_local_call(else_expr) - } - Expr::Match { - value, - arms, - default, - .. - } => { - expr_contains_local_call(value) - || arms - .iter() - .any(|(_, arm_expr)| expr_contains_local_call(arm_expr)) - || expr_contains_local_call(default) - } - Expr::Block { stmts, expr } => { - stmts.iter().any(stmt_contains_local_call) || expr_contains_local_call(expr) - } - } } - fn stmt_line(stmt: &Stmt) -> u32 { match stmt { Stmt::Noop { line } @@ -1001,7 +635,6 @@ pub(super) struct LocalSlotAllocator { liveness: LivenessRewriter, function_impls: HashMap, adjacency: Vec>, - function_footprint_cache: HashMap, full_footprint: LiveSet, } @@ -1017,7 +650,6 @@ impl LocalSlotAllocator { liveness, function_impls: function_impls.clone(), adjacency: (0..local_count).map(|_| HashSet::new()).collect(), - function_footprint_cache: HashMap::new(), full_footprint: vec![true; local_count], } } @@ -1186,15 +818,15 @@ impl LocalSlotAllocator { self.collect_expr_constraints(value, &live_during)?; self.collect_expr_constraints(fallback, &live_during)?; } - Expr::Call(index, _, args) => { + Expr::Call(_, _, args) => { + // Arguments are evaluated in the caller frame, so their + // constraints belong here. The callee body runs in a separate + // runtime frame with its own local_base, so caller/callee + // cross-live edges would only needlessly separate slots that + // frame bases already isolate. for arg in args { self.collect_expr_constraints(arg, &live_during)?; } - if self.function_impls.contains_key(index) { - let mut stack = Vec::new(); - let footprint = self.function_footprint(*index, &mut stack); - self.add_cross_live_with_set(&live_during, &footprint); - } } // Resolved module calls (pre-merge only) constrain their // arguments; the callee's footprint is folded in post-merge. @@ -1278,31 +910,6 @@ impl LocalSlotAllocator { Ok(()) } - fn function_footprint(&mut self, index: u16, stack: &mut Vec) -> LiveSet { - if let Some(cached) = self.function_footprint_cache.get(&index) { - return cached.clone(); - } - if stack.contains(&index) { - return self.full_footprint.clone(); - } - let Some(function_impl) = self.function_impls.get(&index).cloned() else { - return self.liveness.empty_set(); - }; - stack.push(index); - let mut footprint = self.liveness.empty_set(); - for slot in &function_impl.param_slots { - self.mark_set_slot(&mut footprint, *slot); - } - for stmt in &function_impl.body_stmts { - self.collect_stmt_footprint(stmt, &mut footprint, stack); - } - self.collect_expr_footprint(&function_impl.body_expr, &mut footprint, stack); - stack.pop(); - self.function_footprint_cache - .insert(index, footprint.clone()); - footprint - } - fn closure_footprint(&mut self, closure: &ClosureExpr, stack: &mut Vec) -> LiveSet { let mut footprint = self.liveness.empty_set(); for slot in &closure.param_slots { @@ -1419,15 +1026,10 @@ impl LocalSlotAllocator { self.collect_expr_footprint(value, set, stack); self.collect_expr_footprint(fallback, set, stack); } - Expr::Call(index, _, args) => { - if self.function_impls.contains_key(index) { - let footprint = self.function_footprint(*index, stack); - for (slot, used) in footprint.iter().enumerate() { - if *used { - set[slot] = true; - } - } - } + Expr::Call(_, _, args) => { + // The callee runs in its own frame even when called from a + // closure body, so only argument slots join the caller-side + // footprint. for arg in args { self.collect_expr_footprint(arg, set, stack); } diff --git a/src/compiler/lifetime/mod.rs b/src/compiler/lifetime/mod.rs index b4e57dd1..bdb0b962 100644 --- a/src/compiler/lifetime/mod.rs +++ b/src/compiler/lifetime/mod.rs @@ -1,3 +1,32 @@ +//! Frame-local lifetime analysis. +//! +//! # Same-frame interference +//! +//! Locals that are simultaneously live inside one execution frame share a +//! single interference domain: the coloring pass must give them distinct +//! relative slot numbers. This applies to the root body and to each named +//! function body independently — argument evaluation and values used after +//! a call keep the caller's slots live across the call. +//! +//! # Cross-frame reuse +//! +//! Every script invocation allocates its own runtime frame with a fresh +//! `local_base` (see `docs/callable-runtime.md`). A statically resolved +//! named call (`Expr::Call`) therefore contributes only caller-side +//! argument uses to the caller live set; the callee body's locals are +//! analyzed inside the callee frame and never union into the caller. +//! Locals from different frames may reuse the same relative slot numbers — +//! the runtime frame bases already separate them, so cross-frame live +//! ranges need no interference edges. +//! +//! # Conservative dynamic paths +//! +//! Dynamic targets keep their pre-frame conservatism on purpose: +//! `Expr::LocalCall` marks the whole live set because the invoked slot can +//! hold an inline closure whose captures are not visible from the call +//! expression, and closure bodies contribute their transitive footprint so +//! captured slots stay live for the duration of the call. + mod availability; mod liveness; diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs new file mode 100644 index 00000000..6bea071c --- /dev/null +++ b/src/compiler/materialization.rs @@ -0,0 +1,2311 @@ +//! Classify named script functions by whether they require a runtime +//! `Value::Callable` identity (materialization). +//! +//! The classification is keyed by the resolved flat function index assigned +//! during semantic module merge — never by source name — so same-named +//! declarations in independent modules classify independently. Codegen +//! consumes the classification when allocating hidden callable slots: +//! direct-only functions are lowered by the direct script-call opcode with +//! no hidden slot, and every function that needs materialization keeps a +//! hidden callable slot bound at frame entry. +//! +//! # Flow model +//! +//! The classification is computed by one authoritative IR visitor plus a +//! small monotone fixed-point dataflow: +//! +//! - The visitor handles every [`Expr`]/[`Stmt`] variant in exactly one +//! place and emits the semantic events: named function values +//! (`referenced_as_value`), statically resolved calls (`called_directly`), +//! per-frame slot-flow records, call sites with argument provenance, and +//! closure/capture boundaries. New IR variants must be added to the +//! visitor; there are no parallel walkers that can drift. +//! - Each execution frame (program root, named function body, closure body) +//! owns a slot-value flow: which named functions can occupy which local +//! slots, which slots are invoked through `Expr::LocalCall`, and which +//! call sites pass which argument provenance into which callee. +//! - A dynamic callable target is an invocation of a tracked slot +//! (`LocalCall`), or an argument that provably reaches an invoked +//! parameter slot of a known callee (named function or closure), tracked +//! transitively across frames. Passing a function value to an opaque +//! callee (host/builtin) or storing it in a container only marks +//! `referenced_as_value`; it never claims `dynamic_target_required` +//! without tracked flow to an invocation. This keeps +//! `requires_callable_slot` sound: every function value in the merged IR +//! originates from an `Expr::FunctionRef` node, so `referenced_as_value` +//! is always set where a dynamic target could be. +//! - Callable provenance that the flow record cannot enumerate — call +//! results, container reads, closures in value position, and slot values +//! that are not classified script functions — is tracked as *unknown* +//! per slot, and crosses the same alias, parameter, and capture edges as +//! tracked values. A dynamic invocation may claim that an argument +//! provably avoids a dynamic target (`Some(false)`) only when the callee +//! set is complete and every possible callee is known not to invoke the +//! parameter; unknown provenance keeps the propagation conservative. +//! - Captures copy values across frame boundaries (closures at creation +//! time, named functions at frame entry); the fixed point seeds capture +//! slots from the declaring frame's flow and translates invocations of a +//! captured slot back to its source slot, so a captured callable invoked +//! from inside a closure is attributed to the slot that held it. +//! - `runtime_self_required` only fires for recursion that executes in the +//! function's own frame: a statically resolved self-call in the function's +//! executable body (blocks, branches and loops are the same frame; closure +//! bodies are not), or a dynamic invocation of the function's own value +//! reachable from its frame (stored value invoked through `LocalCall`, or +//! the value passed to a callee that invokes its parameter). +//! +//! # Cost +//! +//! Classification runs once per compilation on the merged IR: one full IR +//! walk plus a monotone fixed point over frames, slots, and call sites. The +//! fixed point terminates because every lattice (slot values, invoked +//! slots, closure values, invoked parameters) only grows and is bounded by +//! the merged IR size; there is no O(function × IR) rescanning. This is +//! pure metadata production; codegen consumes `requires_callable_slot` +//! when counting callable slots and assigning hidden callable locals. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use super::ir::{ClosureExpr, Expr, FrontendIr, LocalSlot, Stmt}; + +/// Semantic facts about how one named script function is used across the +/// whole merged compilation. +/// +/// Compiler-internal metadata for the hidden callable slot allocation +/// decision; not part of the public API. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct CallableUseFacts { + /// The function is invoked through a statically resolved call site. + pub called_directly: bool, + /// The function value appears in the value domain (`Expr::FunctionRef`), + /// for example stored into a local, a map, or an array. + pub referenced_as_value: bool, + /// The function is exported under the `ExportedCallable` contract. + pub exported: bool, + /// The function captures an environment (declaration-time capture cells). + pub captures_environment: bool, + /// A dynamic call site can reach this function through tracked value + /// flow: the function value is stored into a slot that is invoked + /// (`Expr::LocalCall`), or it is passed as an argument to a parameter of + /// a known callee that is itself dynamically invoked. + pub dynamic_target_required: bool, + /// The function's own runtime callable identity must be bound at frame + /// entry (capturing or dynamic recursion path). + pub runtime_self_required: bool, +} + +impl CallableUseFacts { + /// Single decision derived from the semantic facts: does this function + /// need a hidden callable local slot? + /// + /// Plain direct calls — including non-capturing direct recursion — do + /// not require a slot; the direct script-call opcode lowers them by + /// prototype ID. Every other fact forces materialization into a hidden + /// callable slot that the runtime frame binds at entry. + pub fn requires_callable_slot(&self) -> bool { + self.referenced_as_value + || self.exported + || self.captures_environment + || self.dynamic_target_required + || self.runtime_self_required + } +} + +/// One observed classification entry for a resolved flat function identity, +/// produced by the production pipeline (parse -> module merge -> lifetime -> +/// classification -> Compiler) and attached to [`CompiledProgram`] so the +/// crate's unit tests can assert the facts the compiler actually received. +/// +/// Compiled into unit-test builds only; never part of the public API. +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CallableUseObservation { + /// Resolved flat function index (the classification key). + pub function_index: u16, + /// Merged declaration name, carried only so tests can identify the + /// entry; classification itself never keys by name. + pub name: String, + pub facts: CallableUseFacts, +} + +/// Classify every named script function in the merged IR. +/// +/// Facts are keyed by the resolved flat function index (the identity the +/// linker assigned through `SymbolId` remapping), never by source name. +pub(crate) fn classify_named_callables(ir: &FrontendIr) -> HashMap { + let mut classifier = Classifier::new(ir); + classifier.classify(ir); + classifier.facts +} + +/// Argument value provenance: the named function values an expression +/// directly evaluates to, the slots it reads, and whether it can also +/// evaluate to a callable whose identity is not tracked. +#[derive(Clone, Debug, Default)] +struct ArgFlow { + functions: BTreeSet, + slots: BTreeSet, + /// The expression can evaluate to a callable the flow record cannot + /// enumerate (call results, container reads, closures in value + /// position). A slot seeded with such a flow has an incomplete callee + /// set and may never be claimed to provably avoid invoking a parameter. + unknown: bool, +} + +/// A statically resolved call site with per-argument provenance. +#[derive(Clone, Debug)] +struct CallSite { + callee: u16, + args: Vec, +} + +/// A closure invocation with per-argument provenance. +#[derive(Clone, Debug)] +struct ClosureCallSite { + callee_frame: usize, + args: Vec, +} + +/// A dynamic invocation of a local slot with per-argument provenance. +#[derive(Clone, Debug)] +struct LocalCallSite { + slot: LocalSlot, + args: Vec, +} + +/// One execution frame's slot-flow records: the program root, a named +/// function body, or a closure body. Slot numbers are frame-relative; the +/// fixed point never mixes slots across frames except through the explicit +/// capture mappings. +#[derive(Default)] +struct FrameFlow { + /// The named function whose body this frame executes (`None` for the + /// program root and closure bodies). + function: Option, + /// Parameter slots of this frame (named functions and closures). + params: Vec, + /// Slots that directly received named function values. + seeds: HashMap>, + /// Slot aliases: `target` receives the values of every `source`. + aliases: HashMap>, + /// Slots invoked through `Expr::LocalCall`. + local_calls: BTreeSet, + /// LocalCall sites with arguments. + local_call_sites: Vec, + /// Named call sites. + call_sites: Vec, + /// Closure call sites. + closure_call_sites: Vec, + /// Closures created in this frame: (child frame, capture copies). + closures_created: Vec<(usize, Vec<(LocalSlot, LocalSlot)>)>, + /// Closure frames stored into slots (rebinds union). + closure_slots: HashMap>, + /// Slots that received values whose callable provenance is untracked + /// (call results, container reads): their callee sets are incomplete. + unknown: HashSet, +} + +/// The classification pass: one authoritative visitor plus a monotone +/// fixed-point dataflow over per-frame slot flows. +struct Classifier { + facts: HashMap, + frames: Vec, + /// Functions that call themselves from their own executable frame. + direct_self: HashSet, + /// Named-function body frame per function index. + function_frames: HashMap, + /// Captures per named function: (body frame, capture copies). + function_captures: HashMap)>, + /// Frame that declares each function (capture sources live there). + decl_frames: HashMap, + /// Fixed-point state: slot contents per frame. + values: Vec>>, + /// Fixed-point state: slots whose contents reach a dynamic callable + /// target. + invoked: Vec>, + /// Fixed-point state: closure frames per slot (alias-closed). + closure_values: Vec>>, + /// Fixed-point state: parameter slots that reach a dynamic callable + /// target. + dyn_params: Vec>, + /// Fixed-point state: slots with unknown callable provenance per frame. + unknown_values: Vec>, +} + +impl Classifier { + fn new(ir: &FrontendIr) -> Self { + let mut facts: HashMap = ir + .function_impls + .keys() + .map(|&index| (index, CallableUseFacts::default())) + .collect(); + for decl in &ir.functions { + if let Some(fact) = facts.get_mut(&decl.index) { + fact.exported = decl.exported; + } + } + for (index, function_impl) in &ir.function_impls { + if let Some(fact) = facts.get_mut(index) { + fact.captures_environment = !function_impl.capture_copies.is_empty(); + } + } + Self { + facts, + frames: vec![FrameFlow::default()], + direct_self: HashSet::new(), + function_frames: HashMap::new(), + function_captures: HashMap::new(), + decl_frames: HashMap::new(), + values: Vec::new(), + invoked: Vec::new(), + closure_values: Vec::new(), + dyn_params: Vec::new(), + unknown_values: Vec::new(), + } + } + + fn classify(&mut self, ir: &FrontendIr) { + // Create every named-function frame up front so call sites in any + // body can resolve callee frames regardless of walk order. + let mut function_impls = ir.function_impls.iter().collect::>(); + function_impls.sort_unstable_by_key(|(index, _)| **index); + for (index, function_impl) in &function_impls { + let frame = self.frames.len(); + self.frames.push(FrameFlow { + function: Some(**index), + params: function_impl.param_slots.clone(), + ..FrameFlow::default() + }); + self.function_frames.insert(**index, frame); + } + for (index, function_impl) in &function_impls { + let frame = self.function_frames[index]; + for stmt in &function_impl.body_stmts { + self.stmt(frame, stmt); + } + self.expr(frame, &function_impl.body_expr); + self.function_captures + .insert(**index, (frame, function_impl.capture_copies.clone())); + } + for stmt in &ir.stmts { + self.stmt(0, stmt); + } + self.fixed_point(); + self.attribute(); + } + + /// Authoritative statement visitor. Every [`Stmt`] variant is handled + /// here exactly once. + fn stmt(&mut self, frame: usize, stmt: &Stmt) { + match stmt { + Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } | Stmt::Drop { .. } => {} + Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { + let mut flow = self.value_flow(expr); + if matches!(expr, Expr::Closure(_)) { + // A directly assigned closure is fully tracked through + // `closure_slots` below, so the slot's callee set stays + // complete. + flow.unknown = false; + } + self.seed_slot(frame, *index, &flow); + if let Expr::Closure(closure) = expr { + let child = self.closure(frame, closure); + self.frames[frame] + .closure_slots + .entry(*index) + .or_default() + .push(child); + } else { + self.expr(frame, expr); + } + } + Stmt::ClosureLet { closure, .. } => { + self.closure(frame, closure); + } + Stmt::FuncDecl { index, .. } => { + self.decl_frames.entry(*index).or_insert(frame); + } + Stmt::Expr { expr, .. } => self.expr(frame, expr), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + self.expr(frame, condition); + for stmt in then_branch { + self.stmt(frame, stmt); + } + for stmt in else_branch { + self.stmt(frame, stmt); + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + self.stmt(frame, init); + self.expr(frame, condition); + self.stmt(frame, post); + for stmt in body { + self.stmt(frame, stmt); + } + } + Stmt::While { + condition, body, .. + } => { + self.expr(frame, condition); + for stmt in body { + self.stmt(frame, stmt); + } + } + } + } + + /// Authoritative expression visitor. Every [`Expr`] variant is handled + /// here exactly once; nested statements in blocks and closure bodies are + /// routed back through [`Self::stmt`] / [`Self::closure`]. + fn expr(&mut self, frame: usize, expr: &Expr) { + match expr { + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) => {} + Expr::FunctionRef(index, _) => { + if let Some(fact) = self.facts.get_mut(index) { + fact.referenced_as_value = true; + } + } + // The classification runs on merged IR where module function + // references are already lowered to plain `Expr::FunctionRef` + // and `Expr::Call`; unresolved refs are rejected before this + // point. Only argument expressions can still be visited here. + Expr::ModuleFunctionRef(..) | Expr::UnresolvedFunctionRef { .. } => {} + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.expr(frame, arg); + } + } + Expr::OptionalGet { container, key, .. } => { + self.expr(frame, container); + self.expr(frame, key); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + self.expr(frame, value); + self.expr(frame, fallback); + } + Expr::Call(target, _, args) => { + if let Some(fact) = self.facts.get_mut(target) { + fact.called_directly = true; + if self.frames[frame].function == Some(*target) { + self.direct_self.insert(*target); + } + } + if self.function_frames.contains_key(target) { + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].call_sites.push(CallSite { + callee: *target, + args: flows, + }); + } + for arg in args { + self.expr(frame, arg); + } + } + Expr::LocalCall(slot, _, args) => { + self.frames[frame].local_calls.insert(*slot); + if !args.is_empty() { + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].local_call_sites.push(LocalCallSite { + slot: *slot, + args: flows, + }); + } + for arg in args { + self.expr(frame, arg); + } + } + Expr::Closure(closure) => { + self.closure(frame, closure); + } + Expr::ClosureCall(closure, args) => { + let callee_frame = self.closure(frame, closure); + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].closure_call_sites.push(ClosureCallSite { + callee_frame, + args: flows, + }); + for arg in args { + self.expr(frame, arg); + } + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) => { + self.expr(frame, lhs); + self.expr(frame, rhs); + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => { + self.expr(frame, inner); + } + Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } => {} + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + self.expr(frame, condition); + self.expr(frame, then_expr); + self.expr(frame, else_expr); + } + Expr::Match { + value, + arms, + default, + .. + } => { + self.expr(frame, value); + for (_, arm_expr) in arms { + self.expr(frame, arm_expr); + } + self.expr(frame, default); + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + self.stmt(frame, stmt); + } + self.expr(frame, expr); + } + } + } + + /// Walk a closure body in its own frame and register the capture + /// boundary with the creating frame. Returns the child frame index. + fn closure(&mut self, frame: usize, closure: &ClosureExpr) -> usize { + let child = self.frames.len(); + self.frames.push(FrameFlow { + function: None, + params: closure.param_slots.clone(), + ..FrameFlow::default() + }); + self.expr(child, &closure.body); + self.frames[frame] + .closures_created + .push((child, closure.capture_copies.clone())); + child + } + + /// Top-level value provenance of an expression: the named function + /// values it directly evaluates to, the slots it reads, and whether it + /// can evaluate to a callable the flow record cannot enumerate. This is + /// a provenance query over the value-producing shapes only (function + /// values, slot reads, and union control flow); every other expression + /// yields no tracked provenance, and its nested function values are + /// still recorded by the visitor. + fn value_flow(&self, expr: &Expr) -> ArgFlow { + match expr { + Expr::FunctionRef(index, _) => ArgFlow { + functions: BTreeSet::from([*index]), + slots: BTreeSet::new(), + unknown: false, + }, + Expr::Borrow(inner) | Expr::BorrowMut(inner) | Expr::ToOwned(inner) => { + self.value_flow(inner) + } + Expr::Var(slot) | Expr::MoveVar(slot) => ArgFlow { + functions: BTreeSet::new(), + slots: BTreeSet::from([*slot]), + unknown: false, + }, + Expr::IfElse { + then_expr, + else_expr, + .. + } => { + let mut flow = self.value_flow(then_expr); + let other = self.value_flow(else_expr); + flow.functions.extend(other.functions); + flow.slots.extend(other.slots); + flow.unknown |= other.unknown; + flow + } + Expr::Match { arms, default, .. } => { + let mut flow = self.value_flow(default); + for (_, arm_expr) in arms { + let arm = self.value_flow(arm_expr); + flow.functions.extend(arm.functions); + flow.slots.extend(arm.slots); + flow.unknown |= arm.unknown; + } + flow + } + Expr::Block { stmts: _, expr } => self.value_flow(expr), + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + let mut flow = self.value_flow(value); + let other = self.value_flow(fallback); + flow.functions.extend(other.functions); + flow.slots.extend(other.slots); + flow.unknown |= other.unknown; + flow + } + // Call results, container reads, module references, moved + // container fields, and closures in value position can be + // callables whose identity the flow record cannot enumerate; a + // slot seeded with them has an incomplete callee set. Their + // nested function values are recorded by the visitor as value + // references. + Expr::ModuleCall(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::LocalCall(..) + | Expr::ClosureCall(..) + | Expr::OptionalGet { .. } + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } + | Expr::Closure(_) => ArgFlow { + functions: BTreeSet::new(), + slots: BTreeSet::new(), + unknown: true, + }, + // Literals and numeric/boolean operations cannot produce + // callable values. + _ => ArgFlow::default(), + } + } + + fn seed_slot(&mut self, frame: usize, slot: LocalSlot, flow: &ArgFlow) { + if flow.unknown { + self.frames[frame].unknown.insert(slot); + } + if !flow.functions.is_empty() { + self.frames[frame] + .seeds + .entry(slot) + .or_default() + .extend(flow.functions.iter().copied()); + } + if !flow.slots.is_empty() { + self.frames[frame] + .aliases + .entry(slot) + .or_default() + .extend(flow.slots.iter().copied()); + } + } + + /// Monotone fixed point over per-frame slot values, invoked slots, + /// closure values, unknown callable provenance, and dynamically invoked + /// parameters. Terminates because every lattice only grows. + fn fixed_point(&mut self) { + let frame_count = self.frames.len(); + self.values = (0..frame_count) + .map(|frame| self.frames[frame].seeds.clone()) + .collect(); + self.invoked = (0..frame_count) + .map(|frame| self.frames[frame].local_calls.clone()) + .collect(); + self.closure_values = (0..frame_count) + .map(|frame| self.frames[frame].closure_slots.clone()) + .collect(); + self.unknown_values = (0..frame_count) + .map(|frame| self.frames[frame].unknown.clone()) + .collect(); + self.dyn_params = vec![BTreeSet::new(); frame_count]; + + // Frame-derived records are immutable during the fixed point; clone + // them once so the iteration only mutates the growing lattices. + let aliases = self + .frames + .iter() + .map(|frame| frame.aliases.clone()) + .collect::>(); + let frame_params = self + .frames + .iter() + .map(|frame| frame.params.clone()) + .collect::>(); + let call_sites = self + .frames + .iter() + .map(|frame| frame.call_sites.clone()) + .collect::>(); + let closure_call_sites = self + .frames + .iter() + .map(|frame| frame.closure_call_sites.clone()) + .collect::>(); + let local_call_sites = self + .frames + .iter() + .map(|frame| frame.local_call_sites.clone()) + .collect::>(); + let closures_created = (0..frame_count) + .flat_map(|frame| { + self.frames[frame] + .closures_created + .iter() + .map(move |(child, captures)| (frame, *child, captures.clone())) + }) + .collect::>(); + let function_captures = self + .function_captures + .iter() + .map(|(index, (body_frame, captures))| (*index, *body_frame, captures.clone())) + .collect::>(); + + let mut changed = true; + while changed { + changed = false; + for frame in 0..frame_count { + // Intra-frame alias closure for slot values: values stored + // into an aliased slot flow into its targets. + for (target, sources) in &aliases[frame] { + let mut source_values = BTreeSet::new(); + for source in sources { + if let Some(values) = self.values[frame].get(source) { + source_values.extend(values.iter().copied()); + } + } + if !source_values.is_empty() { + let target_values = self.values[frame].entry(*target).or_default(); + for index in source_values { + if target_values.insert(index) { + changed = true; + } + } + } + } + // Reverse alias: a slot feeding an invoked slot is invoked + // too, so its contents reach the dynamic callable target. + for (target, sources) in &aliases[frame] { + if !self.invoked[frame].contains(target) { + continue; + } + for source in sources { + if self.invoked[frame].insert(*source) { + changed = true; + } + } + } + // Unknown callable provenance follows the same alias edges. + for (target, sources) in &aliases[frame] { + if sources + .iter() + .any(|source| self.unknown_values[frame].contains(source)) + && self.unknown_values[frame].insert(*target) + { + changed = true; + } + } + // Closure values follow the same alias edges. + for (target, sources) in &aliases[frame] { + let mut source_closures = Vec::new(); + for source in sources { + if let Some(closures) = self.closure_values[frame].get(source) { + source_closures.extend(closures.iter().copied()); + } + } + if !source_closures.is_empty() { + let target_closures = + self.closure_values[frame].entry(*target).or_default(); + for child in source_closures { + if !target_closures.contains(&child) { + target_closures.push(child); + changed = true; + } + } + } + } + // Invoked parameter slots reach a dynamic callable target. + for param in &frame_params[frame] { + if self.invoked[frame].contains(param) && self.dyn_params[frame].insert(*param) + { + changed = true; + } + } + // Named call sites: an invoked callee parameter makes the + // argument provenance invoked in this frame. + for site in &call_sites[frame] { + let Some(&callee_frame) = self.function_frames.get(&site.callee) else { + continue; + }; + for (arg_index, arg) in site.args.iter().enumerate() { + let Some(param) = frame_params[callee_frame].get(arg_index) else { + continue; + }; + if !self.dyn_params[callee_frame].contains(param) { + continue; + } + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + } + // Closure call sites: same rule, plus closure parameter value + // seeding so intra-closure aliasing sees the argument values. + for site in &closure_call_sites[frame] { + for (arg_index, arg) in site.args.iter().enumerate() { + let Some(param) = frame_params[site.callee_frame].get(arg_index) else { + continue; + }; + if self.dyn_params[site.callee_frame].contains(param) { + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + if self.seed_param_values(frame, site.callee_frame, *param, arg) { + changed = true; + } + } + } + // LocalCall sites: resolve statically known callees (named + // function values in the slot, closures stored into it); + // incomplete callee sets stay conservative. + for site in &local_call_sites[frame] { + let slot_values = self.values[frame] + .get(&site.slot) + .cloned() + .unwrap_or_default(); + let slot_closures = self + .closure_values + .get(frame) + .and_then(|closures| closures.get(&site.slot)) + .cloned() + .unwrap_or_default(); + for (arg_index, arg) in site.args.iter().enumerate() { + let known_invokes = callee_invokes_param( + site.slot, + frame, + &slot_values, + &slot_closures, + &self.function_frames, + &frame_params, + &self.dyn_params, + &self.unknown_values, + arg_index, + ); + if matches!(known_invokes, Some(false)) { + // Known callees never invoke this parameter and + // the callee set is complete: the argument does + // not reach a dynamic target. + continue; + } + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + for &callee_frame in &slot_closures { + if let Some(param) = frame_params[callee_frame].get(arg_index) + && self.seed_param_values(frame, callee_frame, *param, arg) + { + changed = true; + } + } + } + } + } + // Capture seeding across frame boundaries: closures copy values + // from their creating frame at creation time; named functions + // copy from their declaring frame at frame entry. An invocation + // of a captured slot inside the child frame also invokes the + // source slot in the creating frame (closure-escape dynamic + // paths), translated transitively by the fixed point. Unknown + // callable provenance crosses the same boundaries. + for (frame, child, captures) in &closures_created { + for (source, captured) in captures { + let source_values = + self.values[*frame].get(source).cloned().unwrap_or_default(); + if !source_values.is_empty() { + let target_values = self.values[*child].entry(*captured).or_default(); + for index in source_values { + if target_values.insert(index) { + changed = true; + } + } + } + if self.unknown_values[*frame].contains(source) + && self.unknown_values[*child].insert(*captured) + { + changed = true; + } + if self.invoked[*child].contains(captured) + && self.invoked[*frame].insert(*source) + { + changed = true; + } + } + } + for (index, body_frame, captures) in &function_captures { + let decl_frame = self.decl_frames.get(index).copied().unwrap_or(0); + for (source, captured) in captures { + let source_values = self.values[decl_frame] + .get(source) + .cloned() + .unwrap_or_default(); + if !source_values.is_empty() { + let target_values = self.values[*body_frame].entry(*captured).or_default(); + for value in source_values { + if target_values.insert(value) { + changed = true; + } + } + } + if self.unknown_values[decl_frame].contains(source) + && self.unknown_values[*body_frame].insert(*captured) + { + changed = true; + } + if self.invoked[*body_frame].contains(captured) + && self.invoked[decl_frame].insert(*source) + { + changed = true; + } + } + } + } + } + + /// Seed a callee's parameter slot with the argument's value provenance + /// (direct function values plus the caller slot contents) and unknown + /// callable provenance. Returns whether either lattice grew. + fn seed_param_values( + &mut self, + caller_frame: usize, + callee_frame: usize, + param: LocalSlot, + arg: &ArgFlow, + ) -> bool { + let mut changed = false; + if (arg.unknown + || arg + .slots + .iter() + .any(|slot| self.unknown_values[caller_frame].contains(slot))) + && self.unknown_values[callee_frame].insert(param) + { + changed = true; + } + let mut param_values = arg.functions.clone(); + for slot in &arg.slots { + if let Some(slot_values) = self.values[caller_frame].get(slot) { + param_values.extend(slot_values.iter().copied()); + } + } + if param_values.is_empty() { + return changed; + } + let target = self.values[callee_frame].entry(param).or_default(); + for index in param_values { + if target.insert(index) { + changed = true; + } + } + changed + } + + /// Derive the final facts from the fixed-point state. + fn attribute(&mut self) { + // Every slot whose contents reach a dynamic callable target marks + // those contents as dynamic targets. + let invoked = self.invoked.clone(); + for (frame, slots) in invoked.iter().enumerate() { + for slot in slots { + if let Some(indexes) = self.values[frame].get(slot) { + for index in indexes { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + } + } + + // Frame-local self recursion: dynamic invocations of the function's + // own value reachable from its own frame — a stored value invoked + // through LocalCall, or the value passed to a callee that invokes + // its parameter. + let frame_params = self + .frames + .iter() + .map(|frame| frame.params.clone()) + .collect::>(); + let mut dynamic_self = HashSet::new(); + for (index, &(body_frame, _)) in &self.function_captures { + for slot in &self.invoked[body_frame] { + if self + .values + .get(body_frame) + .and_then(|values| values.get(slot)) + .is_some_and(|indexes| indexes.contains(index)) + { + dynamic_self.insert(*index); + } + } + for site in &self.frames[body_frame].call_sites { + let Some(&callee_frame) = self.function_frames.get(&site.callee) else { + continue; + }; + for (arg_index, arg) in site.args.iter().enumerate() { + if arg.functions.contains(index) + && frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| self.dyn_params[callee_frame].contains(param)) + { + dynamic_self.insert(*index); + } + } + } + for site in &self.frames[body_frame].closure_call_sites { + for (arg_index, arg) in site.args.iter().enumerate() { + if arg.functions.contains(index) + && frame_params[site.callee_frame] + .get(arg_index) + .is_some_and(|param| self.dyn_params[site.callee_frame].contains(param)) + { + dynamic_self.insert(*index); + } + } + } + for site in &self.frames[body_frame].local_call_sites { + let slot_values = self + .values + .get(body_frame) + .and_then(|values| values.get(&site.slot)) + .cloned() + .unwrap_or_default(); + let slot_closures = self + .closure_values + .get(body_frame) + .and_then(|closures| closures.get(&site.slot)) + .cloned() + .unwrap_or_default(); + for (arg_index, arg) in site.args.iter().enumerate() { + if !arg.functions.contains(index) { + continue; + } + let known_invokes = callee_invokes_param( + site.slot, + body_frame, + &slot_values, + &slot_closures, + &self.function_frames, + &frame_params, + &self.dyn_params, + &self.unknown_values, + arg_index, + ); + if !matches!(known_invokes, Some(false)) { + dynamic_self.insert(*index); + } + } + } + } + + for index in self.function_captures.keys().copied().collect::>() { + let self_recursive = self.direct_self.contains(&index) || dynamic_self.contains(&index); + if let Some(fact) = self.facts.get_mut(&index) { + fact.runtime_self_required = + self_recursive && (fact.captures_environment || fact.dynamic_target_required); + } + } + } +} + +/// Whether any statically known callee of a local slot (named function +/// values in the slot, closures stored into it) dynamically invokes argument +/// position `arg_index`. +/// +/// Returns `Some(true)` when at least one known callee invokes the +/// parameter, `Some(false)` when the callee set is complete and every +/// possible target provably does not invoke it, and `None` when the callee +/// set is incomplete — no callee is known, the slot also holds values with +/// untracked callable provenance (call results, container reads), or a slot +/// value is not a classified script function — so the caller must stay +/// conservative. +#[allow(clippy::too_many_arguments)] +fn callee_invokes_param( + slot: LocalSlot, + frame: usize, + slot_values: &BTreeSet, + slot_closures: &[usize], + function_frames: &HashMap, + frame_params: &[Vec], + dyn_params: &[BTreeSet], + unknown_values: &[HashSet], + arg_index: usize, +) -> Option { + if slot_values.is_empty() && slot_closures.is_empty() { + return None; + } + let mut any_invokes = false; + let mut all_known = true; + for &callee in slot_values { + let Some(&callee_frame) = function_frames.get(&callee) else { + // A callable value whose invocation behavior was not classified + // (e.g. a host/builtin function value): it cannot be proven not + // to invoke the parameter. + all_known = false; + continue; + }; + if frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| dyn_params[callee_frame].contains(param)) + { + any_invokes = true; + } + } + for &callee_frame in slot_closures { + if frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| dyn_params[callee_frame].contains(param)) + { + any_invokes = true; + } + } + if any_invokes { + return Some(true); + } + if !all_known || unknown_values[frame].contains(&slot) { + // Incomplete callee set: a possible callee with unknown invocation + // behavior keeps the propagation conservative. + return None; + } + Some(false) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::ValueType; + + use super::super::ir::{AssignmentKind, FunctionDecl, FunctionImpl, LocalSlot, MatchPattern}; + use super::super::linker::{ParsedUnit, merge_units}; + use super::super::modules::{ModuleId, SymbolId}; + use super::*; + + fn decl(index: u16, name: &str, exported: bool, symbol: Option) -> FunctionDecl { + FunctionDecl { + name: name.to_string(), + arity: 0, + index, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported, + return_type: ValueType::Int, + symbol, + } + } + + fn impl_with( + capture_copies: Vec<(LocalSlot, LocalSlot)>, + body_stmts: Vec, + body_expr: Expr, + ) -> FunctionImpl { + impl_with_params(Vec::new(), capture_copies, body_stmts, body_expr) + } + + fn impl_with_params( + param_slots: Vec, + capture_copies: Vec<(LocalSlot, LocalSlot)>, + body_stmts: Vec, + body_expr: Expr, + ) -> FunctionImpl { + FunctionImpl { + param_slots, + capture_copies, + body_stmts, + body_expr, + body_expr_line: 1, + } + } + + fn ir_with( + stmts: Vec, + functions: Vec, + function_impls: HashMap, + ) -> FrontendIr { + FrontendIr { + stmts, + locals: 0, + local_bindings: Vec::new(), + struct_schemas: HashMap::new(), + unknown_type_spans: Vec::new(), + functions, + function_impls, + stmt_sources: Vec::new(), + function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + } + } + + fn call(index: u16) -> Expr { + Expr::Call(index, Vec::new(), Vec::new()) + } + + fn func_decl_stmt(name: &str, index: u16) -> Stmt { + Stmt::FuncDecl { + name: name.to_string(), + index, + arity: 0, + args: Vec::new(), + exported: false, + has_impl: true, + line: 1, + } + } + + fn expr_stmt(expr: Expr) -> Stmt { + Stmt::Expr { expr, line: 1 } + } + + fn let_stmt(slot: LocalSlot, expr: Expr) -> Stmt { + Stmt::Let { + index: slot, + declared_schema: None, + expr, + line: 1, + } + } + + #[test] + fn materialization_direct_only_helper_needs_no_callable_slot() { + // `helper` is only ever invoked through statically resolved calls + // (from the root and from `caller`). No value reference, no export, + // no captures: it must not require a callable slot. + let helper_impl = impl_with(Vec::new(), Vec::new(), Expr::Int(1)); + let caller_impl = impl_with(Vec::new(), Vec::new(), call(0)); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("caller", 1), + expr_stmt(call(0)), + expr_stmt(call(1)), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "caller", false, None), + ], + HashMap::from([(0, helper_impl), (1, caller_impl)]), + ); + + let facts = classify_named_callables(&ir); + let helper = facts[&0]; + assert!(helper.called_directly); + assert!(!helper.referenced_as_value); + assert!(!helper.exported); + assert!(!helper.captures_environment); + assert!(!helper.dynamic_target_required); + assert!(!helper.runtime_self_required); + assert!(!helper.requires_callable_slot()); + assert!(facts[&1].called_directly); + } + + #[test] + fn materialization_exported_direct_helper_requires_slot() { + let ir = ir_with( + vec![func_decl_stmt("helper", 0), expr_stmt(call(0))], + vec![decl(0, "helper", true, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(helper.exported); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_value_referenced_local_requires_slot() { + // `let stored = helper;` puts the function value into the value + // domain even though nothing invokes it dynamically. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_container_storage_keeps_materialization_without_dynamic_target() { + // `list.push(helper)` flows the function value into a container + // through an opaque callee. The value is referenced and materialized, + // but no tracked value flow reaches an actual dynamic callable + // target, so `dynamic_target_required` stays false (F6 precision); + // materialization is preserved through `referenced_as_value`. + let push = Expr::Call( + 200, + Vec::new(), + vec![Expr::Var(11), Expr::FunctionRef(0, Vec::new())], + ); + let ir = ir_with( + vec![func_decl_stmt("helper", 0), let_stmt(12, push)], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_locally_stored_value_called_dynamically_requires_dynamic_target() { + // The stored function value is invoked through `LocalCall` on the + // local that received it: a dynamic call site can target it. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_capturing_named_function_requires_environment() { + let ir = ir_with( + vec![func_decl_stmt("read", 0), expr_stmt(call(0))], + vec![decl(0, "read", false, None)], + HashMap::from([(0, impl_with(vec![(5, 7)], Vec::new(), Expr::Int(1)))]), + ); + + let read = classify_named_callables(&ir)[&0]; + assert!(read.called_directly); + assert!(read.captures_environment); + assert!(read.requires_callable_slot()); + } + + #[test] + fn materialization_noncapturing_direct_recursion_needs_no_runtime_self() { + // `fn count() { count() }` recurses through a statically resolved + // call and captures nothing: once the direct script-call opcode + // exists it needs neither a slot nor a runtime self identity. + let count_impl = impl_with(Vec::new(), Vec::new(), call(0)); + let ir = ir_with( + vec![func_decl_stmt("count", 0), expr_stmt(call(0))], + vec![decl(0, "count", false, None)], + HashMap::from([(0, count_impl)]), + ); + + let count = classify_named_callables(&ir)[&0]; + assert!(count.called_directly); + assert!(!count.captures_environment); + assert!(!count.runtime_self_required); + assert!(!count.requires_callable_slot()); + } + + #[test] + fn materialization_capturing_recursion_retains_runtime_self() { + // A capturing function that recurses directly needs its runtime self + // identity bound at frame entry to re-enter with its environment. + let ir = ir_with( + vec![func_decl_stmt("walk", 0), expr_stmt(call(0))], + vec![decl(0, "walk", false, None)], + HashMap::from([(0, impl_with(vec![(5, 7)], Vec::new(), call(0)))]), + ); + + let walk = classify_named_callables(&ir)[&0]; + assert!(walk.called_directly); + assert!(walk.captures_environment); + assert!(walk.runtime_self_required); + assert!(walk.requires_callable_slot()); + } + + #[test] + fn materialization_same_source_name_follows_resolved_identity() { + // Two functions both named `helper`, each with its own resolved + // identity: classification must follow the function index, never the + // shared source name. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("helper", 1), + expr_stmt(call(0)), + expr_stmt(call(1)), + ], + vec![ + decl(0, "helper", true, None), + decl(1, "helper", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert_eq!(facts.len(), 2); + let exported = facts[&0]; + let direct_only = facts[&1]; + assert!(exported.exported); + assert!(exported.requires_callable_slot()); + assert!(!direct_only.exported); + assert!(direct_only.called_directly); + assert!(!direct_only.requires_callable_slot()); + } + + #[test] + fn materialization_classification_survives_module_merge_remap() { + // Two independent modules each declare `fn helper` plus a `run` that + // calls it. The root calls its own exported `helper` directly and + // imports the sibling's `run` through a `ModuleCall`. After the real + // merge pipeline remaps unit indices and symbols to flat indices, + // classification must attribute facts to the resolved flat identity + // of each same-named function. + let sibling_symbol_helper = SymbolId { + module: ModuleId(2), + index: 0, + }; + let sibling_symbol_run = SymbolId { + module: ModuleId(2), + index: 1, + }; + let root_symbol_helper = SymbolId { + module: ModuleId(1), + index: 0, + }; + + let sibling_unit = ParsedUnit { + parsed: ir_with( + vec![func_decl_stmt("helper", 0), func_decl_stmt("run", 1)], + vec![ + decl(0, "helper", false, Some(sibling_symbol_helper)), + decl(1, "run", false, Some(sibling_symbol_run)), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(11))), + // `run` calls the sibling's own `helper` (unit index 0). + (1, impl_with(Vec::new(), Vec::new(), call(0))), + ]), + ), + scope_identity: Some("sibling__m2".to_string()), + source_name: "sibling.rss".to_string(), + module: ModuleId(2), + source_id: 1, + }; + + let root_unit = ParsedUnit { + parsed: ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + // Imported call resolved to the sibling's `run` symbol. + expr_stmt(Expr::ModuleCall(sibling_symbol_run, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", true, Some(root_symbol_helper))], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(22)))]), + ), + scope_identity: None, + source_name: "main.rss".to_string(), + module: ModuleId(1), + source_id: 0, + }; + + let merged = + merge_units(vec![sibling_unit, root_unit]).expect("hand-built units must merge"); + + // Both same-named helpers survive as distinct flat entries; the + // assertions below key everything by resolved identity, never by the + // merged display name (a mangling policy change must not affect + // them). + assert_eq!(merged.functions.len(), 3); + assert_eq!(merged.function_impls.len(), 3); + + let facts = classify_named_callables(&merged); + assert_eq!(facts.len(), 3); + for index in merged.function_impls.keys() { + assert!(facts.contains_key(index), "every impl must be classified"); + } + + let flat_of = |symbol: SymbolId| -> u16 { + merged + .functions + .iter() + .find(|function| function.symbol == Some(symbol)) + .expect("symbol must have a flat entry") + .index + }; + + // The two same-named helpers must classify under distinct resolved + // flat identities. + let root_helper_index = flat_of(root_symbol_helper); + let sibling_helper_index = flat_of(sibling_symbol_helper); + assert_ne!( + root_helper_index, sibling_helper_index, + "same-named helpers must have distinct flat identities" + ); + + // The root's exported helper (flat index from symbol remap) keeps the + // exported fact and requires materialization. + let root_helper = facts[&root_helper_index]; + assert!(root_helper.called_directly); + assert!(root_helper.exported); + assert!(root_helper.requires_callable_slot()); + + // The sibling's direct-only helper (same source name, different + // identity) is called directly by its own `run` and needs no slot. + let sibling_helper = facts[&sibling_helper_index]; + assert!(sibling_helper.called_directly); + assert!(!sibling_helper.exported); + assert!(!sibling_helper.requires_callable_slot()); + + // The sibling's `run` is reached from the root through the + // symbol-resolved `ModuleCall` and is classified as called directly. + let sibling_run = facts[&flat_of(sibling_symbol_run)]; + assert!(sibling_run.called_directly); + assert!(!sibling_run.requires_callable_slot()); + } + + #[test] + fn materialization_requires_callable_slot_ignores_call_count_and_spelling() { + // The decision is a pure function of the semantic facts: many direct + // calls still need no slot, while a single value reference does. + let many_calls = ir_with( + vec![ + func_decl_stmt("hot", 0), + expr_stmt(call(0)), + expr_stmt(call(0)), + expr_stmt(call(0)), + ], + vec![decl(0, "hot", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + assert!(!classify_named_callables(&many_calls)[&0].requires_callable_slot()); + + let single_value_use = ir_with( + vec![ + func_decl_stmt("hot", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + ], + vec![decl(0, "hot", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + assert!(classify_named_callables(&single_value_use)[&0].requires_callable_slot()); + } + + #[test] + fn materialization_facts_ignore_unrelated_statement_kinds() { + // Assignments and drops of ordinary values must not perturb the + // classification of an unrelated direct-only function. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + let_stmt(10, Expr::Int(5)), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Int(6), + line: 1, + }, + Stmt::Drop { index: 10, line: 1 }, + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(!helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(!helper.requires_callable_slot()); + } + + // --- F1: slot-to-slot / control-flow propagation of dynamic targets --- + + #[test] + fn materialization_slot_alias_chain_propagates_dynamic_target() { + // `let a = helper; let b = a; b();`: the function value flows through + // slot-to-slot aliasing before the dynamic invocation. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt(11, Expr::Var(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_move_var_alias_propagates_dynamic_target() { + // `let a = helper; let b = move a; b();`: moved values keep flowing. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt(11, Expr::MoveVar(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_ifelse_branch_values_propagate_dynamic_target() { + // `let x = if c { helper } else { other }; x();`: either branch value + // can reach the dynamic invocation. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + else_expr: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_match_arm_values_propagate_dynamic_target() { + // `let x = match v { 1 => helper, _ => other }; x();` + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt( + 10, + Expr::Match { + value_slot: 20, + result_slot: 21, + value: Box::new(Expr::Int(1)), + arms: vec![(MatchPattern::Int(1), Expr::FunctionRef(0, Vec::new()))], + default: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_block_result_propagates_dynamic_target() { + // `let x = { helper }; x();`: the block result value flows to the slot. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt( + 10, + Expr::Block { + stmts: Vec::new(), + expr: Box::new(Expr::FunctionRef(0, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_rebind_alias_propagates_dynamic_target() { + // `let a = helper; a = other; let b = a; b();`: `b` aliases `a` after + // the rebind; the rebound value must still be attributed. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::FunctionRef(1, Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Var(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].referenced_as_value); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_closure_captured_callable_propagates_dynamic_target() { + // `let a = helper; let c = || { a() }; c();`: the closure captures slot + // `a` and invokes the captured value dynamically in its own frame. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt( + 11, + Expr::Closure(ClosureExpr { + param_slots: Vec::new(), + capture_copies: vec![(10, 30)], + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }), + ), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_named_function_capture_invocation_marks_dynamic_target() { + // `let a = helper; fn g() { a(); } g();`: the named function `g` + // captures slot `a` and invokes the captured value in its own frame. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("g", 1), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(call(1)), + ], + vec![decl(0, "helper", false, None), decl(1, "g", false, None)], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + ( + 1, + impl_with( + vec![(10, 30)], + Vec::new(), + Expr::LocalCall(30, Vec::new(), Vec::new()), + ), + ), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + } + + // --- F2: frame-local self recursion --- + + #[test] + fn materialization_nested_closure_recursion_is_not_frame_local_self_recursion() { + // `fn f() { let c = || { f() }; c(); }` with captures: the call to `f` + // executes in the closure's frame, not in `f`'s own executable body, + // so it must not count as direct self-recursion. + let f_impl = impl_with( + vec![(5, 7)], + vec![ + let_stmt( + 10, + Expr::Closure(ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(call(0)), + }), + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + Expr::Int(1), + ); + let ir = ir_with( + vec![func_decl_stmt("f", 0), expr_stmt(call(0))], + vec![decl(0, "f", false, None)], + HashMap::from([(0, f_impl)]), + ); + + let f = classify_named_callables(&ir)[&0]; + assert!(f.called_directly); + assert!(f.captures_environment); + assert!(!f.runtime_self_required); + assert!(f.requires_callable_slot()); + } + + #[test] + fn materialization_function_value_recursion_requires_runtime_self() { + // `fn f() { let g = f; g(); }`: the function's own value is invoked + // dynamically from within its own frame — a dynamic recursion path + // that must bind the runtime self identity. + let f_impl = impl_with( + Vec::new(), + vec![ + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + Expr::Int(1), + ); + let ir = ir_with( + vec![func_decl_stmt("f", 0), expr_stmt(call(0))], + vec![decl(0, "f", false, None)], + HashMap::from([(0, f_impl)]), + ); + + let f = classify_named_callables(&ir)[&0]; + assert!(f.dynamic_target_required); + assert!(f.runtime_self_required); + } + + // --- F6: dynamic targets only through tracked invocation flow --- + + #[test] + fn materialization_opaque_callee_arg_keeps_materialization_without_dynamic_target() { + // `consume(helper)` where `consume` never invokes its parameter: the + // function value is referenced and materialized, but no tracked value + // flow reaches an actual dynamic callable target. + let consume_impl = impl_with_params(vec![10], Vec::new(), Vec::new(), Expr::Int(1)); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("consume", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "consume", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, consume_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + let helper = facts[&0]; + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_invoking_callee_param_marks_dynamic_target() { + // `apply(f) { f() }` invoked as `apply(helper)`: the argument reaches + // a dynamic callable target inside the callee frame. + let apply_impl = impl_with_params( + vec![10], + Vec::new(), + Vec::new(), + Expr::LocalCall(10, Vec::new(), Vec::new()), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].referenced_as_value); + assert!(facts[&0].dynamic_target_required); + assert!(!facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_callee_param_alias_invocation_marks_dynamic_target() { + // `apply(f) { let g = f; g(); }`: the parameter reaches the dynamic + // invocation through an intra-frame alias. + let apply_impl = impl_with_params( + vec![10], + Vec::new(), + vec![let_stmt(11, Expr::Var(10))], + Expr::LocalCall(11, Vec::new(), Vec::new()), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + } + + #[test] + fn materialization_transitive_callee_param_invocation_marks_dynamic_target() { + // `apply2(g) { apply(g) }` and `apply(f) { f() }`; `apply2(helper)`: + // the argument reaches the dynamic callable target through two frames. + let apply_impl = impl_with_params( + vec![20], + Vec::new(), + Vec::new(), + Expr::LocalCall(20, Vec::new(), Vec::new()), + ); + let apply2_impl = impl_with_params( + vec![10], + Vec::new(), + Vec::new(), + Expr::Call(1, Vec::new(), vec![Expr::Var(10)]), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + func_decl_stmt("apply2", 2), + expr_stmt(Expr::Call( + 2, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + decl(2, "apply2", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + (2, apply2_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(!facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_closure_call_param_invocation_marks_dynamic_target() { + // Immediate closure invocation `(|f| f())(helper)`. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(Expr::ClosureCall( + closure, + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_stored_closure_call_param_invocation_marks_dynamic_target() { + // `let apply = |f| f(); apply(helper);`: the closure is stored in a + // slot and later invoked through `LocalCall` with an argument that + // reaches its invoked parameter. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::Closure(closure)), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + // --- F7: incomplete callee sets stay conservative (unknown provenance) --- + + #[test] + fn materialization_unknown_callee_provenance_keeps_conservative_propagation() { + // `let f = helper; f = get_cb(); f(cb);`: the slot holds a known + // named function that never invokes its parameter *and* a call + // result whose callable provenance is untracked. The callee set is + // incomplete, so `Some(false)` must not suppress the conservative + // propagation: the argument still reaches a dynamic callable target. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!( + facts[&1].dynamic_target_required, + "the argument must be conservatively treated as reaching a dynamic target" + ); + } + + #[test] + fn materialization_control_flow_closure_callee_keeps_conservative_propagation() { + // `let f = if c { |x| x() } else { helper }; f(cb);`: the closure + // branch is created but never recorded in the slot's closure set + // (only direct closure lets are), so the callee set is incomplete + // even though `helper` is a known non-invoking callee. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::Closure(ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + })), + else_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None), decl(1, "cb", false, None)], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!( + facts[&1].dynamic_target_required, + "the untracked closure branch must keep the propagation conservative" + ); + } + + #[test] + fn materialization_unknown_provenance_flows_through_closure_param_transitively() { + // `let apply = |f| { let g = f; g(cb) }; let a = helper; a = get_cb(); + // apply(a);`: the unknown provenance of `a` must flow through the + // closure's parameter slot and its alias `g`, so `cb` is + // conservatively treated as reaching a dynamic callable target even + // though the known callee `helper` never invokes its parameter. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::Block { + stmts: vec![let_stmt(31, Expr::Var(30))], + expr: Box::new(Expr::LocalCall( + 31, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + }), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Closure(closure)), + expr_stmt(Expr::LocalCall(11, Vec::new(), vec![Expr::Var(10)])), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!( + facts[&1].dynamic_target_required, + "unknown provenance must flow through the closure parameter alias chain" + ); + } + + #[test] + fn materialization_unknown_provenance_flows_through_alias_chain_transitively() { + // `let a = helper; a = get_cb(); let b = a; let c = b; c(cb);`: the + // unknown provenance travels through two alias hops before the + // invocation, so the callee set of `c` is incomplete and `cb` must + // be conservatively marked as reaching a dynamic callable target. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Var(10)), + let_stmt(12, Expr::Var(11)), + expr_stmt(Expr::LocalCall( + 12, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!( + facts[&1].dynamic_target_required, + "unknown provenance must flow through the alias chain to the invocation" + ); + } + + #[test] + fn materialization_complete_control_flow_callee_set_keeps_precision() { + // `let f = if c { helper } else { other }; f(cb);`: every branch is + // a tracked named function and neither invokes its parameter, so the + // callee set is complete and `Some(false)` legitimately suppresses + // the propagation (precision guard: the soundness fix must not + // degrade fully-tracked control flow). + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + func_decl_stmt("cb", 2), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + else_expr: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(2, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + decl(2, "cb", false, None), + ], + HashMap::from([ + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + ( + 1, + impl_with_params(vec![41], Vec::new(), Vec::new(), Expr::Int(2)), + ), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + assert!( + !facts[&2].dynamic_target_required, + "a complete callee set of non-invoking functions must suppress propagation" + ); + } +} diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index ff77d89f..1f0f0a74 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -14,6 +14,7 @@ mod frontends; pub mod ir; mod lifetime; mod linker; +mod materialization; mod modules; mod parser; mod pipeline; @@ -21,6 +22,8 @@ mod source_loader; pub mod source_map; mod typing; +#[cfg(test)] +use self::materialization::CallableUseObservation; use self::source_map::{SourceMap, Span}; pub use self::codegen::Compiler; @@ -58,6 +61,15 @@ pub enum CompileError { CallableUsedAsValue, NonCallableLocal(LocalSlot), LocalSlotOverflow(LocalSlot), + /// The aggregate frame-local count (data slots plus materialized callable + /// slots) exceeds what the short bytecode operands can address. Carries + /// the real counts so the diagnostic is actionable instead of a sentinel. + FrameLocalLimitExceeded { + data_slots: usize, + callable_slots: usize, + total_slots: usize, + max_slots: usize, + }, CallableArityMismatch { expected: usize, got: usize, @@ -155,6 +167,14 @@ impl CompileError { CompileError::LocalSlotOverflow(slot) => { format!("local slot {slot} exceeds the supported bytecode encoding") } + CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + } => format!( + "frame requires {total_slots} local slots ({data_slots} data + {callable_slots} callable); short bytecode supports {max_slots}" + ), CompileError::CallableArityMismatch { expected, got } => { format!("callable arity mismatch: expected {expected}, got {got}") } @@ -478,6 +498,12 @@ pub struct CompiledProgram { pub program: Program, pub locals: usize, pub functions: Vec, + /// Milestone-5 callable-use classification observed through the + /// production pipeline, keyed by resolved flat function index and + /// sorted by index. Test-only observation compiled into the crate's + /// unit-test builds only; never part of the public API. + #[cfg(test)] + pub(crate) callable_use_facts: Vec, } impl CompiledProgram { diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index dda97e63..c3d63a23 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -13,8 +13,8 @@ use super::source_loader::load_units_for_source_file; use super::source_map::SourceMap; use super::{ CompileError, CompileSourceFileOptions, CompiledProgram, CompiledReplProgram, ParseError, - ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, TypingMode, lifetime, parser, - typing, + ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, TypingMode, lifetime, + materialization, parser, typing, }; #[derive(Clone, Copy, Debug, Default)] @@ -389,6 +389,11 @@ fn compile_parsed_output_with_entry_locals( enable_local_move_semantics, ) .map_err(SourceError::Parse)?; + // Classify named callable materialization on the final merged IR + // (post-lifetime, so capture metadata and rewritten uses are + // authoritative). Codegen consumes `requires_callable_slot` to omit + // hidden callable slots for direct-only functions. + let callable_use_facts = materialization::classify_named_callables(&parsed); let type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); let FrontendIr { stmts, @@ -405,6 +410,27 @@ fn compile_parsed_output_with_entry_locals( .map(|decl| (decl.index, decl)) .collect::>(); + // Milestone-5 observation for the crate's unit tests: capture the + // classification keyed by the merged flat function identity before the + // facts move into the Compiler, so tests observe exactly what the + // compiler received. Compiled into unit-test builds only; never part of + // the public API. + #[cfg(test)] + let mut callable_use_observations = functions + .iter() + .filter_map(|decl| { + callable_use_facts.get(&decl.index).map(|facts| { + materialization::CallableUseObservation { + function_index: decl.index, + name: decl.name.clone(), + facts: *facts, + } + }) + }) + .collect::>(); + #[cfg(test)] + callable_use_observations.sort_unstable_by_key(|observation| observation.function_index); + let mut runtime_import_functions: Vec = functions .iter() .filter(|func| !function_impls.contains_key(&func.index)) @@ -442,6 +468,7 @@ fn compile_parsed_output_with_entry_locals( compiler.set_root_local_count(locals); compiler.set_function_decls(function_decls); compiler.set_function_impls(function_impls); + compiler.set_callable_use_facts(callable_use_facts); compiler.set_struct_schemas(struct_schemas); compiler.set_host_import_return_types(host_import_return_types); compiler.set_host_import_signatures(host_import_signatures); @@ -473,6 +500,8 @@ fn compile_parsed_output_with_entry_locals( program, locals: runtime_locals, functions: visible_runtime_import_functions, + #[cfg(test)] + callable_use_facts: callable_use_observations, }) } @@ -1463,3 +1492,269 @@ where } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::vm::Vm; + + use super::*; + + #[test] + fn production_path_callable_use_facts_observed() { + // Observe the milestone-5 classification through the real production + // pipeline (parse -> module merge -> lifetime -> classification -> + // Compiler) via the crate-internal test observation on + // CompiledProgram. Facts must be keyed by resolved flat identity + // and include the flow-aware dynamic-target and runtime-self facts; + // allocation behavior stays untouched (every named function keeps + // its prototype and hidden callable slot). + let source = r#" + fn direct_helper(x: int) -> int { x + 1 } + pub fn exported_helper(x: int) -> int { x + 2 } + fn stored_helper(x: int) -> int { x + 3 } + fn flow_helper() -> int { 4 } + fn consume(f) -> int { 1 } + fn apply(f) -> int { f(1) } + fn direct_recursive(n: int) -> int { + if n <= 0 => { 0 } else => { direct_recursive(n - 1) } + } + let captured = 42; + fn read_captured() -> int { captured } + fn captured_walk(n: int) -> int { + if n <= 0 => { captured } else => { captured_walk(n - 1) } + } + let stored = stored_helper; + let a = flow_helper; + let b = a; + b(); + consume(stored_helper); + apply(consume); + direct_helper(1); + exported_helper(1); + direct_recursive(3); + read_captured; + captured_walk(2); + "#; + let compiled = compile_source(source).expect("classification program should compile"); + let observations = &compiled.callable_use_facts; + let find = |name: &str| { + observations + .iter() + .find(|observation| observation.name == name) + .unwrap_or_else(|| panic!("observation for '{name}' missing: {observations:#?}")) + .facts + }; + assert_eq!( + observations.len(), + 9, + "every named script function must carry production-path facts" + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.function_index) + .collect::>() + .len(), + 9, + "facts must be keyed by distinct resolved flat identities" + ); + + let direct = find("direct_helper"); + assert!(direct.called_directly); + assert!(!direct.referenced_as_value); + assert!(!direct.exported); + assert!(!direct.captures_environment); + assert!(!direct.dynamic_target_required); + assert!(!direct.runtime_self_required); + assert!(!direct.requires_callable_slot()); + + let exported = find("exported_helper"); + assert!(exported.called_directly); + assert!(exported.exported); + assert!(exported.requires_callable_slot()); + + let stored = find("stored_helper"); + assert!(stored.referenced_as_value); + assert!( + !stored.dynamic_target_required, + "passing a function value to a callee that never invokes it must not \ + mark a dynamic target (tracked flow only)" + ); + assert!(stored.requires_callable_slot()); + + let flow = find("flow_helper"); + assert!(flow.referenced_as_value); + assert!( + flow.dynamic_target_required, + "the alias chain `let a = flow_helper; let b = a; b();` must propagate \ + to the dynamic invocation" + ); + + let consume = find("consume"); + assert!(consume.called_directly); + assert!( + consume.dynamic_target_required, + "consume is passed to `apply`, whose parameter is dynamically invoked" + ); + + let recursive = find("direct_recursive"); + assert!(recursive.called_directly); + assert!(!recursive.captures_environment); + assert!( + !recursive.runtime_self_required, + "non-capturing direct recursion needs no runtime self identity" + ); + assert!(!recursive.requires_callable_slot()); + + let read_captured = find("read_captured"); + assert!(read_captured.captures_environment); + assert!(!read_captured.runtime_self_required); + + let captured_walk = find("captured_walk"); + assert!(captured_walk.captures_environment); + assert!( + captured_walk.runtime_self_required, + "capturing direct recursion retains the runtime self identity" + ); + assert!(captured_walk.requires_callable_slot()); + + // Milestone 6 lowering: every named function keeps its prototype; + // direct-only functions (no value reference, export, capture, or + // dynamic target) keep no hidden callable slot, while the + // materialized functions retain their runtime self slot. + assert_eq!(compiled.program.callable_prototypes.len(), 9); + let self_slots = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(); + assert_eq!( + self_slots, 6, + "exported, stored, flow, consume, and both capturing functions stay materialized" + ); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 3, + "direct_helper, apply, and direct_recursive are direct-only" + ); + assert_eq!(compiled.program.root_callable_bindings.len(), 4); + assert!( + compiled + .program + .code + .contains(&(crate::OpCode::CallScript as u8)), + "direct-only call sites emit CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, crate::vm::VmStatus::Halted); + } + + #[test] + fn production_path_module_merge_facts_follow_flat_indices() { + // Two modules each declare a private `helper` plus a `pub run` that + // calls it, merged through the real production pipeline. The + // classification must attribute facts to distinct resolved flat + // identities; assertions never parse the merged display names (a + // mangling policy change must not affect them) and instead check + // counts, index uniqueness, and the exported-vs-private semantic + // facts. + let options = CompileSourceFileOptions::new() + .with_module_override_source( + "a/util.rss", + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + ) + .with_module_override_source( + "b/util.rss", + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + ); + let source = "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n"; + let compiled = + compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, options) + .expect("same-named module helpers should compile"); + + let observations = &compiled.callable_use_facts; + assert_eq!( + observations.len(), + 4, + "both modules' run and both same-named helpers must carry facts: {observations:#?}" + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.function_index) + .collect::>() + .len(), + 4, + "classification must be keyed by distinct resolved flat identities" + ); + + let runs = observations + .iter() + .filter(|observation| observation.facts.exported) + .collect::>(); + assert_eq!(runs.len(), 2, "both exported runs must survive the merge"); + for run in runs { + assert!(run.facts.called_directly); + assert!(run.facts.requires_callable_slot()); + } + + let helpers = observations + .iter() + .filter(|observation| !observation.facts.exported) + .collect::>(); + assert_eq!( + helpers.len(), + 2, + "both same-named private helpers must survive the merge" + ); + for helper in helpers { + assert!( + helper.facts.called_directly, + "each module's run calls its own same-named helper" + ); + assert!(!helper.facts.dynamic_target_required); + assert!(!helper.facts.requires_callable_slot()); + } + + // Milestone 6 allocation: every merged function keeps its prototype; + // the same-named private helpers are direct-only (no hidden slot), + // and both exported runs stay materialized and exported. + assert_eq!(compiled.program.callable_prototypes.len(), 4); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(), + 2, + "both exported runs keep their runtime self slot" + ); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 2, + "both same-named private helpers are direct-only" + ); + assert_eq!(compiled.program.root_callable_bindings.len(), 2); + assert_eq!(compiled.program.exported_callables.len(), 2); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, crate::vm::VmStatus::Halted); + } +} diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 1f944ed5..8783ad00 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -12,8 +12,8 @@ use super::super::jit::JitConfig; use super::compile::CompiledProgram; const MAGIC: [u8; 4] = *b"PAT\0"; -const VERSION: u16 = 7; -const ABI_VERSION: u16 = 7; +const VERSION: u16 = 8; +const ABI_VERSION: u16 = 8; const FLAG_INTERPRETER_BOUNDARY_ONLY: u16 = 1; const SUPPORTED_FLAGS: u16 = FLAG_INTERPRETER_BOUNDARY_ONLY; @@ -660,7 +660,7 @@ mod tests { } #[test] - fn aot_artifact_v7_roundtrips_callable_metadata_and_rejects_old_revisions() { + fn aot_artifact_v8_roundtrips_callable_metadata_and_rejects_old_revisions() { let compiled = crate::compile_source_for_repl("pub fn add_one(value: int) -> int { value + 1 }") .expect("callable program should compile"); @@ -669,20 +669,20 @@ mod tests { let encoded = vm .encode_aot_artifact() .expect("artifact encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 7); - assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 7); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 8); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 8); let mut old_format = encoded.clone(); - old_format[4..6].copy_from_slice(&6u16.to_le_bytes()); + old_format[4..6].copy_from_slice(&7u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), - Err(AotArtifactError::UnsupportedVersion(6)) + Err(AotArtifactError::UnsupportedVersion(7)) )); let mut old_abi = encoded.clone(); - old_abi[6..8].copy_from_slice(&6u16.to_le_bytes()); + old_abi[6..8].copy_from_slice(&7u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_abi, JitConfig::default()), - Err(AotArtifactError::UnsupportedAbiVersion(6)) + Err(AotArtifactError::UnsupportedAbiVersion(7)) )); let mut standalone = @@ -702,4 +702,69 @@ mod tests { Value::Int(42) ); } + + #[test] + fn aot_artifact_v8_roundtrips_direct_call_script_program() { + // A real direct-only program: the root body calls a named function + // through `CallScript` and the callee is a native AOT body, so the + // artifact must embed both the callable metadata and the executable + // AOT code for the direct path. + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = + crate::compile_source_for_repl(source).expect("direct call program should compile"); + assert!( + compiled + .program + .code + .contains(&(crate::OpCode::CallScript as u8)), + "expected the root body to embed CallScript bytecode" + ); + + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compile should succeed"); + let encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 8); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 8); + + let mut old_format = encoded.clone(); + old_format[4..6].copy_from_slice(&7u16.to_le_bytes()); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), + Err(AotArtifactError::UnsupportedVersion(7)) + )); + + let mut standalone = + Vm::new_from_aot_artifact_with_jit_config(&encoded, JitConfig::default()) + .expect("standalone direct artifact should load"); + assert!( + standalone.has_aot_program(), + "standalone vm should install aot" + ); + assert_eq!( + standalone.run().expect("direct call program should run"), + VmStatus::Halted + ); + assert_eq!(standalone.stack(), &[Value::Int(16)]); + assert!( + standalone.aot_exec_count() > 0, + "standalone artifact should execute through the native AOT path: {}", + standalone.dump_aot_info() + ); + assert!( + !standalone.dump_aot_info().contains("interpreter-boundary"), + "standalone artifact should not fall back to the interpreter: {}", + standalone.dump_aot_info() + ); + } } diff --git a/src/vm/aot/cfg.rs b/src/vm/aot/cfg.rs index f0f1f886..2834ea47 100644 --- a/src/vm/aot/cfg.rs +++ b/src/vm/aot/cfg.rs @@ -47,6 +47,12 @@ pub(crate) enum AotBlockTerminal { call_ip: usize, resume_ip: usize, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterpreterExit { exit_ip: usize, }, @@ -56,9 +62,11 @@ pub(crate) enum AotBlockTerminal { impl AotBlockTerminal { pub(crate) fn successor_ips(&self) -> Vec { match self { - Self::Return | Self::CallValue { .. } | Self::InterpreterExit { .. } | Self::Stop => { - Vec::new() - } + Self::Return + | Self::CallValue { .. } + | Self::CallScript { .. } + | Self::InterpreterExit { .. } + | Self::Stop => Vec::new(), Self::Jump { target_ip } => vec![*target_ip], Self::ConditionalJump { target_ip, @@ -129,6 +137,16 @@ pub(crate) fn build_cfg(program: &Program) -> Result { call_ip: ip, resume_ip: next_ip, }), + OpCode::CallScript => Some(AotBlockTerminal::CallScript { + prototype_id: u32::from_le_bytes( + code[ip + 1..ip + 5] + .try_into() + .expect("callscript operand width validated by bounds decoder"), + ), + argc: code[ip + 5], + call_ip: ip, + resume_ip: next_ip, + }), _ if next_ip == code.len() => Some(AotBlockTerminal::Stop), _ if Some(next_ip) == next_block_start => { validate_fallthrough_region(®ions, ip, next_ip)?; @@ -183,7 +201,7 @@ fn collect_block_starts( starts.insert(next_ip); } } - OpCode::CallValue => { + OpCode::CallValue | OpCode::CallScript => { if next_ip < code.len() { starts.insert(next_ip); } diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index 96718ba7..39593028 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -21,10 +21,11 @@ use crate::vm::native::{ clear_value_slot_entry_address, clone_value_signature, clone_value_to_slot_entry_address, collection_get_signature, collection_mutation_signature, collection_set_entry_address, copy_bytes_entry_address, copy_bytes_signature, detect_native_stack_layout, - enter_call_value_entry_address, enter_call_value_signature, entry_signature, - frame_state_entry_address, frame_state_signature, free_buffer_signature, helper_entry_offset, - helper_signature, init_null_value_slot_entry_address, jump_with_status, - leave_frame_entry_address, leave_frame_signature, pack_shared_signature, resolve_offsets, + enter_call_script_entry_address, enter_call_script_signature, enter_call_value_entry_address, + enter_call_value_signature, entry_signature, frame_state_entry_address, frame_state_signature, + free_buffer_signature, helper_entry_offset, helper_signature, + init_null_value_slot_entry_address, jump_with_status, leave_frame_entry_address, + leave_frame_signature, pack_shared_signature, resolve_offsets, restore_active_exit_state_entry_address, restore_exit_signature, restore_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, @@ -332,6 +333,7 @@ struct AotDeoptHelperRefs { interrupt_ref: cranelift_codegen::ir::SigRef, frame_state_ref: cranelift_codegen::ir::SigRef, enter_call_value_ref: cranelift_codegen::ir::SigRef, + enter_call_script_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, clone_value_ref: cranelift_codegen::ir::SigRef, value_eq_ref: cranelift_codegen::ir::SigRef, @@ -349,6 +351,7 @@ struct AotDeoptHelperAddrs { aot_interrupt: usize, frame_state: usize, enter_call_value: usize, + enter_call_script: usize, leave_frame: usize, clone_value: usize, value_eq: usize, @@ -500,6 +503,7 @@ fn compile_ssa( let alloc_buffer_sig = alloc_buffer_signature(pointer_type, call_conv); let frame_state_sig = frame_state_signature(pointer_type, call_conv); let enter_call_value_sig = enter_call_value_signature(pointer_type, call_conv); + let enter_call_script_sig = enter_call_script_signature(pointer_type, call_conv); let leave_frame_sig = leave_frame_signature(pointer_type, call_conv); let free_buffer_sig = free_buffer_signature(pointer_type, call_conv); let pack_shared_sig = pack_shared_signature(pointer_type, call_conv); @@ -530,6 +534,7 @@ fn compile_ssa( aot_interrupt: aot_call_boundary_interrupt_entry_address(), frame_state: frame_state_entry_address(), enter_call_value: enter_call_value_entry_address(), + enter_call_script: enter_call_script_entry_address(), leave_frame: leave_frame_entry_address(), clone_value: clone_value_to_slot_entry_address(), value_eq: value_eq_entry_address(), @@ -587,6 +592,7 @@ fn compile_ssa( interrupt_ref: b.import_signature(interrupt_sig), frame_state_ref: b.import_signature(frame_state_sig), enter_call_value_ref: b.import_signature(enter_call_value_sig), + enter_call_script_ref: b.import_signature(enter_call_script_sig), leave_frame_ref: b.import_signature(leave_frame_sig), clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), @@ -1622,6 +1628,58 @@ fn lower_aot_ssa_terminator( let status = b.inst_results(call)[0]; jump_with_status(b, exit_block, status); } + AotSsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + stack, + locals, + } => { + materialize_state_to_vm( + b, + vm_ptr, + exit_block, + pointer_type, + layout, + helper_refs, + helper_addrs, + stack, + locals, + values, + *call_ip, + )?; + emit_call_boundary_interrupt( + b, + vm_ptr, + helper_refs.interrupt_ref, + helper_addrs.aot_interrupt, + pointer_type, + exit_block, + )?; + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.enter_call_script)?; + let prototype_id = b.ins().iconst(types::I64, i64::from(*prototype_id)); + let argc = b.ins().iconst(types::I64, i64::from(*argc)); + let call_ip = b.ins().iconst( + types::I64, + i64::try_from(*call_ip).map_err(|_| { + AotCompileError::Codegen("callscript ip does not fit i64".to_string()) + })?, + ); + let resume_ip = b.ins().iconst( + types::I64, + i64::try_from(*resume_ip).map_err(|_| { + AotCompileError::Codegen("callscript resume ip does not fit i64".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_script_ref, + helper_ptr, + &[vm_ptr, prototype_id, argc, call_ip, resume_ip], + ); + let status = b.inst_results(call)[0]; + jump_with_status(b, exit_block, status); + } AotSsaTerminator::InterpreterBoundary { ip, stack, locals } => { materialize_state_to_vm( b, diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index da1921ae..5bf202f0 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -325,6 +325,15 @@ fn lower_block( kind: "script callable frame operation requires runtime lowering", }); } + OpCode::CallScript => { + // `CallScript` is lowered as an explicit terminal; a + // mid-block occurrence means the CFG is inconsistent. + return Err(AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "unexpected script call terminal in lowered instruction stream", + }); + } OpCode::Ret | OpCode::Br | OpCode::Brfalse => { return Err(AotLowerError::InvalidImmediate { ip, @@ -505,6 +514,16 @@ fn is_explicit_terminal_opcode( && read_u8(code, ip + 1) == Some(*argc) && ip == *call_ip && next_ip == *resume_ip), + AotBlockTerminal::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => Ok(opcode == OpCode::CallScript + && read_u32(code, ip + 1) == Some(*prototype_id) + && read_u8(code, ip + 5) == Some(*argc) + && ip == *call_ip + && next_ip == *resume_ip), AotBlockTerminal::InterpreterExit { exit_ip } => { Ok(opcode == OpCode::CallValue && ip == *exit_ip) } diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index b7c0e9e9..cbe70092 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -420,6 +420,14 @@ pub(crate) enum AotSsaTerminator { stack: Vec, locals: Vec, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + stack: Vec, + locals: Vec, + }, InterpreterBoundary { ip: usize, stack: Vec, @@ -744,6 +752,7 @@ fn verify_terminator( } AotSsaTerminator::CallBoundary { stack, locals, .. } | AotSsaTerminator::CallValue { stack, locals, .. } + | AotSsaTerminator::CallScript { stack, locals, .. } | AotSsaTerminator::InterpreterBoundary { stack, locals, .. } | AotSsaTerminator::Return { stack, locals, .. } => { for materialization in stack.iter().chain(locals.iter()) { @@ -852,6 +861,14 @@ enum ProcessResult { frame: Frame, resume_frame: Frame, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + frame: Frame, + resume_frame: Frame, + }, InterpreterBoundary { ip: usize, frame: Frame, @@ -961,6 +978,9 @@ impl<'a> Builder<'a> { } if let AotBlockTerminal::CallValue { call_ip, resume_ip, .. + } + | AotBlockTerminal::CallScript { + call_ip, resume_ip, .. } = block.terminal { checkpoint_ips.insert(call_ip); @@ -1114,6 +1134,21 @@ impl<'a> Builder<'a> { stack: materialize_values(&frame.stack), locals: materialize_values(&frame.locals), }, + ProcessResult::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + frame, + resume_frame: _, + } => AotSsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + stack: materialize_values(&frame.stack), + locals: materialize_values(&frame.locals), + }, ProcessResult::InterpreterBoundary { ip, frame } => { AotSsaTerminator::InterpreterBoundary { ip, @@ -1215,6 +1250,13 @@ impl<'a> Builder<'a> { } => { self.merge_shape(resume_ip, resume_frame.shape(), &mut queue)?; } + ProcessResult::CallScript { + resume_ip, + resume_frame, + .. + } => { + self.merge_shape(resume_ip, resume_frame.shape(), &mut queue)?; + } ProcessResult::InterpreterBoundary { .. } | ProcessResult::Return { .. } | ProcessResult::Stop { .. } => {} @@ -1507,6 +1549,31 @@ impl<'a> Builder<'a> { resume_frame, }) } + AotBlockTerminal::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => { + // `CallScript` pushes no callable operand: the arguments are + // exactly the top `argc` stack values. + let mut resume_frame = frame.clone(); + for _ in 0..usize::from(*argc) { + resume_frame.pop(*call_ip, "callscript")?; + } + let return_repr = value_type_repr(operand_types_at(self.program, *call_ip).1); + resume_frame.stack.push(FrameValue { + value: AotSsaValue::new(AotSsaValueId::new(0), return_repr), + }); + Ok(ProcessResult::CallScript { + prototype_id: *prototype_id, + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + frame: frame.clone(), + resume_frame, + }) + } AotBlockTerminal::Return => Ok(ProcessResult::Return { ip: block .terminal_ip @@ -1564,7 +1631,8 @@ fn terminal_ip(block: &super::ir::AotIrBlock) -> Option { block.end_ip.checked_sub(5) } AotBlockTerminal::Fallthrough { .. } | AotBlockTerminal::Stop => None, - AotBlockTerminal::CallValue { call_ip, .. } => Some(call_ip), + AotBlockTerminal::CallValue { call_ip, .. } + | AotBlockTerminal::CallScript { call_ip, .. } => Some(call_ip), AotBlockTerminal::InterpreterExit { exit_ip } => Some(exit_ip), } } diff --git a/src/vm/jit/inline.rs b/src/vm/jit/inline.rs index 0a0cc1e0..36b86cf2 100644 --- a/src/vm/jit/inline.rs +++ b/src/vm/jit/inline.rs @@ -57,12 +57,63 @@ pub(crate) fn classify_static_inline_candidate( if bindings.next().is_some() { return Err(InlineRejectReason::PolymorphicTarget); } - if caller_prototype_id == Some(binding.prototype_id) { + classify_prototype_inline_candidate( + program, + binding.prototype_id, + caller_prototype_id, + argc, + remaining_trace_budget, + ) +} + +/// Classify an inline candidate for a static `CallScript` call site. +/// +/// The prototype identity comes from the instruction operands instead of a +/// runtime callable local, so no `root_callable_bindings` lookup or +/// polymorphic guard is needed. Environment-free eligibility mirrors the +/// interpreter contract: `CallScript` can never supply captures or a self +/// binding, so such prototypes are rejected here exactly like +/// `CallScriptRequiresEnvironment` at runtime. +pub(crate) fn classify_direct_inline_candidate( + program: &Program, + caller_frame_key: u64, + caller_prototype_id: Option, + prototype_id: u32, + argc: u8, + remaining_trace_budget: usize, +) -> Result { + if caller_frame_key != ROOT_FRAME_KEY { + return Err(InlineRejectReason::NonRootCaller); + } + let prototype = program + .callable_prototypes + .get(prototype_id as usize) + .ok_or(InlineRejectReason::UnknownTarget)?; + if prototype.self_slot.is_some() { + return Err(InlineRejectReason::CapturedCallable); + } + classify_prototype_inline_candidate( + program, + prototype_id, + caller_prototype_id, + argc, + remaining_trace_budget, + ) +} + +fn classify_prototype_inline_candidate( + program: &Program, + prototype_id: u32, + caller_prototype_id: Option, + argc: u8, + remaining_trace_budget: usize, +) -> Result { + if caller_prototype_id == Some(prototype_id) { return Err(InlineRejectReason::Recursive); } let prototype = program .callable_prototypes - .get(binding.prototype_id as usize) + .get(prototype_id as usize) .ok_or(InlineRejectReason::UnknownTarget)?; if prototype.kind != CallableKind::FunctionItem || !prototype.capture_slots.is_empty() @@ -99,7 +150,7 @@ pub(crate) fn classify_static_inline_candidate( return Err(InlineRejectReason::TraceBudgetExceeded); } Ok(InlineCandidate { - prototype_id: binding.prototype_id, + prototype_id, entry_ip, end_ip, parameter_slots: prototype.parameter_slots.clone(), @@ -173,6 +224,9 @@ fn scan_inline_region( } } OpCode::CallValue => return Err(InlineRejectReason::NestedScriptCall), + // `CallScript` is a nested script call too; inline analysis + // support for the direct path lands with backend parity. + OpCode::CallScript => return Err(InlineRejectReason::NestedScriptCall), OpCode::Call => { let index = read_u16(&program.code, &mut ip).ok_or(InlineRejectReason::UnknownTarget)?; diff --git a/src/vm/jit/ir.rs b/src/vm/jit/ir.rs index 5b3e50f6..3c8c16fd 100644 --- a/src/vm/jit/ir.rs +++ b/src/vm/jit/ir.rs @@ -563,6 +563,15 @@ pub(crate) enum SsaTerminator { resume_ip: usize, exit: SsaExitId, }, + /// Static direct script-function call: the callee prototype is part of + /// the instruction, so no runtime callable value is consumed. + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + exit: SsaExitId, + }, } #[derive(Clone, Debug, PartialEq)] @@ -1044,7 +1053,8 @@ fn verify_terminator( } SsaTerminator::Exit { exit } | SsaTerminator::Return { exit } - | SsaTerminator::CallValue { exit, .. } => { + | SsaTerminator::CallValue { exit, .. } + | SsaTerminator::CallScript { exit, .. } => { if !exit_ids.contains(exit) { return Err(SsaVerifyError::UnknownExit(*exit)); } @@ -1277,6 +1287,15 @@ fn render_terminator(terminator: &SsaTerminator) -> String { resume_ip, exit, } => format!("call_value argc={argc} call_ip={call_ip} resume_ip={resume_ip} {exit}"), + SsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + exit, + } => format!( + "call_script prototype={prototype_id} argc={argc} call_ip={call_ip} resume_ip={resume_ip} {exit}" + ), } } diff --git a/src/vm/jit/native/lower.rs b/src/vm/jit/native/lower.rs index 5eb8a5af..d13c9913 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -17,6 +17,7 @@ use crate::vm::native::{ clear_bridge_error_entry_address, clear_value_slot_entry_address, clone_value_signature, clone_value_to_slot_entry_address, collection_get_signature, collection_predicate_signature, copy_bytes_entry_address, copy_bytes_signature, detect_native_stack_layout, + enter_call_script_inherited_entry_address, enter_call_script_inherited_signature, enter_call_value_inherited_entry_address, enter_call_value_inherited_signature, entry_signature, frame_state_entry_address, frame_state_signature, free_buffer_signature, jump_with_status, leave_frame_inherited_entry_address, leave_frame_inherited_signature, @@ -619,6 +620,7 @@ fn try_compile_ssa_trace( let frame_state_sig = frame_state_signature(pointer_type, call_conv); let leave_frame_sig = leave_frame_inherited_signature(pointer_type, call_conv); let enter_call_value_sig = enter_call_value_inherited_signature(pointer_type, call_conv); + let enter_call_script_sig = enter_call_script_inherited_signature(pointer_type, call_conv); let resume_linked_trace_sig = entry_signature(pointer_type, call_conv); let string_contains_sig = string_contains_signature(pointer_type, call_conv); @@ -702,6 +704,7 @@ fn try_compile_ssa_trace( restore_virtual_frame_ref: b.import_signature(restore_virtual_frame_sig), leave_frame_ref: b.import_signature(leave_frame_sig), enter_call_value_ref: b.import_signature(enter_call_value_sig), + enter_call_script_ref: b.import_signature(enter_call_script_sig), resume_linked_trace_ref: b.import_signature(resume_linked_trace_sig), }; @@ -728,6 +731,7 @@ fn try_compile_ssa_trace( restore_virtual_frame: restore_virtual_frame_entry_address(), leave_frame: leave_frame_inherited_entry_address(), enter_call_value: enter_call_value_inherited_entry_address(), + enter_call_script: enter_call_script_inherited_entry_address(), resume_linked_trace: resume_linked_trace_entry_address(), }; @@ -780,7 +784,30 @@ fn try_compile_ssa_trace( call_ip, resume_ip, exit, - }) => Some((*exit, (*argc, *call_ip, *resume_ip))), + }) => Some(( + *exit, + SsaCallExit { + prototype_id: None, + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + }, + )), + Some(SsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + exit, + }) => Some(( + *exit, + SsaCallExit { + prototype_id: Some(*prototype_id), + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + }, + )), _ => None, }) .collect::>(); @@ -1034,18 +1061,38 @@ fn try_compile_ssa_trace( }, )?; lower_ssa_exit_block(&mut b, lower_ctx, exit, spec, SsaExitAction::Return)?; - if let Some((argc, call_ip, resume_ip)) = call_value_exits.get(&exit.id).copied() { - lower_ssa_exit_block( - &mut b, - lower_ctx, - exit, - spec, - SsaExitAction::CallValue { - argc, - call_ip, - resume_ip, - }, - )?; + if let Some(call_exit) = call_value_exits.get(&exit.id).copied() { + let SsaCallExit { + prototype_id, + argc, + call_ip, + resume_ip, + } = call_exit; + match prototype_id { + None => lower_ssa_exit_block( + &mut b, + lower_ctx, + exit, + spec, + SsaExitAction::CallValue { + argc, + call_ip, + resume_ip, + }, + )?, + Some(prototype_id) => lower_ssa_exit_block( + &mut b, + lower_ctx, + exit, + spec, + SsaExitAction::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + }, + )?, + } } if spec.interrupt_block.is_some() { lower_ssa_exit_block(&mut b, lower_ctx, exit, spec, SsaExitAction::InterruptYield)?; @@ -1109,6 +1156,16 @@ struct SsaExitLowering { inputs: Vec, } +#[derive(Clone, Copy)] +struct SsaCallExit { + /// `None` for dynamic `CallValue`; `Some(prototype_id)` for static + /// `CallScript` boundaries. + prototype_id: Option, + argc: u8, + call_ip: usize, + resume_ip: usize, +} + #[derive(Clone, Copy)] enum SsaExitAction { TraceExit { @@ -1120,6 +1177,12 @@ enum SsaExitAction { call_ip: usize, resume_ip: usize, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterruptYield, } @@ -1147,6 +1210,7 @@ struct SsaDeoptHelperRefs { restore_virtual_frame_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, enter_call_value_ref: cranelift_codegen::ir::SigRef, + enter_call_script_ref: cranelift_codegen::ir::SigRef, resume_linked_trace_ref: cranelift_codegen::ir::SigRef, } @@ -1175,6 +1239,7 @@ struct SsaDeoptHelperAddrs { restore_virtual_frame: usize, leave_frame: usize, enter_call_value: usize, + enter_call_script: usize, resume_linked_trace: usize, } @@ -1602,7 +1667,8 @@ fn borrowed_array_get_outputs(ssa: &SsaTrace) -> BTreeSet { } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } } for exit in &ssa.exits { @@ -1816,7 +1882,8 @@ fn ssa_backedge_targets( } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } targets } @@ -4234,7 +4301,7 @@ fn lower_ssa_terminator( let args = ssa_block_args(args); b.ins().jump(spec.halted_block, &args); } - SsaTerminator::CallValue { exit, .. } => { + SsaTerminator::CallValue { exit, .. } | SsaTerminator::CallScript { exit, .. } => { let spec = exit_specs.get(exit).ok_or_else(|| { VmError::JitNative("SSA call-value exit lowering missing".to_string()) })?; @@ -4487,6 +4554,41 @@ fn ssa_exit_action_status( ); Ok(b.inst_results(call)[0]) } + SsaExitAction::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => { + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.enter_call_script)?; + let prototype_id = b.ins().iconst(types::I64, i64::from(prototype_id)); + let argc = b.ins().iconst(types::I64, i64::from(argc)); + let call_ip = b.ins().iconst( + types::I64, + i64::try_from(call_ip).map_err(|_| { + VmError::JitNative("SSA call-script ip out of range".to_string()) + })?, + ); + let resume_ip = b.ins().iconst( + types::I64, + i64::try_from(resume_ip).map_err(|_| { + VmError::JitNative("SSA call-script resume ip out of range".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_script_ref, + helper_ptr, + &[ + vm_ptr, + prototype_id, + argc, + call_ip, + resume_ip, + inherited_state_ptr, + ], + ); + Ok(b.inst_results(call)[0]) + } SsaExitAction::TraceExit { allow_link_handoff } => { if allow_link_handoff { let helper_ptr = @@ -4568,7 +4670,7 @@ fn lower_ssa_exit_block( let block = match action { SsaExitAction::TraceExit { .. } => spec.trace_exit_block, SsaExitAction::Return => spec.halted_block, - SsaExitAction::CallValue { .. } => spec + SsaExitAction::CallValue { .. } | SsaExitAction::CallScript { .. } => spec .call_value_block .ok_or_else(|| VmError::JitNative("SSA call-value exit block missing".to_string()))?, SsaExitAction::InterruptYield => spec diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index 769d14da..c2b35c96 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -1,16 +1,21 @@ use std::fmt; +use std::sync::Arc; use crate::builtins::BuiltinFunction; +use crate::bytecode::CallableValue; use crate::compiler::TypeSchema; use crate::vm::{OpCode, Program, Value, ValueType, checked_int_div}; use super::JitTraceTerminal; use super::builtin_spec::{self, InputRepr, OutputKind}; use super::deopt::materialize_ssa_values; -use super::inline::{InlineCandidate, InlineRejectReason, classify_static_inline_candidate}; +use super::inline::{ + InlineCandidate, InlineRejectReason, classify_direct_inline_candidate, + classify_static_inline_candidate, +}; use super::ir::{ - SsaBranchTarget, SsaInstKind, SsaMaterialization, SsaTerminator, SsaTrace, SsaTraceBuilder, - SsaValue, SsaValueId, SsaValueRepr, VirtualFrameSnapshot, + SsaBlockId, SsaBranchTarget, SsaInstKind, SsaMaterialization, SsaTerminator, SsaTrace, + SsaTraceBuilder, SsaValue, SsaValueId, SsaValueRepr, VirtualFrameSnapshot, }; pub(super) const MAX_PROFITABLE_FRAME_LOCALS: usize = 64; @@ -212,11 +217,14 @@ impl AnalysisFrame { entry_stack_depth: usize, local_count: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, ) -> Self { Self { stack: vec![ValueInfo::tagged(); entry_stack_depth], locals: (0..local_count) - .map(|local| entry_local_info(program, local, entry_local_types)) + .map(|local| { + entry_local_info(program, local, entry_local_types, entry_callable_prototypes) + }) .collect(), } } @@ -250,10 +258,31 @@ fn entry_local_info( program: &Program, local: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, ) -> ValueInfo { let known_type = entry_local_types .and_then(|types| types.get(local)) .copied() + .or_else(|| { + // The runtime observed a callable in this slot at trace entry: + // mirror `enter_script_frame`'s inheritance of callable-valued + // caller locals at the same slot index. + entry_callable_prototypes + .and_then(|prototypes| prototypes.get(local)) + .copied() + .flatten() + .map(|_| ValueType::Callable) + }) + .or_else(|| { + // Root callable binding slots always hold environment-free + // callables at frame entry: mirror `enter_script_frame`'s fresh + // binding re-initialization even for programs without a type map. + program + .root_callable_bindings + .iter() + .any(|binding| usize::from(binding.local_slot) == local) + .then_some(ValueType::Callable) + }) .or_else(|| { program .type_map @@ -265,6 +294,164 @@ fn entry_local_info( known_type.map_or_else(ValueInfo::tagged, ValueInfo::tagged_typed) } +/// Build the callee-local SSA state for an inline frame, mirroring the +/// interpreter's `enter_script_frame` initialization: +/// +/// 1. every root callable binding slot is freshly bound to an +/// environment-free callable of the binding's prototype (never copied +/// from the caller's current slot value); +/// 2. every remaining callable-valued caller local is inherited at the same +/// slot index; +/// 3. a root binding outside the callee frame rejects the trace, matching +/// the interpreter's `InvalidFrameState` instead of silently skipping. +/// +/// The second element of the returned pair lists the slots inherited from +/// the caller frame (step 2), so the caller can record entry guards for +/// callable-valued inherited locals. +fn init_inline_callee_locals( + builder: &mut SsaTraceBuilder, + current_block: SsaBlockId, + ip: usize, + program: &Program, + frame_local_count: usize, + frame: &SymbolicFrame, +) -> Result<(Vec, Vec), TraceRecordError> { + let null = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::Constant(Value::Null), + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + let null = SymbolicValue { + value: null, + info: ValueInfo::tagged_typed(ValueType::Null), + }; + let mut callee_locals = vec![null; frame_local_count]; + let mut binding_slots = Vec::with_capacity(program.root_callable_bindings.len()); + for binding in &program.root_callable_bindings { + let slot = usize::from(binding.local_slot); + if slot >= callee_locals.len() { + return Err(TraceRecordError::UnsupportedTrace( + "root callable binding is outside the script frame".to_string(), + )); + } + binding_slots.push(slot); + let kind = program + .callable_prototypes + .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) + .ok_or(TraceRecordError::UnsupportedTrace( + "root callable binding references an unknown prototype".to_string(), + ))?; + let fresh = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::Constant(Value::Callable(Arc::new(CallableValue { + prototype_id: binding.prototype_id, + kind, + env: None, + }))), + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + callee_locals[slot] = SymbolicValue { + value: fresh, + info: ValueInfo::tagged_typed(ValueType::Callable), + }; + } + let mut inherited_callable_slots = Vec::new(); + for (slot, local) in frame + .locals + .iter() + .copied() + .enumerate() + .take(callee_locals.len()) + { + if local.info.known_type == Some(ValueType::Callable) && !binding_slots.contains(&slot) { + callee_locals[slot] = local; + inherited_callable_slots.push(slot); + } + } + Ok((callee_locals, inherited_callable_slots)) +} + +/// Record entry guards for callable-valued caller locals inherited into an +/// inline callee frame. +/// +/// The interpreter's `enter_script_frame` copies every callable-valued +/// caller local into the callee frame at the same slot index, and the +/// inline simulation mirrors that inheritance. The callee can specialize on +/// the inherited value's recorded type (for example a folded `typeof`), so +/// when the callable type comes from the trace-entry observation and the +/// caller slot was not rewritten on the recorded path, the trace must treat +/// the observed prototype as an entry contract: cache lookup then rejects +/// the trace after an interpreter handoff rewrote the slot, and the +/// loop-header guard check rejects native loops that rewrite it. +fn record_inherited_callable_guards( + entry_callable_guards: &mut Vec<(u8, u32)>, + entry_callable_prototypes: Option<&[Option]>, + frame: &SymbolicFrame, + inherited_callable_slots: &[usize], +) { + for &slot in inherited_callable_slots { + let Some(prototype_id) = entry_callable_prototypes + .and_then(|prototypes| prototypes.get(slot)) + .copied() + .flatten() + else { + continue; + }; + if frame.dirty_locals.get(slot).copied().unwrap_or(false) { + // The recorded path wrote the slot before the call site, so the + // runtime value is the trace's own write and cannot drift from + // the recorded type. + continue; + } + let entry_guard = (slot as u8, prototype_id); + if !entry_callable_guards.contains(&entry_guard) { + entry_callable_guards.push(entry_guard); + } + } +} + +/// Type-only twin of [`init_inline_callee_locals`] for the loop-header +/// analysis pass, which tracks `ValueInfo` without SSA values. Out-of-frame +/// root bindings are skipped here (the SSA build rejects them); the analysis +/// must stay conservative so its own checks (for example mutated inline +/// callable sources) keep firing. +fn analysis_inline_callee_locals( + program: &Program, + frame_local_count: usize, + frame: &AnalysisFrame, +) -> Vec { + let null = ValueInfo::tagged_typed(ValueType::Null); + let mut callee_locals = vec![null; frame_local_count]; + let mut binding_slots = Vec::with_capacity(program.root_callable_bindings.len()); + for binding in &program.root_callable_bindings { + let slot = usize::from(binding.local_slot); + if slot >= callee_locals.len() { + continue; + } + binding_slots.push(slot); + callee_locals[slot] = ValueInfo::tagged_typed(ValueType::Callable); + } + for (slot, local) in frame + .locals + .iter() + .copied() + .enumerate() + .take(callee_locals.len()) + { + if local.known_type == Some(ValueType::Callable) && !binding_slots.contains(&slot) { + callee_locals[slot] = local; + } + } + callee_locals +} + #[derive(Clone, Copy, Debug, PartialEq)] struct SymbolicValue { value: SsaValue, @@ -553,6 +740,12 @@ enum DecodedOp { argc: u8, resume_ip: usize, }, + CallScript { + ip: usize, + prototype_id: u32, + argc: u8, + resume_ip: usize, + }, } impl DecodedOp { @@ -572,7 +765,8 @@ impl DecodedOp { | Self::Brfalse { ip, .. } | Self::Br { ip, .. } | Self::Call { ip, .. } - | Self::CallValue { ip, .. } => ip, + | Self::CallValue { ip, .. } + | Self::CallScript { ip, .. } => ip, } } @@ -599,7 +793,8 @@ impl DecodedOp { | Self::Dup { .. } | Self::Br { .. } | Self::Call { .. } - | Self::CallValue { .. } => false, + | Self::CallValue { .. } + | Self::CallScript { .. } => false, Self::Stloc { .. } | Self::Neg { .. } | Self::Not { .. } @@ -898,6 +1093,19 @@ impl<'a> TraceCursor<'a> { argc, resume_ip: self.ip, } + } else if opcode == OpCode::CallScript as u8 { + self.recorded_ops += 1; + let prototype_id = read_u32(&self.program.code, &mut self.ip).ok_or( + TraceRecordError::InvalidImmediate("callscript prototype id"), + )?; + let argc = read_u8(&self.program.code, &mut self.ip) + .ok_or(TraceRecordError::InvalidImmediate("callscript argc"))?; + DecodedOp::CallScript { + ip: instr_ip, + prototype_id, + argc, + resume_ip: self.ip, + } } else { return Err(TraceRecordError::UnsupportedOpcode(opcode)); }; @@ -951,6 +1159,7 @@ pub(crate) fn record_trace_with_local_count( entry_stack_depth, local_count, entry_local_types, + entry_callable_prototypes, max_trace_len, non_yielding_host_imports, )?; @@ -975,7 +1184,12 @@ pub(crate) fn record_trace_with_local_count( .append_param(entry, SsaValueRepr::Tagged, format!("local{local}")) .map(|value| SymbolicValue { value, - info: entry_local_info(program, local, entry_local_types), + info: entry_local_info( + program, + local, + entry_local_types, + entry_callable_prototypes, + ), }) .map_err(|err| TraceRecordError::InvalidIr(err.to_string())) }) @@ -1563,25 +1777,20 @@ pub(crate) fn record_trace_with_local_count( let mut operands = frame.stack.split_off(operand_base); let _callable = operands.remove(0); let prototype = &program.callable_prototypes[candidate.prototype_id as usize]; - let null = builder - .append_value_inst( - current_block, - ip, - SsaValueRepr::Tagged, - SsaInstKind::Constant(Value::Null), - ) - .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; - let null = SymbolicValue { - value: null, - info: ValueInfo::tagged_typed(ValueType::Null), - }; - let mut callee_locals = vec![null; prototype.frame_local_count]; - for binding in &program.root_callable_bindings { - let slot = usize::from(binding.local_slot); - if slot < callee_locals.len() && slot < frame.locals.len() { - callee_locals[slot] = frame.locals[slot]; - } - } + let (mut callee_locals, inherited_callable_slots) = init_inline_callee_locals( + &mut builder, + current_block, + ip, + program, + prototype.frame_local_count, + &frame, + )?; + record_inherited_callable_guards( + &mut entry_callable_guards, + entry_callable_prototypes, + &frame, + &inherited_callable_slots, + ); for (slot, mut argument) in candidate.parameter_slots.iter().zip(operands) { if argument.info.repr == SsaValueRepr::Tagged { let cloned = builder @@ -1636,6 +1845,140 @@ pub(crate) fn record_trace_with_local_count( terminal = Some(JitTraceTerminal::CallValue); break; } + DecodedOp::CallScript { + ip, + prototype_id, + argc, + resume_ip, + } => { + if frame.stack.len() < usize::from(argc) { + return Err(TraceRecordError::StackUnderflow); + } + let caller_prototype_id = (caller_frame_key != crate::vm::native::ROOT_FRAME_KEY) + .then_some(caller_frame_key as u32); + // The prototype identity is static: no callable local is + // loaded and no polymorphic entry guard is required. + let candidate = classify_direct_inline_candidate( + program, + caller_frame_key, + caller_prototype_id, + prototype_id, + argc, + max_trace_len.saturating_sub(cursor.recorded_ops), + ); + let inline_reject_reason = candidate.as_ref().err().copied(); + if inline_frame.is_none() + && let Ok(candidate) = candidate + { + let prototype = &program.callable_prototypes[prototype_id as usize]; + let argument_start = frame.stack.len() - usize::from(argc); + let schema_guard = append_inline_argument_schema_guards( + &mut builder, + current_block, + ip, + &frame.stack[argument_start..], + prototype.schema.as_ref(), + )?; + if let Some(schema_guard) = schema_guard { + let schema_exit = + add_symbolic_exit(&mut builder, ip, &frame, inline_frame.as_ref()); + let (guarded_block, guarded_frame, guard_args) = + continue_with_inline_frame( + &mut builder, + &frame, + &mut inline_frame, + "inline_callable_schema", + )?; + builder + .set_terminator( + current_block, + SsaTerminator::BranchBool { + condition: schema_guard, + if_true: SsaBranchTarget::Block { + target: guarded_block, + args: guard_args, + }, + if_false: SsaBranchTarget::Exit(schema_exit), + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + current_block = guarded_block; + frame = guarded_frame; + } + + // `CallScript` pushes no callable operand: the arguments + // are exactly the top `argc` stack values. + let operand_base = frame.stack.len() - usize::from(argc); + let operands = frame.stack.split_off(operand_base); + let (mut callee_locals, inherited_callable_slots) = init_inline_callee_locals( + &mut builder, + current_block, + ip, + program, + prototype.frame_local_count, + &frame, + )?; + record_inherited_callable_guards( + &mut entry_callable_guards, + entry_callable_prototypes, + &frame, + &inherited_callable_slots, + ); + for (slot, mut argument) in candidate.parameter_slots.iter().zip(operands) { + if argument.info.repr == SsaValueRepr::Tagged { + let cloned = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::CloneTagged { + input: argument.value.id, + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + argument.value = cloned; + } + callee_locals[usize::from(*slot)] = argument; + } + op_names.push(format!("inline_call:{prototype_id}")); + let caller = std::mem::replace( + &mut frame, + SymbolicFrame::new(Vec::new(), callee_locals), + ); + inline_frame = Some(InlineRecorderFrame { + candidate: candidate.clone(), + call_ip: ip, + return_ip: resume_ip, + caller, + }); + cursor.jump_to(candidate.entry_ip)?; + has_call = true; + continue; + } + if let Some(reason) = inline_reject_reason { + op_names.push(format!("inline_reject:{reason:?}")); + } else if inline_frame.is_some() { + op_names.push("inline_reject:NestedCallable".to_string()); + } + op_names.push("call_script".to_string()); + let exit = add_symbolic_exit(&mut builder, ip, &frame, inline_frame.as_ref()); + builder + .set_terminator( + current_block, + SsaTerminator::CallScript { + prototype_id, + argc, + call_ip: ip, + resume_ip, + exit, + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + has_call = true; + has_yielding_call = true; + terminal = Some(JitTraceTerminal::CallScript); + break; + } DecodedOp::Call { ip, index, @@ -1754,7 +2097,11 @@ pub(crate) fn record_trace_with_local_count( } let terminal = terminal.ok_or(TraceRecordError::MissingTerminal)?; - if loop_header_plan.is_some() + // A native loop re-iterates the recorded body without a cache lookup, so + // a guarded callable source local must stay untouched by the recorded + // path. This applies to every `LoopBack` trace, including loop-header + // plans the analysis pass declined to build. + if matches!(terminal, JitTraceTerminal::LoopBack) && entry_callable_guards.iter().any(|(local, _)| { let local = usize::from(*local); frame.dirty_locals.get(local).copied().unwrap_or(false) @@ -1791,11 +2138,18 @@ fn infer_loop_header_plan( entry_stack_depth: usize, local_count: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, max_trace_len: usize, non_yielding_host_imports: &[bool], ) -> Result, TraceRecordError> { let mut cursor = TraceCursor::new(program, root_ip, max_trace_len); - let mut frame = AnalysisFrame::new(program, entry_stack_depth, local_count, entry_local_types); + let mut frame = AnalysisFrame::new( + program, + entry_stack_depth, + local_count, + entry_local_types, + entry_callable_prototypes, + ); let mut entry_use = vec![EntryUseState::Untouched; local_count]; let mut local_written = vec![false; local_count]; let mut inline_frame: Option<(AnalysisFrame, usize)> = None; @@ -2011,14 +2365,47 @@ fn infer_loop_header_plan( let operand_base = frame.stack.len() - usize::from(argc) - 1; let mut operands = frame.stack.split_off(operand_base); let _callable = operands.remove(0); - let null = ValueInfo::tagged_typed(ValueType::Null); - let mut callee_locals = vec![null; prototype.frame_local_count]; - for binding in &program.root_callable_bindings { - let slot = usize::from(binding.local_slot); - if slot < callee_locals.len() && slot < frame.locals.len() { - callee_locals[slot] = frame.locals[slot]; - } + let mut callee_locals = + analysis_inline_callee_locals(program, prototype.frame_local_count, &frame); + for (slot, argument) in candidate.parameter_slots.iter().zip(operands) { + callee_locals[usize::from(*slot)] = argument; + } + let caller = std::mem::replace( + &mut frame, + AnalysisFrame { + stack: Vec::new(), + locals: callee_locals, + }, + ); + inline_frame = Some((caller, resume_ip)); + cursor.jump_to(candidate.entry_ip)?; + } + DecodedOp::CallScript { + prototype_id, + argc, + resume_ip, + .. + } => { + if inline_frame.is_some() || frame.stack.len() < usize::from(argc) { + return Ok(None); } + let caller_prototype_id = (caller_frame_key != crate::vm::native::ROOT_FRAME_KEY) + .then_some(caller_frame_key as u32); + let Ok(candidate) = classify_direct_inline_candidate( + program, + caller_frame_key, + caller_prototype_id, + prototype_id, + argc, + max_trace_len.saturating_sub(cursor.recorded_ops), + ) else { + return Ok(None); + }; + let prototype = &program.callable_prototypes[prototype_id as usize]; + let operand_base = frame.stack.len() - usize::from(argc); + let operands = frame.stack.split_off(operand_base); + let mut callee_locals = + analysis_inline_callee_locals(program, prototype.frame_local_count, &frame); for (slot, argument) in candidate.parameter_slots.iter().zip(operands) { callee_locals[usize::from(*slot)] = argument; } @@ -5036,7 +5423,11 @@ mod tests { kind: CallableKind::FunctionItem, target: CallableTarget::ScriptFunction(0), arity: 0, - frame_local_count: 1, + // The callee frame must span both root binding slots + // (0 and 1); a smaller frame would be rejected by the + // interpreter's `enter_script_frame` before the + // mutation check this test exercises. + frame_local_count: 2, parameter_slots: Vec::new(), capture_source_slots: Vec::new(), capture_slots: Vec::new(), @@ -5274,4 +5665,109 @@ mod tests { .all(|block| !matches!(block.terminator, Some(SsaTerminator::CallValue { .. }))) ); } + + #[test] + fn rejects_inline_callee_with_root_binding_outside_frame() { + // Root: i = 0; loop: i = i + 1; callscript 1 0; i < 2; brfalse end; + // br loop; end: ldc 0; ret. Prototype 1 (the inlinable callee) has a + // frame_local_count of 2 while the root binding for prototype 0 + // lives at slot 3: the interpreter's `enter_script_frame` raises + // `InvalidFrameState`, so the recorder must reject the trace instead + // of silently skipping the out-of-frame binding. + let mut bc = BytecodeBuilder::new(); + bc.ldc(0); + bc.stloc(0); + let root_ip = bc.position(); + bc.ldloc(0); + bc.ldc(1); + bc.add(); + bc.stloc(0); + bc.call_script(1, 0); + bc.ldloc(0); + bc.ldc(2); + bc.clt(); + let branch_ip = bc.position(); + bc.brfalse(0); + let end_label = bc.position(); + bc.ldc(0); + bc.ret(); + let br_ip = bc.position(); + bc.br(0); + let mut code = bc.finish(); + patch_branch_target(&mut code, branch_ip, end_label); + patch_branch_target(&mut code, br_ip, root_ip); + let callee_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let callee_end = code.len() as u32; + + let program = Program::new(vec![Value::Int(0), Value::Int(1), Value::Int(2)], code) + .with_local_count(4) + .with_callable_metadata( + vec![ + ScriptFunction { + entry_ip: callee_entry, + end_ip: callee_end, + }, + ScriptFunction { + entry_ip: callee_entry, + end_ip: callee_end, + }, + ], + vec![ + CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(1), + arity: 0, + frame_local_count: 2, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + ], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: callee_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: callee_entry, + end_ip: callee_end, + prototype_id: Some(0), + }, + FunctionRegion { + start_ip: callee_entry, + end_ip: callee_end, + prototype_id: Some(1), + }, + ], + vec![RootCallableBinding { + local_slot: 3, + prototype_id: 0, + }], + ); + + let error = record_trace(&program, root_ip as usize, 0, 64, &[]) + .expect_err("out-of-frame root binding must reject the trace, not silently skip"); + assert!(matches!( + error, + TraceRecordError::UnsupportedTrace(detail) + if detail == "root callable binding is outside the script frame" + )); + } } diff --git a/src/vm/jit/region.rs b/src/vm/jit/region.rs index aea8c35b..981bf640 100644 --- a/src/vm/jit/region.rs +++ b/src/vm/jit/region.rs @@ -447,7 +447,8 @@ fn offset_terminator( } SsaTerminator::Exit { exit } | SsaTerminator::Return { exit } - | SsaTerminator::CallValue { exit, .. } => { + | SsaTerminator::CallValue { exit, .. } + | SsaTerminator::CallScript { exit, .. } => { *exit = offset_exit_id(*exit, exit_offset)?; } } diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index d3a92072..3e4fa193 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -147,6 +147,7 @@ pub enum JitTraceTerminal { Halt, BranchExit, CallValue, + CallScript, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1251,59 +1252,31 @@ fn scan_loop_headers(program: &Program) -> Vec { let mut ip = 0usize; while ip < code.len() { - let opcode = code[ip]; + let Some(opcode) = OpCode::try_from(code[ip]).ok() else { + // Unknown opcode: its length cannot be determined, so advance + // a single byte rather than misaligning the scan. + ip = ip.saturating_add(1); + continue; + }; let instr_ip = ip; - ip = ip.saturating_add(1); - match opcode { - x if x == OpCode::Ldc as u8 => { - if read_u32(code, &mut ip).is_none() { - break; - } - } - x if x == OpCode::Br as u8 || x == OpCode::Brfalse as u8 => { - let Some(target_u32) = read_u32(code, &mut ip) else { - break; - }; - let target = target_u32 as usize; - if target <= instr_ip && target < headers.len() { - headers[target] = true; - } - } - x if x == OpCode::Ldloc as u8 || x == OpCode::Stloc as u8 => { - if read_u8(code, &mut ip).is_none() { - break; - } - } - x if x == OpCode::Call as u8 => { - if read_u16(code, &mut ip).is_none() { - break; - } - if read_u8(code, &mut ip).is_none() { - break; - } + if opcode == OpCode::Br || opcode == OpCode::Brfalse { + ip = ip.saturating_add(1); + let Some(target_u32) = read_u32(code, &mut ip) else { + break; + }; + let target = target_u32 as usize; + if target <= instr_ip && target < headers.len() { + headers[target] = true; } - _ => {} } + // Advance by the full instruction length (opcode plus operands) so + // operand bytes are never interpreted as opcodes. + ip = instr_ip.saturating_add(1 + opcode.operand_len()); } headers } -fn read_u8(code: &[u8], ip: &mut usize) -> Option { - let value = *code.get(*ip)?; - *ip = ip.saturating_add(1); - Some(value) -} - -fn read_u16(code: &[u8], ip: &mut usize) -> Option { - if ip.saturating_add(2) > code.len() { - return None; - } - let bytes = [code[*ip], code[*ip + 1]]; - *ip = ip.saturating_add(2); - Some(u16::from_le_bytes(bytes)) -} - fn read_u32(code: &[u8], ip: &mut usize) -> Option { if ip.saturating_add(4) > code.len() { return None; @@ -2023,6 +1996,32 @@ mod tests { assert!(!headers[branch_ip as usize]); } + #[test] + fn scan_loop_headers_skips_call_script_operand_bytes() { + // CallScript(12, 0) encodes as 0x1A followed by five operand bytes. + // The first operand byte is 0x0C (Brfalse) and the remaining bytes + // decode as a backward branch target of 0: a walker that does not + // advance over the full operand span would mark offset 0 as a false + // loop header. + let mut code = vec![OpCode::CallScript as u8]; + code.extend_from_slice(&12u32.to_le_bytes()); + code.push(0); + let loop_ip = code.len() as u32; + code.push(OpCode::Nop as u8); + let branch_ip = code.len() as u32; + code.push(OpCode::Br as u8); + code.extend_from_slice(&loop_ip.to_le_bytes()); + let program = Program::new(vec![], code); + + let headers = scan_loop_headers(&program); + assert!( + !headers[0], + "CallScript operand bytes must not be interpreted as a branch" + ); + assert!(headers[loop_ip as usize]); + assert!(!headers[branch_ip as usize]); + } + #[test] fn callable_side_exit_backoff_resets_on_native_progress() { if !native_jit_supported() { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 54e8c873..4acd749c 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -109,6 +109,9 @@ pub enum VmError { expected: u8, got: u8, }, + /// `CallScript` targeted a prototype whose capture layout requires a + /// callable environment, which a static script call cannot supply. + CallScriptRequiresEnvironment(u32), CallStackOverflow { limit: usize, }, @@ -180,6 +183,10 @@ impl std::fmt::Display for VmError { f, "invalid call arity for callable {prototype_id}: expected {expected}, got {got}" ), + VmError::CallScriptRequiresEnvironment(prototype_id) => write!( + f, + "callscript prototype {prototype_id} requires a callable environment" + ), VmError::CallStackOverflow { limit } => { write!(f, "script call stack limit {limit} exceeded") } @@ -1094,17 +1101,88 @@ impl Vm { let Value::Callable(callable) = callee else { return Err(VmError::InvalidCallable); }; + let prototype_id = callable.prototype_id; + let continuation = FrameContinuation::ResumeBytecode { + return_ip: self.instance.ip, + }; + self.enter_script_frame( + prototype_id, + Some(callable), + operands, + operand_stack_base, + call_site_ip, + continuation, + ) + } + + /// Execute a static `CallScript(prototype_id, argc)` instruction. + /// + /// The operands are split off the stack and the frame is entered through + /// the shared [`Self::enter_script_frame`] helper with no callable value: + /// `CallScript` can never supply a callable environment, so capture- or + /// self-requiring prototypes are rejected there with a typed error. + fn execute_call_script( + &mut self, + prototype_id: u32, + argc: u8, + call_ip: usize, + ) -> VmResult { + let operand_count = argc as usize; + if self.instance.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let operand_stack_base = self.instance.stack.len() - operand_count; + let operands = self.instance.stack.split_off(operand_stack_base); + let continuation = FrameContinuation::ResumeBytecode { + return_ip: self.instance.ip, + }; + self.enter_script_frame( + prototype_id, + None, + operands, + operand_stack_base, + Some(call_ip), + continuation, + ) + } + + /// Shared script-frame entry for `CallValue` and `CallScript`. + /// + /// Enters a callable frame from `(prototype_id, optional callable value, + /// operands, continuation)`. `CallValue` passes the runtime callable + /// value, which carries the environment and provides the self binding; + /// `CallScript` passes `None` and must only reach environment-free + /// function prototypes. The helper preserves arity validation, schema + /// checks, depth limits, interruption ticks, the return continuation, + /// operand stack cleanup, root callable binding initialization, capture + /// cell wiring, and self-slot binding. + fn enter_script_frame( + &mut self, + prototype_id: u32, + callable: Option>, + operands: Vec, + operand_stack_base: usize, + call_site_ip: Option, + continuation: FrameContinuation, + ) -> VmResult { let prototype = self .program .callable_prototypes - .get(callable.prototype_id as usize) + .get(prototype_id as usize) .cloned() - .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; - if prototype.arity != argc { + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + // A call without a runtime callable value (`CallScript`) cannot + // populate capture cells or bind the function's self identity. + if callable.is_none() + && (!prototype.capture_slots.is_empty() || prototype.self_slot.is_some()) + { + return Err(VmError::CallScriptRequiresEnvironment(prototype_id)); + } + if prototype.arity != operands.len() as u8 { return Err(VmError::CallableArityMismatch { - prototype_id: callable.prototype_id, + prototype_id, expected: prototype.arity, - got: argc, + got: operands.len() as u8, }); } if let Some(crate::compiler::TypeSchema::Callable { params, .. }) = &prototype.schema @@ -1123,7 +1201,7 @@ impl Vm { self.engine.jit.observe_script_call_target( self.active_frame_key(), call_ip, - callable.prototype_id, + prototype_id, ); } if self.instance.call_depth >= self.instance.max_script_call_depth { @@ -1136,12 +1214,12 @@ impl Vm { .script_functions .get(function_id as usize) .cloned() - .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; if prototype.parameter_slots.len() != operands.len() { return Err(VmError::CallableArityMismatch { - prototype_id: callable.prototype_id, + prototype_id, expected: prototype.parameter_slots.len() as u8, - got: argc, + got: operands.len() as u8, }); } let inherited_callables = self @@ -1199,7 +1277,9 @@ impl Vm { } self.instance.locals[local_base + relative] = argument; } - if let Some(environment) = &callable.env { + if let Some(environment) = + callable.as_ref().and_then(|callable| callable.env.as_ref()) + { let cells = environment .cells .lock() @@ -1247,15 +1327,19 @@ impl Vm { "self slot is outside the script frame", )); } + let Some(callable) = callable else { + return Err(VmError::InvalidFrameState( + "self slot requires a callable value", + )); + }; self.instance.locals[local_base + relative] = Value::Callable(callable.clone()); } - let return_ip = self.instance.ip; self.instance.execution_frames.push(ExecutionFrame { - continuation: FrameContinuation::ResumeBytecode { return_ip }, + continuation, operand_stack_base, local_base, local_count, - prototype_id: Some(callable.prototype_id), + prototype_id: Some(prototype_id), }); self.instance.active_local_base_cache = local_base; self.instance.active_operand_stack_base_cache = operand_stack_base; @@ -1265,6 +1349,12 @@ impl Vm { Ok(ExecOutcome::Continue) } CallableTarget::HostImport(import_index) => { + let Some(callable) = callable else { + // `CallScript` is a static script-function call and must + // never route a host-import prototype to the host path. + return Err(VmError::InvalidCallablePrototype(prototype_id)); + }; + let argc = operands.len() as u8; self.instance.stack.extend(operands); let call_ip = self.instance.ip.saturating_sub(2); match self.execute_host_call(import_index, argc, call_ip)? { @@ -1273,7 +1363,7 @@ impl Vm { HostCallExecOutcome::Yielded => { self.instance .stack - .insert(operand_stack_base, Value::Callable(callable)); + .insert(operand_stack_base, Value::Callable(callable.clone())); Ok(ExecOutcome::Yielded) } HostCallExecOutcome::Pending(op_id) => Ok(ExecOutcome::Waiting(op_id)), @@ -2624,6 +2714,12 @@ impl Vm { let argc = self.read_u8()?; return self.execute_call_value(argc, Some(call_ip)); } + x if x == OpCode::CallScript as u8 => { + let call_ip = self.instance.ip.saturating_sub(1); + let prototype_id = self.read_u32()?; + let argc = self.read_u8()?; + return self.execute_call_script(prototype_id, argc, call_ip); + } other => return Err(VmError::InvalidOpcode(other)), } Ok(ExecOutcome::Continue) diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 357f6b21..c1d38fb8 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -316,6 +316,14 @@ pub(crate) fn enter_call_value_inherited_entry_address() -> usize { pd_vm_native_enter_call_value_inherited as *const () as usize } +pub(crate) fn enter_call_script_entry_address() -> usize { + pd_vm_native_enter_call_script as *const () as usize +} + +pub(crate) fn enter_call_script_inherited_entry_address() -> usize { + pd_vm_native_enter_call_script_inherited as *const () as usize +} + pub(crate) fn leave_frame_entry_address() -> usize { pd_vm_native_leave_frame as *const () as usize } @@ -1005,6 +1013,78 @@ pub(crate) extern "C" fn pd_vm_native_enter_call_value_inherited( }) } +fn native_enter_call_script( + vm: &mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, + inherited_state: *mut u8, +) -> VmResult { + let prototype_id = u32::try_from(prototype_id) + .map_err(|_| VmError::InvalidFrameState("native call-script prototype id out of range"))?; + let argc = u8::try_from(argc) + .map_err(|_| VmError::InvalidFrameState("native call-script argc out of range"))?; + let call_ip = usize::try_from(call_ip) + .map_err(|_| VmError::InvalidFrameState("native call-script ip out of range"))?; + let resume_ip = usize::try_from(resume_ip) + .map_err(|_| VmError::InvalidFrameState("native call-script resume ip out of range"))?; + if vm.instance.ip != call_ip { + vm.jump_to(call_ip)?; + } + if resume_ip > vm.program.code.len() { + return Err(VmError::BytecodeBounds); + } + vm.instance.ip = resume_ip; + let status = match vm.execute_call_script(prototype_id, argc, call_ip)? { + ExecOutcome::Continue => STATUS_LINKED_CONTINUE, + ExecOutcome::Halted => STATUS_HALTED, + ExecOutcome::Yielded => STATUS_YIELDED, + ExecOutcome::Waiting(_) => STATUS_WAITING, + }; + if status == STATUS_LINKED_CONTINUE { + if vm.active_frame_has_shared_capture_cells() { + return Ok(STATUS_CONTINUE); + } + if !inherited_state.is_null() { + write_inherited_state_packet(vm, inherited_state)?; + } + } + Ok(status) +} + +pub(crate) extern "C" fn pd_vm_native_enter_call_script( + vm: *mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, +) -> i32 { + run_step(vm, "enter_call_script", |vm| { + native_enter_call_script( + vm, + prototype_id, + argc, + call_ip, + resume_ip, + std::ptr::null_mut(), + ) + }) +} + +pub(crate) extern "C" fn pd_vm_native_enter_call_script_inherited( + vm: *mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, + inherited_state: *mut u8, +) -> i32 { + run_step(vm, "enter_call_script", |vm| { + native_enter_call_script(vm, prototype_id, argc, call_ip, resume_ip, inherited_state) + }) +} + fn native_leave_frame(vm: &mut Vm, ret_ip: i64, inherited_state: *mut u8) -> VmResult { let ret_ip = usize::try_from(ret_ip) .map_err(|_| VmError::InvalidFrameState("native ret ip out of range"))?; @@ -1316,8 +1396,17 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( "virtual frame local count does not match prototype", )); } - if call_ip.saturating_add(2) != return_ip - || vm.program.code.get(call_ip).copied() != Some(crate::OpCode::CallValue as u8) + // The virtual frame continuation must resume exactly after the call + // instruction that produced it: `CallValue` carries a one-byte + // `argc` operand, `CallScript` a five-byte `(prototype_id, argc)` + // operand. + let call_instruction_len = match vm.program.code.get(call_ip).copied() { + Some(opcode) if opcode == crate::OpCode::CallValue as u8 => 2, + Some(opcode) if opcode == crate::OpCode::CallScript as u8 => 6, + _ => 0, + }; + if call_instruction_len == 0 + || call_ip.saturating_add(call_instruction_len) != return_ip || return_ip > vm.program.code.len() || resume_ip < function.entry_ip as usize || resume_ip >= function.end_ip as usize diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 72d71f48..21030339 100644 --- a/src/vm/native/codegen.rs +++ b/src/vm/native/codegen.rs @@ -60,6 +60,29 @@ pub(crate) fn enter_call_value_inherited_signature( sig } +#[cfg(feature = "cranelift-jit")] +pub(crate) fn enter_call_script_signature( + pointer_type: cranelift_codegen::ir::Type, + call_conv: cranelift_codegen::isa::CallConv, +) -> Signature { + let mut sig = Signature::new(call_conv); + sig.params.push(AbiParam::new(pointer_type)); + // prototype_id:u32, argc:u8, call_ip:usize, resume_ip:usize + sig.params.extend((0..4).map(|_| AbiParam::new(types::I64))); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + +#[cfg(feature = "cranelift-jit")] +pub(crate) fn enter_call_script_inherited_signature( + pointer_type: cranelift_codegen::ir::Type, + call_conv: cranelift_codegen::isa::CallConv, +) -> Signature { + let mut sig = enter_call_script_signature(pointer_type, call_conv); + sig.params.push(AbiParam::new(pointer_type)); + sig +} + #[cfg(feature = "cranelift-jit")] pub(crate) fn leave_frame_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 41b12ae1..b21b0150 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -14,7 +14,8 @@ pub(crate) use bridge::{ aot_call_boundary_interrupt_entry_address, array_push_entry_address, array_set_entry_address, clear_bridge_error, clear_bridge_error_entry_address, clear_value_slot_entry_address, clone_value_to_slot_entry_address, collection_set_entry_address, copy_bytes_entry_address, - decode_jit_trace_exit_status, encode_jit_trace_exit_status, enter_call_value_entry_address, + decode_jit_trace_exit_status, encode_jit_trace_exit_status, enter_call_script_entry_address, + enter_call_script_inherited_entry_address, enter_call_value_entry_address, enter_call_value_inherited_entry_address, frame_state_entry_address, helper_entry_address, helper_entry_offset, init_null_value_slot_entry_address, interrupt_helper_entry_address, interrupt_helper_entry_offset, leave_frame_entry_address, leave_frame_inherited_entry_address, @@ -36,16 +37,17 @@ pub(crate) use bridge::{ pub(crate) use codegen::{ alloc_buffer_signature, array_set_signature, box_heap_value_signature, clone_value_signature, collection_get_signature, collection_mutation_signature, collection_predicate_signature, - copy_bytes_signature, enter_call_value_inherited_signature, enter_call_value_signature, - entry_signature, frame_state_signature, free_buffer_signature, helper_signature, - jump_with_status, leave_frame_inherited_signature, leave_frame_signature, - map_iter_next_signature, map_iter_take_signature, map_set_signature, - non_yielding_host_call_signature, non_yielding_i64_host_call_signature, - non_yielding_scalar_host_call_signature, pack_shared_signature, regex_match_signature, - regex_replace_signature, restore_exit_signature, restore_virtual_frame_signature, - sparse_restore_exit_signature, string_binary_transform_signature, string_contains_signature, - string_replace_signature, string_unary_transform_signature, value_eq_signature, - value_len_signature, value_slot_signature, + copy_bytes_signature, enter_call_script_inherited_signature, enter_call_script_signature, + enter_call_value_inherited_signature, enter_call_value_signature, entry_signature, + frame_state_signature, free_buffer_signature, helper_signature, jump_with_status, + leave_frame_inherited_signature, leave_frame_signature, map_iter_next_signature, + map_iter_take_signature, map_set_signature, non_yielding_host_call_signature, + non_yielding_i64_host_call_signature, non_yielding_scalar_host_call_signature, + pack_shared_signature, regex_match_signature, regex_replace_signature, restore_exit_signature, + restore_virtual_frame_signature, sparse_restore_exit_signature, + string_binary_transform_signature, string_contains_signature, string_replace_signature, + string_unary_transform_signature, value_eq_signature, value_len_signature, + value_slot_signature, }; pub(crate) use exec::{ExecutableBuffer, prepare_for_execution}; pub(crate) use layout::{ @@ -55,7 +57,11 @@ pub(crate) use layout::{ #[cfg(feature = "cranelift-jit")] pub(crate) use offsets::{HeapIntrinsicAddrs, HeapIntrinsicRefs, ResolvedOffsets, resolve_offsets}; -pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 5; +/// Native callable ABI revision. Bumped for every change to the native +/// callable boundary helpers or their status contract; it is hashed into the +/// program cache identity so stale native products are invalidated exactly +/// once per semantics change. +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 6; pub(crate) const MAX_INHERITED_ENTRY_VALUES: usize = 256; pub(crate) const INHERITED_STATE_ACTIVE_OFFSET: i32 = 0; pub(crate) const INHERITED_STATE_FRAME_KEY_OFFSET: i32 = 8; diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 8777f528..94690272 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -865,6 +865,7 @@ fn aot_executes_script_callable_frames_without_interpreter_boundary() { let compiled = crate::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; add_one(41); "#, ) @@ -886,6 +887,7 @@ fn aot_executes_typed_script_callable_parameter_equality_without_interpreter_bou let compiled = crate::compile_source( r#" fn is_zero(value: int) -> bool { value == 0 } + let f = is_zero; is_zero(0); "#, ) @@ -906,6 +908,7 @@ fn aot_executes_script_callable_bool_return_in_branch_without_interpreter_bounda let compiled = crate::compile_source( r#" fn is_zero(value: int) -> bool { value == 0 } + let f = is_zero; let selected = if is_zero(0) => { 1 } else => { 2 }; selected; "#, @@ -969,6 +972,7 @@ fn aot_callable_call_resumes_after_fuel_yield_without_interpreter_boundary() { let compiled = crate::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; add_one(41); "#, ) @@ -997,6 +1001,8 @@ fn aot_executes_nested_script_callables_without_interpreter_boundary() { r#" fn inc(value: int) -> int { value + 1 } fn twice(value: int) -> int { inc(inc(value)) } + let f = inc; + let g = twice; twice(40); "#, ) @@ -1017,6 +1023,7 @@ fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundar let compiled = crate::compile_source_for_repl( r#" fn recurse(value: int) -> int { recurse(value) } + let f = recurse; recurse(1); "#, ) @@ -2644,3 +2651,52 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } + +#[test] +fn program_cache_key_distinguishes_call_script_from_call_value() { + // A direct-only call lowers to `CallScript`; the same call through a + // materialized callable lowers to `CallValue`. The static cache identity + // must treat the two programs as different even when their metadata + // otherwise matches, because the native call boundary differs. + let direct = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let materialized = + crate::compile_source("fn add2(value: int) -> int { value + 2 } let f = add2; f(40);") + .expect("materialized call source should compile"); + + let mut direct_vm = Vm::new(direct.program); + let mut materialized_vm = Vm::new(materialized.program); + let direct_key = direct_vm.ensure_program_cache_key(); + let materialized_key = materialized_vm.ensure_program_cache_key(); + assert_ne!( + direct_key, materialized_key, + "CallScript and CallValue programs must not share cache identity" + ); + + // The same direct program reproduces the same key across VMs. + let direct_repeat = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut repeat_vm = Vm::new(direct_repeat.program); + assert_eq!( + repeat_vm.ensure_program_cache_key(), + direct_key, + "identical programs must share cache identity" + ); +} + +#[test] +fn native_callable_abi_version_covers_direct_script_calls() { + // `CallScript` adds a new native boundary helper and exit contract; the + // native callable ABI revision must reflect it so every directly coupled + // program/native cache is invalidated exactly once. + assert_eq!( + super::native::NATIVE_CALLABLE_ABI_VERSION, + 6, + "native callable ABI revision must cover direct script call semantics" + ); + let direct = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut vm = Vm::new(direct.program); + let key = vm.ensure_program_cache_key(); + assert_ne!(key, 0, "cache key must be non-trivial"); +} diff --git a/src/vmbc.rs b/src/vmbc.rs index 1ac65b68..b6432c61 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -11,7 +11,7 @@ use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,6 +92,16 @@ pub enum ValidationError { expected: u8, got: u8, }, + InvalidCallScriptTarget { + offset: usize, + prototype_id: u32, + }, + InvalidCallScriptArity { + offset: usize, + prototype_id: u32, + expected: u8, + got: u8, + }, InvalidJumpTarget { offset: usize, target: u32, @@ -129,6 +139,22 @@ impl std::fmt::Display for ValidationError { f, "invalid call arity {got} for import index {index} at offset {offset}, expected {expected}", ), + ValidationError::InvalidCallScriptTarget { + offset, + prototype_id, + } => write!( + f, + "invalid callscript prototype {prototype_id} at offset {offset}", + ), + ValidationError::InvalidCallScriptArity { + offset, + prototype_id, + expected, + got, + } => write!( + f, + "invalid callscript arity {got} for prototype {prototype_id} at offset {offset}, expected {expected}", + ), ValidationError::InvalidJumpTarget { offset, target } => write!( f, "invalid jump target {target} referenced by instruction at offset {offset}", @@ -241,7 +267,7 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&VERSION_V11.to_le_bytes()); + out.extend_from_slice(&VERSION_V12.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; @@ -275,7 +301,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { + if version != VERSION_V12 { return Err(WireError::UnsupportedVersion(version)); } @@ -492,6 +518,19 @@ pub fn disassemble_program_with_options(program: &Program, options: DisassembleO truncated = true; } } + x if x == OpCode::CallScript as u8 => { + if let Some(prototype_id) = read_u32(code, &mut ip) { + if let Some(argc) = read_u8(code, &mut ip) { + instruction.push_str(&format!("callscript {prototype_id} {argc}")); + } else { + instruction.push_str("callscript "); + truncated = true; + } + } else { + instruction.push_str("callscript "); + truncated = true; + } + } x if x == OpCode::Shl as u8 => instruction.push_str("shl"), x if x == OpCode::Shr as u8 => instruction.push_str("shr"), @@ -765,6 +804,43 @@ fn analyze_program( expected_bytes: 1, })?; } + x if x == OpCode::CallScript as u8 => { + let prototype_id = + read_u32(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 5, + })?; + let argc = read_u8(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 5, + })?; + let Some(prototype) = program.callable_prototypes.get(prototype_id as usize) else { + return Err(ValidationError::InvalidCallScriptTarget { + offset: start, + prototype_id, + }); + }; + // `CallScript` is a static script-function call: a + // host-import prototype must never be routed to the host + // path (the VM rejects it with `InvalidCallablePrototype`), + // so reject it deterministically here as well. + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(ValidationError::InvalidCallScriptTarget { + offset: start, + prototype_id, + }); + } + if argc != prototype.arity { + return Err(ValidationError::InvalidCallScriptArity { + offset: start, + prototype_id, + expected: prototype.arity, + got: argc, + }); + } + } other => { return Err(ValidationError::InvalidOpcode { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 335a8d64..6c7fa3f0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -131,6 +131,7 @@ pub enum CompileErrorKind { CallableUsedAsValue, NonCallableLocal, LocalSlotOverflow, + FrameLocalLimitExceeded, CallableArityMismatch, BreakOutsideLoop, ContinueOutsideLoop, @@ -167,6 +168,9 @@ fn compile_error_kind(err: &vm::CompileError) -> CompileErrorKind { vm::CompileError::CallableUsedAsValue => CompileErrorKind::CallableUsedAsValue, vm::CompileError::NonCallableLocal(_) => CompileErrorKind::NonCallableLocal, vm::CompileError::LocalSlotOverflow(_) => CompileErrorKind::LocalSlotOverflow, + vm::CompileError::FrameLocalLimitExceeded { .. } => { + CompileErrorKind::FrameLocalLimitExceeded + } vm::CompileError::CallableArityMismatch { .. } => CompileErrorKind::CallableArityMismatch, vm::CompileError::BreakOutsideLoop => CompileErrorKind::BreakOutsideLoop, vm::CompileError::ContinueOutsideLoop => CompileErrorKind::ContinueOutsideLoop, diff --git a/tests/compiler/compiler_common_tests.rs b/tests/compiler/compiler_common_tests.rs index 2dce7039..b527f7cb 100644 --- a/tests/compiler/compiler_common_tests.rs +++ b/tests/compiler/compiler_common_tests.rs @@ -1,6 +1,7 @@ #[path = "../common/mod.rs"] mod common; use common::*; +use std::collections::HashMap; use vm::OpCode; const LOCAL_SLOT_COMPAT_THRESHOLD: usize = 8; @@ -269,6 +270,244 @@ fn compiler_reuses_slots_with_large_programs_that_call_script_functions() { assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(399)]); } + +/// Generate the storage-shaped frame-local dispatch program: 77 named +/// functions (32 branch leaves each calling a same-frame helper, plus 13 +/// extra leaves) and a 32-branch dispatcher whose branch live sets union the +/// callee footprints. Each callee owns two parameters and one local. +fn frame_local_dispatch_source() -> String { + let mut source = String::new(); + for idx in 0..32usize { + source.push_str(&format!( + "fn h_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n t;\n}}\n" + )); + source.push_str(&format!( + "fn f_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n h_{idx}(t, a);\n}}\n" + )); + } + for idx in 32..45usize { + source.push_str(&format!( + "fn f_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n t;\n}}\n" + )); + } + source.push_str("fn dispatch(idx: int) -> int {\n let mut acc = 0;\n"); + for idx in 0..32usize { + let keyword = if idx == 0 { "if" } else { "else if" }; + source.push_str(&format!( + " {keyword} idx == {idx} {{ acc = f_{idx}(acc, {}); }}\n", + idx + 1 + )); + } + source.push_str(" else { acc = f_32(acc, 33); }\n acc;\n}\n"); + source.push_str("dispatch(0);\ndispatch(31);\n"); + source +} + +#[test] +fn frame_local_dispatch_single_file_pressure_is_bounded() { + // Named script calls run in separate runtime frames, so callee body + // footprints must not inflate the caller frame's live set. The aggregate + // frame-local count must stay within per-frame pressure plus the + // currently required hidden callable slots (one per named function). + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + assert!( + compiled.locals <= 100, + "aggregate frame locals should stay within per-frame pressure plus callable slots, got {}", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + +#[test] +fn frame_local_function_body_rejects_more_than_256_simultaneously_live_locals() { + // Genuine same-frame pressure inside a single function body must still + // fail with the frame-local limit: the frame-aware rules only remove + // cross-frame interference, never real per-frame pressure. + let live_count = (u8::MAX as usize) + 2; + let mut source = String::from("fn crowded() {\n"); + for idx in 0..live_count { + source.push_str(&format!(" let v{idx} = {idx};\n")); + } + source.push_str(" "); + for idx in 0..live_count { + if idx > 0 { + source.push_str(" + "); + } + source.push_str(&format!("v{idx}")); + } + source.push_str(";\n}\ncrowded();\n"); + + let err = match compile_source(&source) { + Ok(_) => panic!("compile should fail"), + Err(err) => err, + }; + match err { + vm::SourceError::Parse(parse_err) => { + assert!( + parse_err + .message + .contains("too many simultaneously live locals"), + "unexpected parse error: {parse_err:?}" + ); + } + other => panic!("expected parse error, got {other:?}"), + } +} + +#[test] +fn frame_local_root_accepts_256_simultaneously_live_locals_and_reads_highest_short_slot() { + // The 256-slot boundary must still compile and read the highest short + // slot; only aggregate pressure beyond 256 is rejected. The sum is the + // trailing expression so no extra local joins the live clique, and it is + // right-nested so codegen's string-classification recursion stays linear + // (it re-walks each left operand). + let live_count = (u8::MAX as usize) + 1; + let mut source = String::new(); + for idx in 0..live_count { + source.push_str(&format!("let v{idx} = {idx};\n")); + } + for idx in 0..live_count - 1 { + source.push_str(&format!("v{idx} + (")); + } + source.push_str(&format!("v{}", live_count - 1)); + for _ in 0..live_count - 1 { + source.push(')'); + } + source.push_str(";\n"); + + let compiled = compile_source(&source).expect("256-live program should compile"); + assert_eq!(compiled.locals, 256); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + let expected: i64 = (0..256).sum(); + assert_eq!(vm.stack(), &[Value::Int(expected)]); +} + +#[test] +fn frame_local_slot_reuse_across_recursive_call_frames() { + // `a` and `b` run in separate runtime frames even when they call each + // other recursively, so their locals must be free to share one relative + // slot: caller/callee cross-live edges would needlessly separate them. + // The program exceeds the slot-allocator compat threshold so physical + // slots are actually compacted. + let source = r#" + fn a(x: int) -> int { + let a1 = x + 1; + let a2 = a1 + 1; + let a3 = a2 + 1; + let a_local = a3 + 1; + if x > 0 => { b(x - 1) } else => { a_local } + } + fn b(y: int) -> int { + let b1 = y + 2; + let b2 = b1 + 2; + let b3 = b2 + 2; + let b_local = b3 + 2; + if y > 0 => { a(y - 1) } else => { b_local } + } + a(3); + "#; + let compiled = compile_source(source).expect("mutual recursion should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let a_local = debug + .locals + .iter() + .find(|local| local.name == "a_local") + .expect("a_local should be in debug info"); + let b_local = debug + .locals + .iter() + .find(|local| local.name == "b_local") + .expect("b_local should be in debug info"); + assert_eq!( + a_local.index, b_local.index, + "disjoint recursive frames should reuse the same relative slot" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(8)]); +} + +#[test] +fn frame_local_same_frame_values_keep_distinct_slots() { + // Negative control: two values genuinely live at the same time inside one + // function must receive different physical slots even though other frames + // may reuse them. The program exceeds the slot-allocator compat threshold + // so physical slots are actually compacted. + let source = r#" + fn overlap(a: int, b: int) -> int { + let p = a + 1; + let q = p + 1; + let x = a + b; + let y = q + x; + let s = y + 1; + let t = s + 1; + x + y + t; + } + overlap(3, 4); + "#; + let compiled = compile_source(source).expect("overlap should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let x = debug + .locals + .iter() + .find(|local| local.name == "x") + .expect("x should be in debug info"); + let y = debug + .locals + .iter() + .find(|local| local.name == "y") + .expect("y should be in debug info"); + assert_ne!( + x.index, y.index, + "simultaneously live values in one frame must keep distinct slots" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + // p = 4, q = 5, x = 7, y = 12, s = 13, t = 14, result = 33 + assert_eq!(vm.stack(), &[Value::Int(33)]); +} + +#[test] +fn frame_local_dispatch_data_pressure_is_small() { + // After frame isolation and milestone-6 slot omission the + // storage-shaped fixture needs only its own per-frame data slots: + // every named function is direct-only, so no hidden callable slots + // remain in the aggregate frame-local count. + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + let materialized = compiled.program.root_callable_bindings.len(); + let data_slots = compiled.locals.saturating_sub(materialized); + assert!( + data_slots <= 20, + "per-frame data pressure should stay small, got {data_slots} data slots" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + #[test] fn compile_source_with_functions() { let source = include_str!("../../examples/example.rss"); @@ -1496,3 +1735,507 @@ fn stack_is_clean_after_halt_with_single_result() { // NOTE: function parameter slot cleanup is covered by // `script_function_frame_values_are_released_after_return` in // compiler_rustscript_tests.rs. + +#[test] +fn named_callable_materialization_omits_direct_only_slots() { + // Milestone 6: direct-only named functions keep a prototype but no + // hidden callable slot, root binding, or runtime self slot. Exported + // and value-referenced functions stay materialized. + let source = r#" + fn direct_helper(x: int) -> int { x + 1 } + fn exported_helper(x: int) -> int { x + 2 } + fn stored_helper(x: int) -> int { x + 3 } + pub fn exported(x: int) -> int { exported_helper(x) } + let stored = stored_helper; + direct_helper(1); + exported(1); + stored(1); + "#; + let compiled = compile_source(source).expect("classification program should compile"); + let program = &compiled.program; + assert_eq!( + program.callable_prototypes.len(), + 4, + "every named function keeps a prototype" + ); + let direct = program + .callable_prototypes + .iter() + .find(|prototype| prototype.parameter_slots.len() == 1) + .expect("direct-only helper prototype"); + // All four prototypes are FunctionItem here; identify the direct-only + // helper as the one with no root binding and no self slot. + let bound = program + .root_callable_bindings + .iter() + .map(|binding| binding.prototype_id) + .collect::>(); + assert_eq!(bound.len(), 2, "only stored and exported stay materialized"); + let direct_only = program + .callable_prototypes + .iter() + .enumerate() + .filter(|(index, _)| !bound.contains(&(*index as u32))) + .map(|(_, prototype)| prototype) + .collect::>(); + assert_eq!(direct_only.len(), 2, "two functions are direct-only"); + for prototype in direct_only { + assert_eq!( + prototype.self_slot, None, + "direct-only functions keep no runtime self slot" + ); + } + assert_eq!(direct.self_slot, None); + for binding in &program.root_callable_bindings { + let prototype = &program.callable_prototypes[binding.prototype_id as usize]; + assert!( + prototype.self_slot.is_some(), + "materialized functions keep their runtime self slot" + ); + } + assert!( + program + .exported_callables + .iter() + .any(|exported| exported.name == "exported"), + "exported function stays materialized and resolvable" + ); + assert!( + program.code.windows(1).any(|window| window[0] == 0x1A), + "direct-only call sites must emit CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(2), Value::Int(3), Value::Int(4)]); +} + +#[test] +fn named_callable_materialization_capturing_allocation_unchanged() { + // A capturing named function keeps its closure prototype, environment + // layout, and runtime self slot until the direct-call milestone: it can + // never use an environment-free direct call path. + let compiled = vm::compile_source_for_repl( + r#" + let captured = 42; + fn read_captured() { captured } + fn walk(n: int) -> int { + if n <= 0 => { captured } else => { walk(n - 1) } + } + read_captured; + walk(2); + "#, + ) + .expect("capturing named functions should compile"); + let program = &compiled.program; + let capturing = program + .callable_prototypes + .iter() + .filter(|prototype| !prototype.capture_slots.is_empty()) + .collect::>(); + assert_eq!( + capturing.len(), + 2, + "both capturing named functions keep their environment layouts" + ); + for prototype in capturing { + assert_eq!(prototype.kind, vm::CallableKind::Closure); + assert!( + prototype.self_slot.is_some(), + "capturing recursion retains the runtime self slot" + ); + } + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack().len(), 2, "callable value plus recursion result"); + assert!( + matches!(vm.stack()[0], Value::Callable(_)), + "the bare function value expression still materializes the callable" + ); + assert_eq!(vm.stack()[1], Value::Int(42)); +} + +#[test] +fn named_callable_without_facts_keeps_legacy_materialization() { + // The public `Compiler` API cannot supply milestone-5 classification + // facts (`set_callable_use_facts` is compiler-internal). A direct + // `Compiler::new().set_function_impls(...).compile_program(...)` path + // with a named script function must keep compiling under the legacy + // conservative contract: every named function stays materialized with + // its hidden callable slot. + let mut compiler = Compiler::new(); + compiler.set_function_impls(HashMap::from([( + 0u16, + vm::compiler::ir::FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: Vec::new(), + body_expr: vm::compiler::ir::Expr::Int(1), + body_expr_line: 1, + }, + )])); + compiler.set_function_decls(HashMap::from([( + 0u16, + vm::compiler::ir::FunctionDecl { + name: "legacy_helper".to_string(), + arity: 0, + index: 0, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported: false, + return_type: vm::ValueType::Int, + symbol: None, + }, + )])); + let stmts = [ + vm::compiler::ir::Stmt::FuncDecl { + name: "legacy_helper".to_string(), + index: 0, + arity: 0, + args: Vec::new(), + exported: false, + has_impl: true, + line: 1, + }, + vm::compiler::ir::Stmt::Expr { + expr: vm::compiler::ir::Expr::Call(0, Vec::new(), Vec::new()), + line: 1, + }, + ]; + let program = compiler + .compile_program(&stmts) + .expect("direct Compiler without facts must still compile named functions"); + + // Legacy materialization: the hidden callable slot and its root binding + // are retained even though no classification facts were provided. + assert_eq!(program.callable_prototypes.len(), 1); + assert!( + program.callable_prototypes[0].self_slot.is_some(), + "absent facts must conservatively retain the hidden callable slot" + ); + assert_eq!(program.root_callable_bindings.len(), 1); + + let mut vm = Vm::new(program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1)]); +} + +// --------------------------------------------------------------------------- +// Milestone 6: direct script-call lowering +// --------------------------------------------------------------------------- + +#[test] +fn direct_script_call_lowering_omits_ldloc_and_bindings() { + // A program whose only named functions are called directly must emit + // `CallScript` at every call site and no `Ldloc`/`Stloc` at all: no + // hidden callable slot exists to load. + let source = r#" + fn helper(x: int) -> int { x + 1 } + fn outer() -> int { helper(1) } + outer(); + "#; + let compiled = compile_source(source).expect("direct-only program should compile"); + let program = &compiled.program; + + assert_eq!( + program.root_callable_bindings.len(), + 0, + "direct-only functions get no root callable bindings" + ); + assert!( + program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_none()), + "direct-only functions keep no runtime self slot" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 2, + "both call sites emit CallScript" + ); + // Every local access stays within the data-slot frame: no hidden + // callable slot exists to load or store. `helper` reads its parameter + // through `Ldloc`, so local loads are legal; they must never reference + // a slot at or beyond the data-slot count. + let mut ip = 0usize; + while ip < program.code.len() { + if matches!( + program.code[ip], + byte if byte == vm::OpCode::Ldloc as u8 || byte == vm::OpCode::Stloc as u8 + ) { + let operand = program.code[ip + 1]; + assert!( + usize::from(operand) < compiled.locals, + "local access {operand} exceeds the data-slot frame of {}", + compiled.locals + ); + } + ip += 1; + } + assert!( + !program.code.contains(&(vm::OpCode::CallValue as u8)), + "direct-only call sites must not use CallValue" + ); + // local_count is exactly the data-slot pressure: no callable slots. + assert_eq!(compiled.locals, 1, "one parameter slot for outer/helper"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(2)]); +} + +#[test] +fn materialized_call_sites_retain_callvalue_lowering() { + // Exported, stored, and capturing named functions keep their hidden + // slot and are invoked through `Ldloc + CallValue`. + let compiled = vm::compile_source_for_repl( + r#" + let captured = 7; + fn read_captured() { captured } + pub fn exported(x: int) -> int { x + 1 } + let stored = exported; + read_captured; + exported(1); + stored(2); + "#, + ) + .expect("materialized program should compile"); + let program = &compiled.program; + assert_eq!( + program.root_callable_bindings.len(), + 1, + "only the exported function gets a root binding; the capturing function has none" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 0, + "materialized call sites never emit CallScript" + ); + assert!( + program.code.contains(&(vm::OpCode::CallValue as u8)), + "materialized call sites keep CallValue" + ); + assert!( + program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_some()), + "materialized and capturing functions keep their runtime self slot" + ); +} + +#[test] +fn direct_script_call_forward_and_mutual_recursion_run() { + // Forward calls (callee declared later), direct recursion, and mutual + // recursion all execute through the direct script-call path. + let source = r#" + fn even(n: int) -> int { + if n == 0 => { 1 } else => { odd(n - 1) } + } + fn odd(n: int) -> int { + if n == 0 => { 0 } else => { even(n - 1) } + } + fn later(x: int) -> int { x * 2 } + fn countdown(n: int) -> int { + if n <= 0 => { 0 } else => { countdown(n - 1) } + } + later(21); + countdown(5); + even(10); + odd(7); + "#; + let compiled = compile_source(source).expect("recursion source should compile"); + assert!( + compiled + .program + .code + .iter() + .filter(|byte| **byte == 0x1A) + .count() + >= 4, + "direct recursion and mutual recursion use CallScript" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(42), Value::Int(0), Value::Int(1), Value::Int(1)] + ); +} + +#[test] +fn direct_script_call_generic_functions_use_their_prototype() { + // A generic function called directly is lowered through `CallScript` + // with a prototype, and generic function values keep using the + // specialized prototype machinery. + let source = r#" + fn identity(value: T) -> T { value } + identity::(42); + "#; + let compiled = compile_source(source).expect("generic call should compile"); + assert_eq!( + compiled.program.callable_prototypes.len(), + 2, + "the generic function keeps its base prototype plus the direct-call specialization" + ); + assert!( + compiled.program.code.contains(&0x1A), + "generic direct call emits CallScript" + ); + assert!( + compiled + .program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_none()), + "direct generic calls allocate no hidden callable slot" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // Specialized generic values keep the substituted-schema prototype and + // the dynamic callable path. + let compiled = compile_source( + r#" + fn identity(value: T) -> T { value } + let f = identity::; + f(42); + "#, + ) + .expect("specialized value should compile"); + assert_eq!( + compiled.program.root_callable_bindings.len(), + 2, + "base plus specialized prototype both stay materialized" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn direct_script_call_generic_resolves_instantiated_prototype_schema() { + // A direct generic call with explicit type arguments must resolve the + // prototype whose schema is the instantiated concrete schema, not the + // generic base prototype whose placeholder schema accepts all values. + // This keeps the runtime schema check and the wire-visible prototype + // metadata aligned with the call-site types. + let source = r#" + fn identity(value: T) -> T { value } + identity::(42); + "#; + let compiled = compile_source(source).expect("generic call should compile"); + let code = &compiled.program.code; + let mut ip = 0usize; + let mut targets = Vec::new(); + while ip < code.len() { + if code[ip] == vm::OpCode::CallScript as u8 { + let prototype_id = u32::from_le_bytes(code[ip + 1..ip + 5].try_into().unwrap()); + targets.push(prototype_id); + ip += 1 + vm::OpCode::CallScript.operand_len(); + } else { + ip += 1; + } + } + assert_eq!( + targets, + vec![1], + "direct generic call must target the specialized prototype" + ); + let prototype = &compiled.program.callable_prototypes[targets[0] as usize]; + let vm::compiler::TypeSchema::Callable { params, result } = prototype + .schema + .as_ref() + .expect("named prototype carries a callable schema") + else { + panic!("expected a callable schema"); + }; + assert_eq!( + params, + &[vm::compiler::TypeSchema::Int], + "specialized prototype schema must use the instantiated parameter type" + ); + assert_eq!( + result.as_ref(), + &vm::compiler::TypeSchema::Int, + "specialized prototype schema must use the instantiated result type" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // The static checker still rejects wrong-typed instantiations at + // compile time; the instantiated schema on the direct prototype is the + // runtime backstop and the wire-visible identity for the call site. + let rejected = compile_source( + r#" + fn identity(value: T) -> T { value } + identity::("not an int"); + "#, + ); + assert!( + matches!( + rejected, + Err(vm::SourceError::Compile( + vm::CompileError::CallableArgumentTypeMismatch { .. } + )) + ), + "wrong-typed generic instantiation must be rejected at compile time" + ); +} + +#[test] +fn direct_script_call_exported_resolution_is_unchanged() { + // `ExportedCallable.local_slot` and `resolve_exported_callable` keep + // working when other functions are direct-only. + let compiled = compile_source( + r#" + fn hidden_helper(x: int) -> int { x + 1 } + pub fn exported(x: int) -> int { hidden_helper(x) } + exported(41); + "#, + ) + .expect("exported program should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + let resolved = vm + .resolve_exported_callable("exported") + .expect("exported callable must resolve"); + assert!( + matches!(resolved, Value::Callable(_)), + "resolved exported value is a callable" + ); +} + +#[test] +fn direct_script_call_pressure_improves_with_slot_omission() { + // The 77-function dispatch fixture: every named function is called + // directly, so zero hidden callable slots remain and the aggregate + // frame-local count falls to the data-slot pressure. + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + assert!( + compiled.locals <= 30, + "direct-only functions must not consume hidden callable slots, got {}", + compiled.locals + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 8641db26..7c230c34 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -765,7 +765,8 @@ fn named_function_recursion_uses_runtime_frames_and_hits_depth_limit() { compiled .program .code - .contains(&(vm::OpCode::CallValue as u8)) + .contains(&(vm::OpCode::CallScript as u8)), + "non-capturing direct recursion lowers through CallScript" ); assert_eq!(compiled.program.script_functions.len(), 1); @@ -799,16 +800,16 @@ fn repeated_named_calls_share_one_emitted_body() { 1 ); let mut ip = 0usize; - let mut callvalue_count = 0usize; + let mut callscript_count = 0usize; while ip < compiled.program.code.len() { let opcode = vm::OpCode::try_from(compiled.program.code[ip]) .expect("compiler should emit valid opcodes"); - if opcode == vm::OpCode::CallValue { - callvalue_count += 1; + if opcode == vm::OpCode::CallScript { + callscript_count += 1; } ip += 1 + opcode.operand_len(); } - assert_eq!(callvalue_count, 3); + assert_eq!(callscript_count, 3); let mut runtime = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); assert_eq!( diff --git a/tests/compiler/diagnostics_tests.rs b/tests/compiler/diagnostics_tests.rs index 57651c3f..7e2926c8 100644 --- a/tests/compiler/diagnostics_tests.rs +++ b/tests/compiler/diagnostics_tests.rs @@ -396,3 +396,97 @@ fn myfn(v: T) { "generic schema local should not be reported as unknown, got {warnings:?}" ); } + +#[test] +fn frame_local_limit_diagnostic_reports_real_counts() { + // Aggregate frame pressure beyond 256 (200 genuinely live data slots in + // one function plus 60 exported callables: 60 exported helpers that stay + // materialized under milestone-6 lowering) must report the real counts + // instead of the old 65535 sentinel. The helpers are exported so they + // keep hidden callable slots; direct-only helpers would be omitted and + // the aggregate would fit. The sum is right-nested so codegen's + // string-classification recursion stays linear (it re-walks each left + // operand; left-nested sums of this size are exponential there). + let mut source = String::new(); + for idx in 0..60usize { + source.push_str(&format!("pub fn helper_{idx}() -> int {{ 0 }}\n")); + } + source.push_str("fn crowded() -> int {\n"); + for idx in 0..200usize { + source.push_str(&format!(" let v{idx} = {idx};\n")); + } + source.push_str(" "); + for idx in 0..200usize - 1 { + source.push_str(&format!("v{idx} + (")); + } + source.push_str("v199"); + for _ in 0..200usize - 1 { + source.push(')'); + } + source.push_str(";\n}\ncrowded();\n"); + + let err = match compile_source(&source) { + Ok(_) => panic!("aggregate frame pressure should fail to compile"), + Err(err) => err, + }; + let compile = match err { + vm::SourceError::Compile(compile) => compile, + other => panic!("expected compile error, got {other:?}"), + }; + match compile { + vm::CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + } => { + assert_eq!(data_slots, 200, "data slot count should be real"); + assert_eq!(callable_slots, 60, "callable slot count should be real"); + assert_eq!(total_slots, 260, "total should be the real aggregate"); + assert_eq!(max_slots, 256, "short bytecode ceiling should be 256"); + } + other => panic!("expected FrameLocalLimitExceeded, got {other:?}"), + } + + let mut source_map = SourceMap::new(); + source_map.add_source("inline.rss", &source); + let rendered = render_compile_error(&source_map, &compile, false); + assert!( + rendered.contains( + "frame requires 260 local slots (200 data + 60 callable); short bytecode supports 256" + ), + "unexpected diagnostic: {rendered}" + ); + assert!( + !rendered.contains("65535"), + "diagnostic must not report the old sentinel slot: {rendered}" + ); +} + +#[test] +fn frame_local_limit_diagnostic_reports_saturated_overflow_counts() { + // A saturated aggregate (usize overflow) must report the saturated counts + // rather than fabricating a concrete slot number. + let mut source_map = SourceMap::new(); + source_map.add_source("inline.rss", ""); + let err = vm::CompileError::FrameLocalLimitExceeded { + data_slots: usize::MAX - 5, + callable_slots: 5, + total_slots: usize::MAX, + max_slots: 256, + }; + let rendered = render_compile_error(&source_map, &err, false); + let expected = format!( + "frame requires {} local slots ({} data + 5 callable); short bytecode supports 256", + usize::MAX, + usize::MAX - 5 + ); + assert!( + rendered.contains(&expected), + "unexpected diagnostic: {rendered}" + ); + assert!( + !rendered.contains("65535"), + "diagnostic must not report the old sentinel slot: {rendered}" + ); +} diff --git a/tests/compiler/module_import_tests.rs b/tests/compiler/module_import_tests.rs index 10229fcd..d7a50c77 100644 --- a/tests/compiler/module_import_tests.rs +++ b/tests/compiler/module_import_tests.rs @@ -911,3 +911,108 @@ fn nested_module_host_namespace_import_stays_host() { remove_module_root(&root); } + +#[test] +fn frame_local_dispatch_module_split_pressure_is_bounded() { + // The same 77-function/32-branch call graph as the single-file frame-local + // dispatch test, split across semantic modules. Named-call pressure must + // be independent of import discovery order and linker local-base + // assignment: callee body footprints stay inside their own frames. + let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("modules") + .join("frame_local_dispatch"); + let main_path = fixture_root.join("main.rss"); + let compiled = compile_source_file(&main_path) + .expect("frame-local module dispatch program should compile"); + assert!( + compiled.locals <= 100, + "aggregate frame locals should stay within per-frame pressure plus callable slots, got {}", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + +#[test] +fn named_callable_materialization_module_split_same_name_materialization() { + // Two modules each declare a private `helper` with the same source name. + // Milestone 5 classification follows the resolved function identity, and + // milestone 6 lowering keeps every named function's prototype while + // omitting hidden slots for the direct-only helpers: each module's + // exported `run` stays materialized, and each module's `run` calls its + // own helper through the direct script-call path. + let root = temp_module_root("named_callable_materialization_same_name"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("same-named module helpers should compile"); + let program = &compiled.program; + assert_eq!( + program.callable_prototypes.len(), + 4, + "each module's run and each module's same-named helper keep a prototype" + ); + assert_eq!( + program.root_callable_bindings.len(), + 2, + "only the exported run functions stay materialized with root bindings" + ); + assert_eq!( + program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(), + 2, + "only the exported run functions keep their runtime self slot" + ); + assert_eq!( + program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 2, + "the direct-only same-named helpers keep no runtime self slot" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 2, + "each module's run calls its own helper through CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "each module's run must resolve its own same-named helper" + ); + + remove_module_root(&root); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_0.rss b/tests/fixtures/modules/frame_local_dispatch/chain_0.rss new file mode 100644 index 00000000..a483c457 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_0.rss @@ -0,0 +1,79 @@ +pub fn h_0(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_0(a: int, b: int) -> int { + let t = a + b; + h_0(t, a); +} + +pub fn h_1(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_1(a: int, b: int) -> int { + let t = a + b; + h_1(t, a); +} + +pub fn h_2(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_2(a: int, b: int) -> int { + let t = a + b; + h_2(t, a); +} + +pub fn h_3(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_3(a: int, b: int) -> int { + let t = a + b; + h_3(t, a); +} + +pub fn h_4(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_4(a: int, b: int) -> int { + let t = a + b; + h_4(t, a); +} + +pub fn h_5(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_5(a: int, b: int) -> int { + let t = a + b; + h_5(t, a); +} + +pub fn h_6(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_6(a: int, b: int) -> int { + let t = a + b; + h_6(t, a); +} + +pub fn h_7(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_7(a: int, b: int) -> int { + let t = a + b; + h_7(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_1.rss b/tests/fixtures/modules/frame_local_dispatch/chain_1.rss new file mode 100644 index 00000000..353ba6c6 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_1.rss @@ -0,0 +1,79 @@ +pub fn h_8(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_8(a: int, b: int) -> int { + let t = a + b; + h_8(t, a); +} + +pub fn h_9(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_9(a: int, b: int) -> int { + let t = a + b; + h_9(t, a); +} + +pub fn h_10(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_10(a: int, b: int) -> int { + let t = a + b; + h_10(t, a); +} + +pub fn h_11(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_11(a: int, b: int) -> int { + let t = a + b; + h_11(t, a); +} + +pub fn h_12(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_12(a: int, b: int) -> int { + let t = a + b; + h_12(t, a); +} + +pub fn h_13(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_13(a: int, b: int) -> int { + let t = a + b; + h_13(t, a); +} + +pub fn h_14(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_14(a: int, b: int) -> int { + let t = a + b; + h_14(t, a); +} + +pub fn h_15(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_15(a: int, b: int) -> int { + let t = a + b; + h_15(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_2.rss b/tests/fixtures/modules/frame_local_dispatch/chain_2.rss new file mode 100644 index 00000000..b2552fcb --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_2.rss @@ -0,0 +1,79 @@ +pub fn h_16(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_16(a: int, b: int) -> int { + let t = a + b; + h_16(t, a); +} + +pub fn h_17(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_17(a: int, b: int) -> int { + let t = a + b; + h_17(t, a); +} + +pub fn h_18(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_18(a: int, b: int) -> int { + let t = a + b; + h_18(t, a); +} + +pub fn h_19(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_19(a: int, b: int) -> int { + let t = a + b; + h_19(t, a); +} + +pub fn h_20(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_20(a: int, b: int) -> int { + let t = a + b; + h_20(t, a); +} + +pub fn h_21(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_21(a: int, b: int) -> int { + let t = a + b; + h_21(t, a); +} + +pub fn h_22(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_22(a: int, b: int) -> int { + let t = a + b; + h_22(t, a); +} + +pub fn h_23(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_23(a: int, b: int) -> int { + let t = a + b; + h_23(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_3.rss b/tests/fixtures/modules/frame_local_dispatch/chain_3.rss new file mode 100644 index 00000000..383b9268 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_3.rss @@ -0,0 +1,79 @@ +pub fn h_24(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_24(a: int, b: int) -> int { + let t = a + b; + h_24(t, a); +} + +pub fn h_25(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_25(a: int, b: int) -> int { + let t = a + b; + h_25(t, a); +} + +pub fn h_26(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_26(a: int, b: int) -> int { + let t = a + b; + h_26(t, a); +} + +pub fn h_27(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_27(a: int, b: int) -> int { + let t = a + b; + h_27(t, a); +} + +pub fn h_28(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_28(a: int, b: int) -> int { + let t = a + b; + h_28(t, a); +} + +pub fn h_29(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_29(a: int, b: int) -> int { + let t = a + b; + h_29(t, a); +} + +pub fn h_30(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_30(a: int, b: int) -> int { + let t = a + b; + h_30(t, a); +} + +pub fn h_31(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_31(a: int, b: int) -> int { + let t = a + b; + h_31(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_4.rss b/tests/fixtures/modules/frame_local_dispatch/chain_4.rss new file mode 100644 index 00000000..5d735b12 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_4.rss @@ -0,0 +1,64 @@ +pub fn f_32(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_33(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_34(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_35(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_36(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_37(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_38(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_39(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_40(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_41(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_42(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_43(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_44(a: int, b: int) -> int { + let t = a + b; + t; +} diff --git a/tests/fixtures/modules/frame_local_dispatch/main.rss b/tests/fixtures/modules/frame_local_dispatch/main.rss new file mode 100644 index 00000000..47556047 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/main.rss @@ -0,0 +1,45 @@ +use self::chain_0 as chain_0; +use self::chain_1 as chain_1; +use self::chain_2 as chain_2; +use self::chain_3 as chain_3; +use self::chain_4 as chain_4; + +fn dispatch(idx) { + let mut acc = 0; + if idx == 0 { acc = chain_0::f_0(acc, 1); } + else if idx == 1 { acc = chain_0::f_1(acc, 2); } + else if idx == 2 { acc = chain_0::f_2(acc, 3); } + else if idx == 3 { acc = chain_0::f_3(acc, 4); } + else if idx == 4 { acc = chain_0::f_4(acc, 5); } + else if idx == 5 { acc = chain_0::f_5(acc, 6); } + else if idx == 6 { acc = chain_0::f_6(acc, 7); } + else if idx == 7 { acc = chain_0::f_7(acc, 8); } + else if idx == 8 { acc = chain_1::f_8(acc, 9); } + else if idx == 9 { acc = chain_1::f_9(acc, 10); } + else if idx == 10 { acc = chain_1::f_10(acc, 11); } + else if idx == 11 { acc = chain_1::f_11(acc, 12); } + else if idx == 12 { acc = chain_1::f_12(acc, 13); } + else if idx == 13 { acc = chain_1::f_13(acc, 14); } + else if idx == 14 { acc = chain_1::f_14(acc, 15); } + else if idx == 15 { acc = chain_1::f_15(acc, 16); } + else if idx == 16 { acc = chain_2::f_16(acc, 17); } + else if idx == 17 { acc = chain_2::f_17(acc, 18); } + else if idx == 18 { acc = chain_2::f_18(acc, 19); } + else if idx == 19 { acc = chain_2::f_19(acc, 20); } + else if idx == 20 { acc = chain_2::f_20(acc, 21); } + else if idx == 21 { acc = chain_2::f_21(acc, 22); } + else if idx == 22 { acc = chain_2::f_22(acc, 23); } + else if idx == 23 { acc = chain_2::f_23(acc, 24); } + else if idx == 24 { acc = chain_3::f_24(acc, 25); } + else if idx == 25 { acc = chain_3::f_25(acc, 26); } + else if idx == 26 { acc = chain_3::f_26(acc, 27); } + else if idx == 27 { acc = chain_3::f_27(acc, 28); } + else if idx == 28 { acc = chain_3::f_28(acc, 29); } + else if idx == 29 { acc = chain_3::f_29(acc, 30); } + else if idx == 30 { acc = chain_3::f_30(acc, 31); } + else if idx == 31 { acc = chain_3::f_31(acc, 32); } + else { acc = chain_4::f_32(acc, 33); } + acc; +} +dispatch(0); +dispatch(31); diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index 2cca3dfb..284dc487 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -2503,6 +2503,8 @@ fn trace_jit_reports_exact_parent_exit_profiles() { let mut i = 0; let mut total = 0; + let f = choose; + while i < 64 { total = total + choose(i); i = i + 1; @@ -6069,6 +6071,8 @@ fn trace_jit_links_dynamic_concat_callable_graph() { out } let values: map = { "a": "one", "b": "two" }; + let f = encode_map; + let mut i = 0; let mut out = ""; while i < 8 { @@ -6574,6 +6578,8 @@ fn trace_jit_inlines_static_leaf_in_root_loop() { let source = r#" fn add_one(value: int) -> int { value + 1 } let mut i = 0; + let f = add_one; + while i < 100 { i = add_one(i); } @@ -6617,6 +6623,9 @@ fn trace_jit_guards_static_inline_callable_identity() { fn add_one(value: int) -> int { value + 1 } fn add_ten(value: int) -> int { value + 10 } let mut i = 0; + let f = add_one; + let g = add_ten; + let mut total = 0; while i < 100 { total = add_one(total); @@ -6662,6 +6671,9 @@ fn trace_jit_invalidates_native_inline_after_callable_local_replacement() { fn add_one(value: int) -> int { value + 1 } fn add_ten(value: int) -> int { value + 10 } let mut i = 0; + let f = add_one; + let g = add_ten; + let mut total = 0; while i < 100 { total = add_one(total); @@ -6842,6 +6854,7 @@ fn trace_jit_preserves_inline_callable_argument_schema_checks() { } let source = r#" fn ignore(value: int) -> int { 1 } + let f = ignore; let mut i = 0; let value: int = 7; while i < 100 { @@ -6887,6 +6900,14 @@ fn trace_jit_preserves_inline_callable_argument_schema_checks() { matches!(error, vm::VmError::TypeMismatch("callable argument schema")), "unexpected error: {error:?}" ); + // The call went through the CallValue boundary and was inlined by the + // trace JIT: the argument schema guard must have been exercised by the + // native trace rather than silently handled by the interpreter. + assert!( + any_trace_op(&vm.jit_snapshot(), "inline_call:0"), + "{}", + vm.dump_jit_info() + ); } #[test] @@ -6897,6 +6918,8 @@ fn trace_jit_inline_instruction_failure_restores_callee_frame() { let source = r#" fn get(values: [int], index: int) -> int { values[index] } let values: [int] = [10, 20]; + let f = get; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -6936,6 +6959,8 @@ fn trace_jit_inline_unbox_failure_matches_interpreter_error() { let source = r#" fn add_one(values: [int]) -> int { values[0] + 1 } let values: [int] = [7]; + let f = add_one; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7006,6 +7031,8 @@ fn trace_jit_preserves_inline_callable_return_schema_checks() { let source = r#" fn first(values: [int]) -> int { values[0] } let values: [int] = [7]; + let f = first; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7084,6 +7111,8 @@ fn trace_jit_inlines_array_swap_leaf() { temporary } let values: [int] = [1, 2]; + let f = swap; + let mut i = 0; while i < 100 { i = i + swap(values, 0, 1) * 0 + 1; @@ -7133,6 +7162,8 @@ fn trace_jit_inline_array_set_failure_restores_callee_frame() { values[0] } let values: [int] = [10, 20]; + let f = write; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7185,6 +7216,8 @@ fn trace_jit_inline_guard_exit_restores_callee() { } let mut i = 0; let mut result = 0; + let f = classify; + while i < 4 { result = classify(i); i = i + 1; @@ -7225,6 +7258,7 @@ fn trace_jit_inline_guard_exit_restores_callee() { fn trace_jit_call_site_profiles_clear_on_vm_reuse() { let source = r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; let mut i = 0; while i < 3 { i = add_one(i); @@ -7241,6 +7275,10 @@ fn trace_jit_call_site_profiles_clear_on_vm_reuse() { assert_eq!(vm.run().expect("first profile run"), VmStatus::Halted); assert_eq!(vm.jit_snapshot().metrics.script_call_observations, 3); + assert!( + !vm.jit_call_site_profiles().is_empty(), + "call-site profiles must be recorded through the callable boundary" + ); vm.reset_for_reuse(); @@ -7362,6 +7400,7 @@ fn trace_jit_missing_dynamic_return_target_does_not_use_stale_static_slot() { } let source = r#" fn inc(x: int) -> int { x + 1 } + let f = inc; let mut i = 0; let mut value = 0; while i < 32 { @@ -7384,6 +7423,11 @@ fn trace_jit_missing_dynamic_return_target_does_not_use_stale_static_slot() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(133)]); + assert!( + vm.jit_native_exec_count() > 0, + "the loop must execute natively to exercise return-target resolution: {}", + vm.dump_jit_info() + ); } #[test] @@ -7396,6 +7440,8 @@ fn trace_jit_links_nested_dynamic_script_callables_without_interpreter_handoff() fn add_two(value: int) -> int { value + 2 } fn apply(function: fn(int) -> int, value: int) -> int { function(value) } let mut i = 0; + let f = apply; + let mut total = 0; while i < 16 { let selected = if i < 8 => { add_one } else => { add_two }; @@ -7445,6 +7491,9 @@ fn trace_jit_links_finite_mutual_recursion_without_interpreter_handoff() { if value == 0 => { 0 } else => { even(value - 1) } } let mut i = 0; + let f = even; + let g = odd; + let mut total = 0; while i < 8 { total = total + even(8); @@ -7472,3 +7521,1050 @@ fn trace_jit_links_finite_mutual_recursion_without_interpreter_handoff() { ); assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); } + +// --------------------------------------------------------------------------- +// Milestone 7: `CallScript` backend parity (Trace JIT and AOT). +// +// The interpreter contract is pinned in tests/vm/call_script_tests.rs; these +// tests prove the same operation executes through the native JIT boundary and +// the whole-program AOT pipeline without being reinterpreted as host `Call` +// or dynamic `CallValue`. + +/// Build a program whose root body is a hot loop that calls +/// `CallScript(prototype_id, argc)` each iteration; the callee body is raw +/// bytes. Used to prove typed failures surface through the native boundary. +fn call_script_loop_program( + prototype_id: u32, + argc: u8, + arity: u8, + target: vm::CallableTarget, + capture_slots: Vec, + self_slot: Option, + callee_body: Vec, +) -> Program { + // Root body: + // ldc 0; stloc 0 i = 0 + // loop: (backward branch target) + // ldloc 0; ldc 1; add; stloc 0 + // callscript(prototype_id, argc) + // ldloc 0; ldc 4; clt; brfalse end + // br loop + // end: ldc 0; ret + let mut code = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + let loop_header = code.len() as u32; + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]); + code.extend_from_slice(&prototype_id.to_le_bytes()); + code.push(argc); + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 2, + 0, + 0, + 0, + OpCode::Clt as u8, + OpCode::Brfalse as u8, + ]); + // The brfalse target must be the instruction immediately after `br loop` + // (a `Br` opcode at code.len()+4 plus its four-byte operand), not a byte + // inside the `br` instruction. + let end_ip = code.len() as u32 + 9; + code.extend_from_slice(&end_ip.to_le_bytes()); + code.extend_from_slice(&[OpCode::Br as u8]); + code.extend_from_slice(&loop_header.to_le_bytes()); + // end: ldc 0; ret + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let function_entry = code.len() as u32; + code.extend_from_slice(&callee_body); + let function_end = code.len() as u32; + + Program::new(vec![Value::Int(0), Value::Int(1), Value::Int(4)], code) + .with_local_count(1) + .with_callable_metadata( + vec![vm::ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + }], + vec![ + vm::FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + vm::FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +#[test] +fn call_script_direct_call_loop_runs_natively() { + if !native_jit_supported() { + return; + } + // The callee contains a loop so inline analysis must reject it + // (BackwardBranch), forcing a native `call_script` call boundary. + let source = r#" + fn bump(value: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + value + 1 + } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("direct call loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("direct call loop should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(16)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected native call_script trace: {}", + vm.dump_jit_info() + ); + assert!( + !any_trace_op(&snapshot, "call_value"), + "CallScript must not be reinterpreted as CallValue: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_nested_direct_calls_resume_continuation() { + if !native_jit_supported() { + return; + } + let source = r#" + fn add2(value: int) -> int { value + 2 } + fn add5(value: int) -> int { add2(value) + 3 } + let mut i = 0; + let mut total = 0; + while i < 8 { + total = add5(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("nested direct calls should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("nested direct call loop should run"), + VmStatus::Halted + ); + // The continuation after each call resumes inside the traced loop and the + // accumulator survives across native boundaries. + assert_eq!(vm.stack(), &[Value::Int(40)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected nested call_script boundary trace: {}", + vm.dump_jit_info() + ); + assert!( + !any_trace_op(&snapshot, "call_value"), + "nested direct calls must not be reinterpreted as CallValue: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_direct_recursion_inside_loop() { + if !native_jit_supported() { + return; + } + let source = r#" + fn fact(n: int) -> int { + if n <= 1 => { 1 } else => { n * fact(n - 1) } + } + let mut i = 0; + let mut total = 0; + while i < 4 { + total = total + fact(5); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("direct recursion should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("direct recursion loop should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(480)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected recursive call_script boundary trace: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_failure_exit_reports_typed_error() { + if !native_jit_supported() { + return; + } + // Unbounded direct recursion is not inlinable (the body contains a + // nested `CallScript`), so the depth-limit failure must surface through + // the native `call_script` boundary as the same typed VmError the + // interpreter produces. + let source = r#" + fn f() -> int { f() } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + f(); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("failure program should compile"); + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + let plain_err = plain + .run() + .expect_err("interpreter recursion must hit the depth limit"); + + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let err = vm + .run() + .expect_err("recursion should fail through the native boundary"); + assert_eq!( + format!("{err:?}"), + format!("{plain_err:?}"), + "native failure must match the interpreter's typed error" + ); + assert!( + matches!(err, vm::VmError::CallStackOverflow { .. }), + "expected CallStackOverflow, got {err:?}" + ); + let snapshot = vm.jit_snapshot(); + assert!( + any_trace_op(&snapshot, "call_script"), + "expected the failure to flow through a recorded call_script trace: {}", + vm.dump_jit_info() + ); +} + +#[test] +fn call_script_capture_prototype_fails_typed() { + if !native_jit_supported() { + return; + } + // VMBC accepts a script prototype that *requires* captures (runtime + // concern); `CallScript` can never supply an environment, so every + // backend must fail with the interpreter's typed error. + let program = call_script_loop_program( + 0, + 0, + 0, + vm::CallableTarget::ScriptFunction(0), + vec![0], + None, + vec![ + OpCode::Ldc as u8, + 0, + 0, + 0, + 0, + OpCode::Pop as u8, + OpCode::Ret as u8, + ], + ); + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + let err = vm + .run() + .expect_err("capture-requiring prototype should fail through CallScript"); + assert!( + matches!(err, vm::VmError::CallScriptRequiresEnvironment(0)), + "expected CallScriptRequiresEnvironment(0), got {err:?}" + ); + let snapshot = vm.jit_snapshot(); + assert!( + any_trace_op(&snapshot, "call_script"), + "expected the typed failure to flow through a recorded call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The raw `CallScript` loop fixture must contain well-formed control flow: +/// the loop-exit `brfalse` lands on the instruction after `br loop`, and the +/// root body terminates with `ldc 0; ret` after the loop. A fixture whose +/// branch target points into the middle of the `br` instruction would decode +/// callee bytes as root code and produce a different final stack. (The loop +/// deliberately leaves the callee results on the stack, so it is not +/// traceable; this pins the bytecode layout itself.) +#[test] +fn call_script_raw_fixture_loop_completes() { + let program = call_script_loop_program( + 0, + 0, + 0, + vm::CallableTarget::ScriptFunction(0), + vec![], + None, + vec![OpCode::Ldc as u8, 1, 0, 0, 0, OpCode::Ret as u8], + ); + let mut vm = Vm::new(program); + + assert_eq!( + vm.run().expect("the raw fixture loop should complete"), + VmStatus::Halted + ); + // Four iterations push the callee result (Int(1)); the root's `end:` + // block then pushes Int(0) and returns. + assert_eq!( + vm.stack(), + &[ + Value::Int(1), + Value::Int(1), + Value::Int(1), + Value::Int(1), + Value::Int(0) + ] + ); +} + +#[test] +fn call_script_fuel_interruption_matches_interpreter() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + value + 1 + } + let mut i = 0; + let mut total = 0; + while i < 1000 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("fuel program should compile"); + + // Fuel interruption yields (VmStatus::Yielded with a Fuel reason); the + // interpreter and the JIT must both interrupt the direct-call loop the + // same way and then complete after recharging. + let drain = |vm: &mut Vm| { + loop { + match vm.run().expect("fuel-limited run should yield") { + VmStatus::Halted => break, + VmStatus::Yielded => { + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Fuel)); + vm.recharge_fuel(200).expect("fuel recharge should succeed"); + } + VmStatus::Waiting(_) => panic!("unexpected host wait"), + } + } + assert_eq!(vm.stack(), &[Value::Int(1000)]); + }; + + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + plain + .set_fuel_check_interval(1) + .expect("fuel interval should set"); + plain.set_fuel(200); + drain(&mut plain); + + // JIT: the direct call crosses the native boundary each iteration; fuel + // must still interrupt execution with the same yield contract. + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + vm.set_fuel_check_interval(1) + .expect("fuel interval should set"); + vm.set_fuel(200); + drain(&mut vm); +} + +#[test] +fn aot_call_script_direct_call_loop() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot direct call loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let status = vm.run().expect("aot direct call loop should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(16)]); + assert!( + vm.aot_exec_count() > 0, + "aot should execute the direct call loop natively: {}", + vm.dump_aot_info() + ); + assert!( + !vm.dump_aot_info().contains("interpreter-boundary"), + "aot should lower the call script program, not fall back: {}", + vm.dump_aot_info() + ); +} + +#[test] +fn aot_call_script_recursion() { + if !native_jit_supported() { + return; + } + let source = r#" + fn fact(n: int) -> int { + if n <= 1 => { 1 } else => { n * fact(n - 1) } + } + fact(8); + "#; + let compiled = compile_source(source).expect("aot recursion should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let status = vm.run().expect("aot recursion should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(40_320)]); + assert!( + vm.aot_exec_count() > 0, + "aot should execute the recursive program natively: {}", + vm.dump_aot_info() + ); +} + +#[test] +fn aot_call_script_failure_exit() { + if !native_jit_supported() { + return; + } + // Unbounded direct recursion fails with the interpreter's typed depth + // error through the AOT `call_script` boundary (the interpreter raises + // it inside `execute_call_script` and the bridge relays it unchanged). + let source = r#" + fn f() -> int { f() } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + f(); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot failure program should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let err = vm + .run() + .expect_err("recursion should fail through aot call script"); + assert!( + matches!(err, vm::VmError::CallStackOverflow { .. }), + "expected CallStackOverflow, got {err:?}" + ); +} + +#[test] +fn aot_call_script_epoch_interruption() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 1000 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot epoch program should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + vm.set_epoch_check_interval(1) + .expect("epoch interval update should succeed"); + vm.set_epoch_deadline(0) + .expect("setting epoch deadline should succeed"); + + let first = vm.run().expect("first aot run should yield"); + assert_eq!(first, VmStatus::Yielded); + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); + + vm.clear_epoch_deadline(); + let halted = vm.run().expect("run should halt after clearing epoch"); + assert_eq!(halted, VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::Int(1000))); +} +// Milestone 7 follow-up: JIT/interpreter parity for inlined `CallScript` +// callee frame initialization. +// +// The interpreter's `enter_script_frame` (1) freshly binds every root +// callable binding slot to an environment-free callable, (2) inherits every +// callable-valued caller local at the same slot index, and (3) rejects root +// bindings outside the callee frame with `InvalidFrameState`. The recorder's +// inline simulation must mirror all three so raw programs cannot diverge +// between the interpreter and the trace JIT. +// +/// Build a program whose root body is a hot loop that calls +/// `CallScript(1 /* probe */, 0)` and accumulates the probe's result into +/// local 3. `root_prefix` is emitted before the loop. The callee `probe` +/// reads local slot `read_slot`, returns 1 when `typeof(slot) == "callable"` +/// and 0 otherwise, through a single `Ret`. A root binding for prototype 0 +/// (`a`) lives at local slot 1. +fn call_script_probe_loop_program(root_prefix: Vec, read_slot: u8) -> Program { + // Default loop body: i = i + 1; acc += CallScript(probe). + let mut body = vec![ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]; + body.extend_from_slice(&1u32.to_le_bytes()); + body.push(0); // argc + body.extend_from_slice(&[ + OpCode::Ldloc as u8, + 3, + OpCode::Add as u8, + OpCode::Stloc as u8, + 3, + ]); + call_script_probe_loop_program_with_body(root_prefix, read_slot, &body, 4) +} + +/// Builds the probe-loop fixture with a caller-provided loop body and local +/// count. The shared loop tail (`i < 4; brfalse end; br loop`) follows +/// `loop_body`; `loop_body` must leave the operand stack empty. +fn call_script_probe_loop_program_with_body( + root_prefix: Vec, + read_slot: u8, + loop_body: &[u8], + local_count: usize, +) -> Program { + // constants: 0=Int(0) 1=Int(1) 2=Int(7) 3=String("callable") 4=Int(4) + // 5=Int(5) (used by the rebind prefix) + let mut code = root_prefix; + // loop header + let loop_header = code.len() as u32; + code.extend_from_slice(loop_body); + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 4, + 0, + 0, + 0, + OpCode::Clt as u8, + OpCode::Brfalse as u8, + ]); + // end label: after `br loop` (5 bytes after the brfalse operand). + let end_ip = code.len() as u32 + 9; + code.extend_from_slice(&end_ip.to_le_bytes()); + code.extend_from_slice(&[OpCode::Br as u8]); + code.extend_from_slice(&loop_header.to_le_bytes()); + // end: ldloc 3; ret + code.extend_from_slice(&[OpCode::Ldloc as u8, 3, OpCode::Ret as u8]); + // a: ldc 7; ret + let a_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldc as u8, 2, 0, 0, 0, OpCode::Ret as u8]); + // probe: ldloc read_slot; call typeof/1; ldc "callable"; ceq; + // brfalse zero; ldc 1; br done; zero: ldc 0; done: ret + let probe_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldloc as u8, read_slot]); + code.extend_from_slice(&[OpCode::Call as u8, 0xA0, 0xFF, 1]); + code.extend_from_slice(&[OpCode::Ldc as u8, 3, 0, 0, 0]); + code.extend_from_slice(&[OpCode::Ceq as u8, OpCode::Brfalse as u8]); + let zero = code.len() as u32 + 14; + code.extend_from_slice(&zero.to_le_bytes()); + code.extend_from_slice(&[OpCode::Ldc as u8, 1, 0, 0, 0, OpCode::Br as u8]); + let done = code.len() as u32 + 9; + code.extend_from_slice(&done.to_le_bytes()); + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let probe_end = code.len() as u32; + + Program::new( + vec![ + Value::Int(0), + Value::Int(1), + Value::Int(7), + Value::String(std::sync::Arc::new("callable".to_string())), + Value::Int(4), + Value::Int(5), + ], + code, + ) + .with_local_count(local_count) + .with_callable_metadata( + vec![ + vm::ScriptFunction { + entry_ip: a_entry, + end_ip: probe_entry, + }, + vm::ScriptFunction { + entry_ip: probe_entry, + end_ip: probe_end, + }, + ], + vec![ + vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target: vm::CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target: vm::CallableTarget::ScriptFunction(1), + arity: 0, + frame_local_count: 4, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + ], + vec![ + vm::FunctionRegion { + start_ip: 0, + end_ip: a_entry, + prototype_id: None, + }, + vm::FunctionRegion { + start_ip: a_entry, + end_ip: probe_entry, + prototype_id: Some(0), + }, + vm::FunctionRegion { + start_ip: probe_entry, + end_ip: probe_end, + prototype_id: Some(1), + }, + ], + vec![vm::RootCallableBinding { + local_slot: 1, + prototype_id: 0, + }], + ) +} + +fn run_call_script_probe_loop(program: Program, jit_enabled: bool) -> Result, String> { + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: jit_enabled, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + match vm.run() { + Ok(VmStatus::Halted) => Ok(vm.stack().to_vec()), + Ok(status) => Err(format!("unexpected status {status:?}")), + Err(err) => Err(format!("{err:?}")), + } +} + +/// The interpreter inherits every callable-valued caller local into the +/// callee frame at the same slot index. An inlined direct callee that reads +/// a non-binding callable local must see the same value the interpreter +/// would provide, not a null slot. +#[test] +fn call_script_inline_inherits_callable_local_from_caller() { + if !native_jit_supported() { + return; + } + // Root copies `a` (binding slot 1) into non-binding slot 2 before each + // `CallScript`; probe reads slot 2. + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldloc as u8, 1, OpCode::Stloc as u8, 2]); + let program = call_script_probe_loop_program(prefix, 2); + + let plain = run_call_script_probe_loop(program.clone(), false) + .expect("interpreter should run the probe loop"); + assert_eq!( + plain, + vec![Value::Int(4)], + "probe must see the inherited callable" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let result = vm.run().expect("jit should run the probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(4)], + "jit must mirror the interpreter" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The interpreter freshly binds every root callable binding slot on frame +/// entry, so a caller-side reassignment of the slot must not leak into an +/// inlined callee. The recorder must mirror that reset instead of copying +/// the caller's current slot value. +#[test] +fn call_script_inline_refreshes_root_binding_slot() { + if !native_jit_supported() { + return; + } + // Root reassigns binding slot 1 to Int(5) before the loop; probe reads + // slot 1 and must still see `a`'s fresh callable, not Int(5). + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldc as u8, 5, 0, 0, 0, OpCode::Stloc as u8, 1]); + let program = call_script_probe_loop_program(prefix, 1); + + let plain = run_call_script_probe_loop(program.clone(), false) + .expect("interpreter should run the probe loop"); + assert_eq!( + plain, + vec![Value::Int(4)], + "probe must see the freshly bound callable" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let result = vm.run().expect("jit should run the probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(4)], + "jit must mirror the interpreter" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +fn run_call_script_guarded_probe_loop( + program: Program, + jit_enabled: bool, +) -> Result, String> { + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: jit_enabled, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + // Install a capture-free callable at slot 4 so the loop body reaches a + // `CallValue` terminal every iteration; the interpreter then runs the + // callee and the code after it, which rewrites the inherited slot. The + // probe prototype is used because its frame fits the root binding. + vm.set_local( + 4, + Value::Callable(Arc::new(vm::CallableValue { + prototype_id: 1, + kind: vm::CallableKind::FunctionItem, + env: None, + })), + ) + .map_err(|err| format!("{err:?}"))?; + match vm.run() { + Ok(VmStatus::Halted) => Ok(vm.stack().to_vec()), + Ok(status) => Err(format!("unexpected status {status:?}")), + Err(err) => Err(format!("{err:?}")), + } +} + +/// The interpreter inherits callable-valued caller locals at the same slot +/// index, and an inlined `CallScript` callee can fold on the inherited +/// value's observed type. Re-entry after an interpreter handoff must not +/// run the folded callee against a rewritten slot: the recorder records an +/// entry guard for inherited callable locals, and cache lookup falls back +/// to the interpreter when the slot no longer holds the recorded callable. +#[test] +fn call_script_inline_guards_inherited_callable_local() { + if !native_jit_supported() { + return; + } + // Root copies `a` (binding slot 1) into non-binding slot 2 before the + // loop. Each iteration: i = i + 1; acc += CallScript(probe); + // CallValue(slot 4); pop; slot2 = 5; if (i < 4) goto loop. The probe + // returns 1 while slot 2 holds a callable and 0 otherwise. The trace + // records the inlined probe (folded `typeof` on the inherited slot) and + // terminates at the `CallValue`; the interpreter then rewrites slot 2, + // so a re-entered trace without an entry guard would keep folding the + // stale callable on later iterations. + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldloc as u8, 1, OpCode::Stloc as u8, 2]); + let mut body = vec![ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]; + body.extend_from_slice(&1u32.to_le_bytes()); + body.push(0); // argc + body.extend_from_slice(&[ + OpCode::Ldloc as u8, + 3, + OpCode::Add as u8, + OpCode::Stloc as u8, + 3, + OpCode::Ldloc as u8, + 4, + OpCode::CallValue as u8, + 0, // argc + OpCode::Pop as u8, + OpCode::Ldc as u8, + 5, + 0, + 0, + 0, + OpCode::Stloc as u8, + 2, + ]); + let program = call_script_probe_loop_program_with_body(prefix, 2, &body, 5); + + let plain = run_call_script_guarded_probe_loop(program.clone(), false) + .expect("interpreter should run the guarded probe loop"); + assert_eq!( + plain, + vec![Value::Int(1)], + "probe must see the rewritten slot from the second iteration" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + vm.set_local( + 4, + Value::Callable(Arc::new(vm::CallableValue { + prototype_id: 1, + kind: vm::CallableKind::FunctionItem, + env: None, + })), + ) + .expect("install callable"); + let result = vm.run().expect("jit should run the guarded probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(1)], + "jit must mirror the interpreter when the inherited callable slot is rewritten" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The interpreter reports `DivisionByZero` when a division inside a +/// direct-only callee fails through `CallScript`. The trace JIT's non-inline +/// `idiv` trap path and the AOT lowering predate `CallScript`: they relay +/// the failure before materializing the VM stack (`StackUnderflow`) or as a +/// raw `JitNative` entry failure without a typed `VmError`. This is a +/// pre-existing backend defect, not a `CallScript` gap, so the JIT/AOT sides +/// are pinned as a known regression instead of being silently fixed here. +/// +/// Ignored so CI stays green; run manually after any backend division work — +/// the JIT/AOT assertions flip when the pre-existing defect is fixed. +#[test] +#[ignore = "pre-existing non-inline JIT/AOT division failure path; run manually after backend division work"] +fn call_script_division_failure_path_known_regression() { + let source = r#" + fn div(n: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + 100 / n + } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + div(i); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("division program should compile"); + + // Interpreter contract: the callee's division failure surfaces through + // the `CallScript` boundary as a typed VmError. + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + let plain_err = plain.run().expect_err("interpreter division must fail"); + assert!( + matches!(plain_err, vm::VmError::DivisionByZero), + "expected DivisionByZero, got {plain_err:?}" + ); + + // KNOWN PRE-EXISTING REGRESSION: the traced non-inline `idiv` trap path + // reports StackUnderflow because the VM stack is not materialized before + // the error is relayed. Not a `CallScript` defect. + let mut vm = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let jit_err = vm.run().expect_err("jit division must fail"); + assert!( + matches!(jit_err, vm::VmError::StackUnderflow), + "pre-existing JIT division regression changed: {jit_err:?}" + ); + + // KNOWN PRE-EXISTING REGRESSION: the AOT entry relay reports a raw + // JitNative failure without a typed VmError. + let mut aot = Vm::new(compiled.program.with_local_count(compiled.locals)); + aot.compile_aot().expect("aot compile should succeed"); + let aot_err = aot.run().expect_err("aot division must fail"); + assert!( + matches!(aot_err, vm::VmError::JitNative(_)), + "pre-existing AOT division regression changed: {aot_err:?}" + ); +} diff --git a/tests/vm/call_script_tests.rs b/tests/vm/call_script_tests.rs new file mode 100644 index 00000000..f0ea3d48 --- /dev/null +++ b/tests/vm/call_script_tests.rs @@ -0,0 +1,423 @@ +//! Milestone 6: `CallScript` interpreter entry tests. +//! +//! These tests build raw `CallScript` bytecode (0x1A, prototype_id:u32 LE, +//! argc:u8) with hand-written callable metadata so the interpreter contract +//! is pinned independently of the compiler: frame entry, resume +//! continuation, operand stack cleanup, typed failures, depth limits, and +//! interruption ticks. +#[path = "../common/mod.rs"] +mod common; +use common::*; + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use vm::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, Program, ScriptFunction, + Value, VmError, VmStatus, +}; + +/// Build a program whose root body is `root_prefix` followed by +/// `CallScript(prototype_id, argc)` and `ret`; the callee body is supplied +/// as raw bytes. Callable metadata describes one prototype with the given +/// arity/target/captures/self slot. +#[allow(clippy::too_many_arguments)] +fn call_script_program( + prototype_id: u32, + argc: u8, + arity: u8, + target: CallableTarget, + capture_slots: Vec, + self_slot: Option, + root_prefix: Vec, + callee_body: Vec, +) -> Program { + let mut code = root_prefix; + code.push(0x1A); + code.extend_from_slice(&prototype_id.to_le_bytes()); + code.push(argc); + code.push(0x01); // ret + let function_entry = code.len() as u32; + code.extend_from_slice(&callee_body); + let function_end = code.len() as u32; + + Program::new(vec![Value::Int(41), Value::Int(1)], code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + }], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +/// A program whose callee (prototype 0) recursively calls itself through +/// `CallScript` with no arguments until the depth limit stops it. +fn call_script_recursion_program() -> Program { + // Root body: CallScript(0, 0), ret. + let mut code = vec![0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + let function_entry = code.len() as u32; + // Callee body: CallScript(0, 0), ret. + code.extend_from_slice(&[0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); + let function_end = code.len() as u32; + + Program::new(Vec::new(), code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +/// Callee body that returns `local 0 + 1` (parameter + 1). +fn callee_param_plus_one() -> Vec { + vec![0x0F, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x03, 0x01] +} + +#[test] +fn call_script_enters_script_frame_and_resumes_caller() { + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], // ldc 0 (41) + callee_param_plus_one(), + ); + let mut vm = Vm::new(program); + assert_eq!(vm.run().expect("script call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert_eq!(vm.call_depth(), 0); +} + +#[test] +fn call_script_preserves_caller_stack_below_operands() { + // Root: ldc 0 (41), ldc 0 (41), CallScript(0, 1), ret. The first value + // sits below the operand stack base and must survive the nested frame. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + callee_param_plus_one(), + ); + let mut vm = Vm::new(program); + assert_eq!(vm.run().expect("script call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(41), Value::Int(42)]); + assert_eq!(vm.call_depth(), 0); +} + +#[test] +fn call_script_rejects_stack_underflow() { + // argc is 2 but only one value is pushed. + let program = call_script_program( + 0, + 2, + 2, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!(vm.run(), Err(VmError::StackUnderflow))); +} + +#[test] +fn call_script_rejects_invalid_prototype_id() { + let program = call_script_program( + 99, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(99)) + )); +} + +#[test] +fn call_script_rejects_invalid_script_function_id() { + // The prototype exists, passes the environment and arity checks, but + // its `ScriptFunction` target id is out of range for the program's + // script-function table. The lookup must fail with the same typed + // error used for the missing-prototype branch rather than entering a + // bogus frame. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(5), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(0)) + )); +} + +#[test] +fn call_script_rejects_wrong_arity() { + // Prototype declares arity 1 but the call passes 2 operands. + let program = call_script_program( + 0, + 2, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallableArityMismatch { + prototype_id: 0, + expected: 1, + got: 2 + }) + )); +} + +#[test] +fn call_script_rejects_non_script_prototype() { + // `CallScript` is a static script-function call: a host-import + // prototype must be rejected instead of routing to the host path. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::HostImport(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(0)) + )); +} + +#[test] +fn call_script_preserves_script_depth_limit() { + let program = call_script_recursion_program(); + let mut vm = Vm::new(program); + vm.set_max_script_call_depth(3) + .expect("positive depth should be accepted"); + assert!(matches!( + vm.run(), + Err(VmError::CallStackOverflow { limit: 3 }) + )); +} + +#[test] +fn call_script_frame_entry_charges_interruption_ticks() { + // Frame entry through `CallScript` must charge interruption ticks like + // `CallValue`: with a tiny fuel budget the recursion exhausts fuel and + // the vm yields with the fuel reason instead of looping forever. + let program = call_script_recursion_program(); + let mut vm = Vm::new(program); + vm.set_fuel_check_interval(1) + .expect("interval update should succeed"); + vm.set_fuel(2); + let status = vm.run().expect("run should yield on fuel exhaustion"); + assert_eq!(status, VmStatus::Yielded); + assert_eq!(vm.get_fuel(), Some(0)); +} + +#[test] +fn call_script_rejects_capture_required_prototype() { + // `CallScript` supplies no callable environment: a prototype whose + // capture layout requires cells must be rejected with a typed error. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + vec![1], + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallScriptRequiresEnvironment(0)) + )); +} + +#[test] +fn call_script_recursion_resumes_caller_locals_intact() { + // Direct recursion through `CallScript`: each frame keeps its own + // parameter value, and the caller's locals survive the nested calls. + let source = r#" + fn countdown(n: int) -> int { + if n <= 0 => { 0 } else => { countdown(n - 1) } + } + let keep = "alive"; + countdown(3); + keep; + "#; + let compiled = compile_source(source).expect("recursion source should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(0), Value::string("alive")]); +} + +/// Host function that reports `Pending` once; the test delivers the +/// completion through `complete_host_op`. +struct PendingOnceHostOp { + call_count: Arc, + op_id: u64, +} + +impl HostFunction for PendingOnceHostOp { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Pending(self.op_id)) + } +} + +#[test] +fn call_script_rejects_self_slot_required_prototype() { + // `CallScript` supplies no callable environment: a prototype that + // requires a self binding is rejected with a typed error even when its + // capture layout is empty. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + Some(0), + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallScriptRequiresEnvironment(0)) + )); +} + +#[test] +fn call_script_callee_host_wait_resumes_caller_continuation() { + // The callee suspends mid-body on a host operation. After the host op + // completes, the callee frame resumes with its local state intact and + // returns through the `CallScript` continuation, which finishes with + // the caller stack below the call operands preserved. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + // Root: ldc 0 (41), ldc 0 (41), CallScript(0, 1), ret. The first + // 41 sits below the operand stack base and must survive the + // nested frame and the suspension. + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + // Callee: Call(host 0, 0), ldloc 0 (parameter), ret. + vec![0x11, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x01], + ); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(program); + vm.register_function(Box::new(PendingOnceHostOp { + call_count: Arc::clone(&calls), + op_id: 802, + })); + + let status = vm.run().expect("first run should wait"); + assert_eq!(status, VmStatus::Waiting(802)); + assert_eq!(calls.load(Ordering::SeqCst), 1, "host op should run once"); + + vm.complete_host_op(802, Vec::new()) + .expect("host op completion should succeed"); + let status = vm.resume().expect("resume should halt"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "resume must not re-enter the host function" + ); + assert_eq!( + vm.stack(), + &[Value::Int(41), Value::Int(41)], + "caller stack below the operands and the callee result must survive the suspension" + ); + assert_eq!(vm.call_depth(), 0); +} diff --git a/tests/vm/drop_contract_tests.rs b/tests/vm/drop_contract_tests.rs index f6ead6a3..4aea31c4 100644 --- a/tests/vm/drop_contract_tests.rs +++ b/tests/vm/drop_contract_tests.rs @@ -894,3 +894,141 @@ fn all_locals_null_after_halt_for_simple_program() { "c should be Null" ); } + +// --------------------------------------------------------------------------- +// 14. Named-call cross-frame drops +// --------------------------------------------------------------------------- + +#[test] +fn named_call_cross_frame_heap_values_drop_exactly_once() { + // Caller and callee frames each own heap values around a named call. + // Each additional dead callee-produced map must add exactly its own drop + // events: no double-drop of the caller's value, no omission of the + // callee's. + let drops_one = compile_run_drop_count( + r#" + fn pass(x) { x } + let a = { tag: "a" }; + let b = pass({ tag: "b" }); + 0; + "#, + ); + let drops_two = compile_run_drop_count( + r#" + fn pass(x) { x } + let a = { tag: "a" }; + let b = pass({ tag: "b" }); + let c = pass({ tag: "c" }); + 0; + "#, + ); + assert!( + drops_two > drops_one, + "more dead values across named calls should produce more drop events ({drops_two} vs {drops_one})" + ); + // The delta is exactly one extra map (map + key + value events) plus one + // extra call's machinery; a double-drop or an omitted drop would change it. + // Direct-only named calls (`CallScript`) no longer materialize a callable + // value, so the per-call machinery drops one event fewer than the + // `CallValue`-era baseline. + assert_eq!( + drops_two - drops_one, + 6, + "named calls should add exactly one map and one call of drop events" + ); +} + +#[test] +fn named_call_yield_resumes_with_caller_locals_intact() { + // A named callee suspends on a host op; the caller's heap local must + // survive the suspension, and the drop count must match the unsuspended + // control exactly. + let plain_source = r#" + fn paused(x) { + x; + } + let caller = { tag: "caller" }; + let back = paused({ tag: "callee" }); + 0; + "#; + let wait_source = r#" + fn wait(); + fn paused(x) { + wait(); + x; + } + let caller = { tag: "caller" }; + let back = paused({ tag: "callee" }); + 0; + "#; + let plain = compile_run_drop_count(plain_source); + + let compiled = compile_source(wait_source).expect("compile should succeed"); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = new_drop_contract_vm(compiled.program); + vm.register_function(Box::new(PendingOnce { + call_count: Arc::clone(&calls), + op_id: 802, + })); + + let status = vm.run().expect("first run should wait"); + assert_eq!(status, VmStatus::Waiting(802)); + vm.complete_host_op(802, Vec::new()) + .expect("complete should succeed"); + let status = vm.resume().expect("resume should halt"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(0)]); + assert_eq!(calls.load(Ordering::SeqCst), 1, "host op should run once"); + + assert_eq!( + vm.drop_contract_event_count(), + plain, + "suspension must not add or remove drop events" + ); +} + +// --------------------------------------------------------------------------- +// 11. Direct script-call (CallScript) drop behavior +// --------------------------------------------------------------------------- + +#[test] +fn direct_script_call_preserves_drop_contract() { + // A named helper invoked through the direct script-call path drops its + // dead heap locals exactly once per value and restores the caller + // stack. The callee's parameter (an int) and its dead string local are + // both dropped when the callee frame completes. + let source = r#" + fn consume(value: int) -> int { + let tmp = "temp"; + value + 1 + } + consume(41); + "#; + let drops = compile_run_drop_count(source); + assert_eq!( + drops, 2, + "callee parameter and tmp string each drop exactly once, got {drops}" + ); + let vm = compile_run_vm(source); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn direct_script_call_preserves_caller_heap_values() { + // A caller heap local must survive a direct script call and drop only + // at the root frame's end; the callee's scalar parameter drops in the + // callee frame. + let source = r#" + fn bump(value: int) -> int { value + 1 } + let keep = "alive"; + bump(1); + keep; + "#; + let drops = compile_run_drop_count(source); + assert_eq!( + drops, 2, + "callee parameter and caller keep string each drop once, got {drops}" + ); + let vm = compile_run_vm(source); + assert_eq!(vm.stack(), &[Value::Int(2), Value::string("alive")]); +} diff --git a/tests/vm_tests.rs b/tests/vm_tests.rs index 8856a0df..176b216c 100644 --- a/tests/vm_tests.rs +++ b/tests/vm_tests.rs @@ -18,3 +18,6 @@ mod vm_async_runtime_tests; #[path = "vm/vm_runtime_tests.rs"] mod vm_runtime_tests; + +#[path = "vm/call_script_tests.rs"] +mod call_script_tests; diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 9cd2a0f7..a48ffd92 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use vm::{ - ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, DebugFunction, DebugInfo, - DisassembleOptions, HostImport, LineInfo, LocalInfo, Program, TypeMap, ValidationError, Value, - ValueType, WireError, builtin_call_index, decode_program, disassemble_vmbc, - disassemble_vmbc_with_options, encode_program, infer_local_count, validate_program, + ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, CallableKind, CallablePrototype, + CallableTarget, DebugFunction, DebugInfo, DisassembleOptions, HostImport, LineInfo, LocalInfo, + Program, ScriptFunction, TypeMap, ValidationError, Value, ValueType, WireError, + builtin_call_index, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, + encode_program, infer_local_count, validate_program, }; #[test] @@ -55,7 +56,7 @@ fn wire_roundtrip_preserves_constants_and_code() { }); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 11); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -117,6 +118,13 @@ fn decode_rejects_invalid_magic_version_and_truncation() { Err(WireError::UnsupportedVersion(10)) )); + let mut v11_version = encoded.clone(); + v11_version[4..6].copy_from_slice(&11u16.to_le_bytes()); + assert!(matches!( + decode_program(&v11_version), + Err(WireError::UnsupportedVersion(11)) + )); + let truncated = &encoded[..encoded.len() - 1]; assert!(matches!( decode_program(truncated), @@ -172,7 +180,7 @@ fn validate_accepts_known_good_program() { } #[test] -fn callable_metadata_roundtrips_vmbc_v11() { +fn callable_metadata_roundtrips_vmbc_v12() { let compiled = vm::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } @@ -481,3 +489,251 @@ fn literal_string_builtin_indices_are_appended_and_publicly_resolved() { assert_eq!(BuiltinFunction::StringLowerAscii.call_index(), first + 2); assert_eq!(BuiltinFunction::StringSplitLiteral.call_index(), first - 1); } + +// --------------------------------------------------------------------------- +// Milestone 6: CallScript wire support (VMBC V12) +// --------------------------------------------------------------------------- + +#[test] +fn call_script_roundtrips_validation_and_disassembly() { + let mut code = vec![0x1A]; + code.extend_from_slice(&7u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + // The V12 validator resolves the prototype id against the callable + // metadata, so the fixture carries a matching prototype (id 7, arity 2, + // script-function target) plus one script function boundary. + let program = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + (0..8) + .map(|_| CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 2, + frame_local_count: 2, + parameter_slots: vec![0, 1], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }) + .collect(), + Vec::new(), + Vec::new(), + ); + + validate_program(&program, 0).expect("callscript should validate structurally"); + let bytes = encode_program(&program).expect("callscript should encode"); + let decoded = decode_program(&bytes).expect("callscript should decode"); + assert_eq!(decoded.code, program.code); + validate_program(&decoded, 0).expect("decoded callscript should validate"); + assert!(disassemble_vmbc(&bytes).unwrap().contains("callscript 7 2")); +} + +#[test] +fn call_script_text_assembler_parses_prototype_and_argc() { + let program = + vm::assemble("callscript 7 2\nret\n").expect("text assembler should parse callscript"); + let mut expected = vec![0x1A]; + expected.extend_from_slice(&7u32.to_le_bytes()); + expected.push(2); + expected.push(vm::OpCode::Ret as u8); + assert_eq!(program.code, expected); +} + +#[test] +fn validate_rejects_truncated_call_script_operands() { + // No operand bytes at all. + let missing_all = Program::new(vec![], vec![0x1A]); + assert!(matches!( + validate_program(&missing_all, 0), + Err(ValidationError::TruncatedOperand { + expected_bytes: 5, + .. + }) + )); + // Four of the five operand bytes present: the u32 prototype id without + // the trailing argc byte. + let mut missing_argc = vec![0x1A]; + missing_argc.extend_from_slice(&3u32.to_le_bytes()); + let missing_argc = Program::new(vec![], missing_argc); + assert!(matches!( + validate_program(&missing_argc, 0), + Err(ValidationError::TruncatedOperand { + expected_bytes: 5, + .. + }) + )); +} + +#[test] +fn validate_rejects_out_of_range_call_script_prototype() { + // CallScript(7, 2) with no callable prototypes at all: the target id is + // out of range and must be rejected deterministically at validation + // time instead of surfacing later as a runtime VM error. + let mut code = vec![0x1A]; + code.extend_from_slice(&7u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + let no_prototypes = Program::new(vec![], code); + assert!(matches!( + validate_program(&no_prototypes, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 7 + }) + )); + + // One prototype exists (id 0) but the call targets id 1. + let mut code = vec![0x1A]; + code.extend_from_slice(&1u32.to_le_bytes()); + code.push(0); + code.push(vm::OpCode::Ret as u8); + let out_of_range = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&out_of_range, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 1 + }) + )); +} + +#[test] +fn validate_rejects_call_script_arity_mismatch() { + // Prototype 0 declares arity 1 but the call passes 2 operands. + let mut code = vec![0x1A]; + code.extend_from_slice(&0u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + let program = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::InvalidCallScriptArity { + offset: 0, + prototype_id: 0, + expected: 1, + got: 2 + }) + )); +} + +#[test] +fn validate_rejects_call_script_targeting_host_import_prototype() { + // `CallScript` is a static script-function call: a host-import + // prototype is not a valid target. The VM rejects the same program + // shape with the typed `InvalidCallablePrototype` runtime error, so + // VMBC must reject it deterministically at validation time too. + let mut code = vec![0x1A]; + code.extend_from_slice(&0u32.to_le_bytes()); + code.push(1); + code.push(vm::OpCode::Ret as u8); + let program = Program::with_imports_and_debug( + Vec::new(), + code, + vec![HostImport { + name: "host_fn".to_string(), + arity: 1, + return_type: ValueType::Unknown, + }], + None, + ) + .with_callable_metadata( + Vec::new(), + vec![CallablePrototype { + kind: CallableKind::HostFunction, + target: CallableTarget::HostImport(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 0 + }) + )); +} + +#[test] +fn call_script_wire_version_is_v12_and_rejects_v11() { + let program = Program::new(vec![], vec![vm::OpCode::Ret as u8]); + let encoded = encode_program(&program).expect("encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + + let mut old = encoded.clone(); + old[4..6].copy_from_slice(&11u16.to_le_bytes()); + assert!(matches!( + decode_program(&old), + Err(WireError::UnsupportedVersion(11)) + )); +} + +#[test] +fn call_script_no_script_program_code_bytes_unchanged_by_version_bump() { + // The V12 bump must not alter instruction bytes for programs without + // script calls: encode a plain arithmetic program and verify the + // embedded code section is exactly the assembler output. + let mut bc = BytecodeBuilder::new(); + bc.ldc(0); + bc.ldc(1); + bc.add(); + bc.ret(); + let program = Program::new(vec![Value::Int(1), Value::Int(2)], bc.finish()); + let encoded = encode_program(&program).expect("encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + let decoded = decode_program(&encoded).expect("decode should succeed"); + assert_eq!(decoded.code, program.code); + assert_eq!(decoded.constants, program.constants); +}