From c77504bb0576616765be223c243c3159ebe0830c Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 17 Aug 2026 18:00:14 +0800 Subject: [PATCH] feat(compiler): finalize frame-local typing and ownership --- build.rs | 91 +- docs/callable-runtime.md | 35 +- pd-host-function/src/lib.rs | 49 +- 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 +- src/assembler.rs | 22 + src/builtins/metadata.rs | 24 + src/builtins/mod.rs | 2 + src/builtins/runtime/cancellation.rs | 1 + src/builtins/runtime/mod.rs | 4 +- src/builtins/runtime/typed.rs | 27 + src/bytecode.rs | 42 +- src/compiler/codegen.rs | 265 +- src/compiler/lifetime/availability.rs | 25 +- .../lifetime/availability/captures.rs | 271 +- src/compiler/lifetime/liveness.rs | 866 +++--- src/compiler/lifetime/mod.rs | 31 + src/compiler/materialization.rs | 2311 +++++++++++++++++ src/compiler/mod.rs | 26 + src/compiler/pipeline.rs | 311 ++- src/compiler/typing/collect.rs | 23 +- src/compiler/typing/context.rs | 505 +++- src/compiler/typing/state.rs | 1 + src/compiler/typing/validate.rs | 216 +- 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/instance.rs | 1 - src/vm/jit/inline.rs | 60 +- src/vm/jit/ir.rs | 36 +- src/vm/jit/native/lower.rs | 167 +- src/vm/jit/recorder.rs | 566 +++- src/vm/jit/region.rs | 7 +- src/vm/jit/trace.rs | 89 +- src/vm/mod.rs | 141 +- src/vm/native/bridge.rs | 182 +- src/vm/native/codegen.rs | 38 + src/vm/native/mod.rs | 24 +- src/vm/tests.rs | 57 + src/vmbc.rs | 82 +- tests/common/mod.rs | 48 + tests/compiler/compiler_common_tests.rs | 743 ++++++ tests/compiler/compiler_rustscript_tests.rs | 1710 +++++++++++- tests/compiler/diagnostics_tests.rs | 94 + tests/compiler/module_import_tests.rs | 1623 ++++++++++++ .../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/host_binding_generation_tests.rs | 20 +- tests/jit/jit_tests.rs | 1096 ++++++++ tests/vm/call_script_tests.rs | 423 +++ tests/vm/drop_contract_tests.rs | 138 + tests/vm/ownership_tests.rs | 784 +++++- tests/vm/vm_runtime_tests.rs | 89 +- tests/vm_tests.rs | 3 + tests/wire/wire_tests.rs | 318 ++- 66 files changed, 14094 insertions(+), 931 deletions(-) create mode 100644 pd-vm-nostd/tests/call_script_tests.rs 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/build.rs b/build.rs index 81a04201..ac699b0a 100644 --- a/build.rs +++ b/build.rs @@ -1201,9 +1201,9 @@ fn render_callable_consts(callables: &[&CallableDecl]) -> String { for param in &callable.params { writeln!( &mut out, - " CallableParam {{ name: {:?}, ty: CallableParamType::{}, optional: {} }},", + " CallableParam {{ name: {:?}, ty: {}, optional: {} }},", param.name, - callable_param_variant(¶m.ty_label), + callable_param_expr(¶m.ty_label), param.optional ) .unwrap(); @@ -1626,18 +1626,38 @@ fn callable_const_base(callable: &CallableDecl) -> String { to_shouty_snake(&format!("{prefix}_{}", callable.rust_ident)) } -fn callable_param_variant(label: &str) -> &'static str { +pub(crate) fn callable_param_expr(label: &str) -> String { match label { - "any" => "Any", - "null" => "Null", - "int" => "Int", - "float" => "Float", - "bool" => "Bool", - "string" => "String", - "bytes" => "Bytes", - "array" => "Array", - "map" => "Map", - "number" => "Number", + "any" => "CallableParamType::Any".to_string(), + "null" => "CallableParamType::Null".to_string(), + "int" => "CallableParamType::Int".to_string(), + "float" => "CallableParamType::Float".to_string(), + "bool" => "CallableParamType::Bool".to_string(), + "string" => "CallableParamType::String".to_string(), + "bytes" => "CallableParamType::Bytes".to_string(), + "array" => "CallableParamType::Array".to_string(), + "map" => "CallableParamType::Map".to_string(), + "number" => "CallableParamType::Number".to_string(), + other if other.starts_with("fn(") => { + let (params, result) = other + .strip_prefix("fn(") + .and_then(|value| value.split_once(") -> ")) + .unwrap_or_else(|| panic!("invalid callable schema '{other}'")); + let params = if params.is_empty() { + Vec::new() + } else { + params + .split(", ") + .map(callable_param_expr) + .collect::>() + }; + let result = callable_param_expr(result); + format!( + "CallableParamType::Callable(CallableType {{ params: &[{}], return_type: &{} }})", + params.join(", "), + result + ) + } other => panic!("unsupported callable param type '{other}'"), } } @@ -2063,7 +2083,7 @@ fn static_return_type_label(output: &ReturnType) -> String { value_type_from_label(&return_type_label(output)).to_string() } -fn type_label(ty: &Type) -> String { +pub(crate) fn type_label(ty: &Type) -> String { match ty { Type::Group(group) => type_label(&group.elem), Type::Paren(paren) => type_label(&paren.elem), @@ -2101,6 +2121,7 @@ fn type_label(ty: &Type) -> String { "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => "array".to_string(), "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => "map".to_string(), "Number" | "NumberValue" => "number".to_string(), + "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => "unknown".to_string(), "CallOutcome" => "unknown".to_string(), "Option" => { @@ -2129,6 +2150,25 @@ fn type_label(ty: &Type) -> String { } } +fn callable_type_label(segment: &syn::PathSegment) -> String { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + panic!("VmCallable requires a function signature"); + }; + let Some(syn::GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + panic!("VmCallable requires fn(...) -> ..."); + }; + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::>(); + let result = match &function.output { + ReturnType::Default => "null".to_string(), + ReturnType::Type(_, ty) => type_label(ty), + }; + format!("fn({}) -> {result}", params.join(", ")) +} + fn type_label_for_vec(segment: &syn::PathSegment) -> String { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { panic!("Vec requires one generic argument"); @@ -2303,3 +2343,26 @@ fn find_matching_paren(source: &str) -> usize { } panic!("unterminated macro invocation"); } + +#[cfg(test)] +mod callable_schema_tests { + use super::*; + use syn::parse_quote; + + #[test] + fn build_metadata_renders_typed_callable_parameters() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty), "fn(map) -> map"); + assert_eq!( + callable_param_expr("fn(map) -> map"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Map], return_type: &CallableParamType::Map })" + ); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty), "fn(float) -> float"); + assert_eq!( + callable_param_expr("fn(float) -> float"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Float], return_type: &CallableParamType::Float })" + ); + } +} 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-host-function/src/lib.rs b/pd-host-function/src/lib.rs index fb4f8f96..a0aa6e38 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -531,6 +531,7 @@ fn type_label(ty: &Type) -> Result { "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => Ok("array".to_string()), "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => Ok("map".to_string()), "Number" | "NumberValue" => Ok("number".to_string()), + "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => Ok("unknown".to_string()), "CallOutcome" => Ok("unknown".to_string()), "Option" => { @@ -575,6 +576,31 @@ fn type_label(ty: &Type) -> Result { } } +fn callable_type_label(segment: &syn::PathSegment) -> Result { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(Error::new_spanned( + &segment.arguments, + "VmCallable requires a function signature", + )); + }; + let Some(syn::GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + return Err(Error::new_spanned( + args, + "VmCallable requires fn(...) -> ...", + )); + }; + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::, _>>()?; + let result = match &function.output { + ReturnType::Default => "null".to_string(), + ReturnType::Type(_, ty) => type_label(ty)?, + }; + Ok(format!("fn({}) -> {result}", params.join(", "))) +} + fn type_label_for_vec(segment: &syn::PathSegment) -> Result { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(Error::new_spanned( @@ -672,8 +698,8 @@ fn uses_taken_extractor(ty: &Type) -> bool { #[cfg(test)] mod tests { - use super::expand_pd_host_function; - use syn::{ItemFn, Meta, Token, parse_quote, punctuated::Punctuated}; + use super::{expand_pd_host_function, type_label}; + use syn::{ItemFn, Meta, Token, Type, parse_quote, punctuated::Punctuated}; #[test] fn accepts_host_call_result_from_the_function_signature() { @@ -779,4 +805,23 @@ mod tests { .contains("parameters must be owned and 'static") ); } + + #[test] + fn callable_wrapper_preserves_parameter_and_result_schema() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty).unwrap(), "fn(map) -> map"); + let attr: Punctuated = parse_quote!(name = "test::stream"); + let item: ItemFn = parse_quote! { + /// Starts a synthetic callable stream. + fn stream(callback: VmCallable VmMap>) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("VmCallable < fn (VmMap) -> VmMap >")); + assert!(expanded.contains("borrow_arg")); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty).unwrap(), "fn(float) -> float"); + } } 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/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/builtins/metadata.rs b/src/builtins/metadata.rs index b7405f94..7881845f 100644 --- a/src/builtins/metadata.rs +++ b/src/builtins/metadata.rs @@ -10,6 +10,13 @@ pub enum CallableParamType { Array, Map, Number, + Callable(CallableType), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallableType { + pub params: &'static [CallableParamType], + pub return_type: &'static CallableParamType, } impl CallableParamType { @@ -25,6 +32,23 @@ impl CallableParamType { Self::Array => "array", Self::Map => "map", Self::Number => "number", + Self::Callable(_) => "function", + } + } + + pub fn display_label(self) -> String { + match self { + Self::Callable(signature) => format!( + "fn({}) -> {}", + signature + .params + .iter() + .map(|param| param.display_label()) + .collect::>() + .join(", "), + signature.return_type.display_label() + ), + other => other.label().to_string(), } } } diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index b47f7405..761ab5a2 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -5,6 +5,8 @@ mod metadata; #[cfg(feature = "runtime")] pub(crate) mod runtime; +#[cfg(test)] +pub use self::metadata::CallableType; pub use self::metadata::{ CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution, }; diff --git a/src/builtins/runtime/cancellation.rs b/src/builtins/runtime/cancellation.rs index 108dbf92..c21d4c47 100644 --- a/src/builtins/runtime/cancellation.rs +++ b/src/builtins/runtime/cancellation.rs @@ -688,6 +688,7 @@ impl OperationRegistry { .ok_or_else(|| operation_not_found(id)) } + #[cfg_attr(not(any(feature = "async", feature = "sqlite")), allow(dead_code))] pub fn operations_by_owner(&self, owner: OperationOwner) -> Vec { let operations = self.registered_operations(); operations diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 8a4cf8ed..3157288d 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -49,9 +49,10 @@ pub use io::{IoHostExt, IoPolicy}; #[cfg(feature = "sqlite")] pub use sqlite::{SqliteHostExt, SqliteLimits, SqlitePolicy}; pub use typed::HostCallResult; +#[allow(unused_imports)] use typed::{ AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, - VmBytes, VmMap, arg, borrow_arg, return_none, return_one, take_arg, + VmBytes, VmCallable, VmMap, arg, borrow_arg, return_none, return_one, take_arg, }; pub(crate) enum BuiltinCallOutcome { @@ -232,6 +233,7 @@ pub(crate) fn close_resources_by_type( } } +#[cfg_attr(not(any(feature = "async", feature = "sqlite")), allow(dead_code))] pub(crate) fn cancel_operations_by_owner( vm: &mut Vm, owner: OperationOwner, diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index f54a8d88..536350a4 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -1,6 +1,7 @@ use super::BuiltinCallOutcome; pub(super) use crate::bytecode::{SharedArray, SharedBytes, SharedMap, VmMap}; use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, VmError, VmResult}; +use std::marker::PhantomData; pub(super) type AnyValue = Value; pub(super) type UnknownValue = Value; @@ -20,6 +21,32 @@ pub(super) type VmBytesHandle = SharedBytes; #[allow(dead_code)] pub(super) type VmMapHandle = SharedMap; +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(super) struct VmCallable { + value: Value, + marker: PhantomData Signature>, +} + +impl VmCallable { + #[allow(dead_code)] + pub(super) fn into_value(self) -> Value { + self.value + } +} + +impl FromVmValue<'_> for VmCallable { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + if !matches!(value, Value::Callable(_)) { + return Err(VmError::TypeMismatch("callable")); + } + Ok(Self { + value: value.clone(), + marker: PhantomData, + }) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum NumberValue { Int(i64), 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/availability.rs b/src/compiler/lifetime/availability.rs index 10c5562c..cd7cd11f 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -144,6 +144,19 @@ pub(super) fn enforce_local_availability( // grows past the compat threshold, compact onto the minimal physical slot // set while still rejecting programs that need more than 256 simultaneous // locals. + Ok(ir) +} + +/// Compact the flat local slot space onto the minimal physical slot set. +/// +/// Kept separate from `enforce_local_availability` so callers can run the +/// callable-materialization classification on the *pre-compaction* IR: the +/// classifier tracks named-function values through slot flows, and merged +/// physical slots would collapse distinct flows into one slot, producing +/// spurious dynamic-target facts. Pre-compaction slots are the true +/// frame-relative value identities, so the classification is strictly more +/// precise on the unallocated IR. +pub(crate) fn allocate_local_slots(mut ir: FrontendIr) -> Result { if ir.locals > LOCAL_SLOT_ALLOCATOR_COMPAT_THRESHOLD { let allocator = LocalSlotAllocator::new(ir.locals, &ir.local_bindings, &ir.function_impls); ir = allocator.allocate(ir)?; @@ -329,7 +342,8 @@ impl AvailabilityAnalyzer { &mut out, *source_slot, *captured_slot, - capture_mode, + capture_mode.0, + capture_mode.1, ); } } @@ -419,7 +433,8 @@ impl AvailabilityAnalyzer { &mut out, *source_slot, *captured_slot, - capture_mode, + capture_mode.0, + capture_mode.1, ); } } @@ -777,7 +792,8 @@ impl AvailabilityAnalyzer { &mut out, *source_slot, *captured_slot, - capture_mode, + capture_mode.0, + capture_mode.1, ); } Ok(out) @@ -792,7 +808,8 @@ impl AvailabilityAnalyzer { &mut out, *source_slot, *captured_slot, - capture_mode, + capture_mode.0, + capture_mode.1, ); } Ok(out) diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index 82cb1774..747ff74d 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -1,5 +1,36 @@ use super::*; +/// Mutable state threaded through the capture-mode scan of a function or +/// closure body. The final mode matches what the runtime `BorrowMut` capture +/// model computes; `implicit_read` additionally records whether the body used +/// the captured slot through a plain by-value read with no explicit `.copy()` +/// or borrow wrapper. +/// +/// `implicit_read` only affects source consumption when the final mode is +/// `Copy`: it is the availability-side signal that a movable source binding +/// was consumed by an implicit by-value read even though the runtime clones +/// the value into the capture. It has no effect on the other modes — `Move` +/// consumes the source unconditionally, and `Borrow`/`BorrowMut` never +/// consume it (mutation flows back through the shared cell) regardless of how +/// the body reads the slot. Bodies that write the slot therefore always end +/// in `BorrowMut` (or `Move` under a move context) and never consult +/// `implicit_read` for consumption. +pub(super) struct CaptureModeScan { + mode: CaptureBindingMode, + seen: bool, + implicit_read: bool, +} + +impl CaptureModeScan { + fn new() -> Self { + Self { + mode: CaptureBindingMode::Copy, + seen: false, + implicit_read: false, + } + } +} + impl AvailabilityAnalyzer { pub(super) fn analyze_args( &self, @@ -119,6 +150,7 @@ impl AvailabilityAnalyzer { source_slot: LocalSlot, captured_slot: LocalSlot, capture_mode: CaptureBindingMode, + implicit_read: bool, ) { let source_idx = source_slot as usize; let captured_idx = captured_slot as usize; @@ -130,7 +162,22 @@ impl AvailabilityAnalyzer { } self.copy_local_field_moves(state, source_slot, captured_slot); self.copy_local_collection_aliases(state, source_slot, captured_slot); - if capture_mode == CaptureBindingMode::Move + // Availability and codegen consume the same capture-mode classifier. + // Codegen only needs the mode; availability additionally applies its + // stricter body-use model: a plain by-value use (an implicit read with + // no explicit `.copy()` or borrow) of a movable source consumes the + // source binding even though the runtime clones the value into the + // capture. Shared borrow captures (`Borrow`/`BorrowMut`) leave the + // source binding usable so mutation can flow back through the cell. + // `implicit_read` is consulted only when the final mode is `Copy`: + // `Move` consumes the source regardless, and the shared-borrow modes + // never consume it no matter how the body reads the slot. + let consumes_source = match capture_mode { + CaptureBindingMode::Move => true, + CaptureBindingMode::Borrow | CaptureBindingMode::BorrowMut => false, + CaptureBindingMode::Copy => implicit_read, + }; + if consumes_source && self.enable_local_move_semantics && source_idx < self.local_count && (state.movable_locals[source_idx] @@ -140,45 +187,57 @@ impl AvailabilityAnalyzer { } } + /// Classifies a named-function capture for availability: returns the + /// runtime capture mode plus whether the body contains an implicit + /// by-value read of the captured slot. pub(super) fn function_capture_mode_for_slot( &self, function_impl: &FunctionImpl, captured_slot: LocalSlot, - ) -> CaptureBindingMode { - let mut mode = CaptureBindingMode::Copy; - let mut seen = false; + ) -> (CaptureBindingMode, bool) { + let mut scan = CaptureModeScan::new(); self.capture_mode_for_stmts( &function_impl.body_stmts, captured_slot, - CaptureBindingMode::Move, - &mut mode, - &mut seen, + CaptureBindingMode::Copy, + true, + &mut scan, ); self.capture_mode_for_expr( &function_impl.body_expr, captured_slot, - CaptureBindingMode::Move, - &mut mode, - &mut seen, + CaptureBindingMode::Copy, + true, + &mut scan, ); - if seen { mode } else { CaptureBindingMode::Move } + if scan.seen { + (scan.mode, scan.implicit_read) + } else { + (CaptureBindingMode::Move, scan.implicit_read) + } } + /// Classifies a closure capture for availability: returns the runtime + /// capture mode plus whether the body contains an implicit by-value read + /// of the captured slot. pub(super) fn closure_capture_mode_for_slot( &self, closure: &ClosureExpr, captured_slot: LocalSlot, - ) -> CaptureBindingMode { - let mut mode = CaptureBindingMode::Copy; - let mut seen = false; + ) -> (CaptureBindingMode, bool) { + let mut scan = CaptureModeScan::new(); self.capture_mode_for_expr( &closure.body, captured_slot, - CaptureBindingMode::Move, - &mut mode, - &mut seen, + CaptureBindingMode::Copy, + true, + &mut scan, ); - if seen { mode } else { CaptureBindingMode::Move } + if scan.seen { + (scan.mode, scan.implicit_read) + } else { + (CaptureBindingMode::Move, scan.implicit_read) + } } pub(super) fn runtime_function_capture_mode_for_slot( @@ -186,23 +245,8 @@ impl AvailabilityAnalyzer { function_impl: &FunctionImpl, captured_slot: LocalSlot, ) -> CaptureBindingMode { - let mut mode = CaptureBindingMode::Copy; - let mut seen = false; - self.capture_mode_for_stmts( - &function_impl.body_stmts, - captured_slot, - CaptureBindingMode::Copy, - &mut mode, - &mut seen, - ); - self.capture_mode_for_expr( - &function_impl.body_expr, - captured_slot, - CaptureBindingMode::Copy, - &mut mode, - &mut seen, - ); - if seen { mode } else { CaptureBindingMode::Move } + self.function_capture_mode_for_slot(function_impl, captured_slot) + .0 } pub(super) fn runtime_closure_capture_mode_for_slot( @@ -210,16 +254,7 @@ impl AvailabilityAnalyzer { closure: &ClosureExpr, captured_slot: LocalSlot, ) -> CaptureBindingMode { - let mut mode = CaptureBindingMode::Copy; - let mut seen = false; - self.capture_mode_for_expr( - &closure.body, - captured_slot, - CaptureBindingMode::Copy, - &mut mode, - &mut seen, - ); - if seen { mode } else { CaptureBindingMode::Move } + self.closure_capture_mode_for_slot(closure, captured_slot).0 } pub(super) fn capture_mode_for_stmts( @@ -227,11 +262,11 @@ impl AvailabilityAnalyzer { stmts: &[Stmt], captured_slot: LocalSlot, context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, + implicit: bool, + scan: &mut CaptureModeScan, ) { for stmt in stmts { - self.capture_mode_for_stmt(stmt, captured_slot, context, mode, seen); + self.capture_mode_for_stmt(stmt, captured_slot, context, implicit, scan); } } @@ -240,8 +275,8 @@ impl AvailabilityAnalyzer { stmt: &Stmt, captured_slot: LocalSlot, context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, + implicit: bool, + scan: &mut CaptureModeScan, ) { match stmt { Stmt::Noop { .. } @@ -250,21 +285,30 @@ impl AvailabilityAnalyzer { | Stmt::Continue { .. } => {} Stmt::Drop { index, .. } => { if *index == captured_slot { - *seen = true; - *mode = (*mode).max(context); + scan.seen = true; + scan.mode = scan.mode.max(context); } } Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { if *index == captured_slot { - *seen = true; + // Writing the captured slot makes the capture shared-mutable + // (or a move under a move context), covering every + // AssignmentKind: plain `state = other` (write-only, RHS + // never reads the slot) and compound `state += rhs` / + // `state++`, whose synthesized `Add(Var(state), rhs)` RHS + // read is picked up below. Since any write forces the mode + // to at least `BorrowMut`, that read can never make + // `implicit_read` affect source consumption (see + // `apply_capture_binding_effect`). + scan.seen = true; let assignment_mode = if context == CaptureBindingMode::Move { CaptureBindingMode::Move } else { CaptureBindingMode::BorrowMut }; - *mode = (*mode).max(assignment_mode); + scan.mode = scan.mode.max(assignment_mode); } - self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); + self.capture_mode_for_expr(expr, captured_slot, context, implicit, scan); } Stmt::ClosureLet { closure, .. } => { for (nested_source_slot, nested_captured_slot) in &closure.capture_copies { @@ -273,15 +317,15 @@ impl AvailabilityAnalyzer { &closure.body, *nested_captured_slot, CaptureBindingMode::Move, - mode, - seen, + true, + scan, ); } } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); + self.capture_mode_for_expr(&closure.body, captured_slot, context, implicit, scan); } Stmt::Expr { expr, .. } => { - self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); + self.capture_mode_for_expr(expr, captured_slot, context, implicit, scan); } Stmt::IfElse { condition, @@ -289,9 +333,9 @@ impl AvailabilityAnalyzer { else_branch, .. } => { - self.capture_mode_for_expr(condition, captured_slot, context, mode, seen); - self.capture_mode_for_stmts(then_branch, captured_slot, context, mode, seen); - self.capture_mode_for_stmts(else_branch, captured_slot, context, mode, seen); + self.capture_mode_for_expr(condition, captured_slot, context, implicit, scan); + self.capture_mode_for_stmts(then_branch, captured_slot, context, implicit, scan); + self.capture_mode_for_stmts(else_branch, captured_slot, context, implicit, scan); } Stmt::For { init, @@ -300,16 +344,16 @@ impl AvailabilityAnalyzer { body, .. } => { - self.capture_mode_for_stmt(init, captured_slot, context, mode, seen); - self.capture_mode_for_expr(condition, captured_slot, context, mode, seen); - self.capture_mode_for_stmt(post, captured_slot, context, mode, seen); - self.capture_mode_for_stmts(body, captured_slot, context, mode, seen); + self.capture_mode_for_stmt(init, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(condition, captured_slot, context, implicit, scan); + self.capture_mode_for_stmt(post, captured_slot, context, implicit, scan); + self.capture_mode_for_stmts(body, captured_slot, context, implicit, scan); } Stmt::While { condition, body, .. } => { - self.capture_mode_for_expr(condition, captured_slot, context, mode, seen); - self.capture_mode_for_stmts(body, captured_slot, context, mode, seen); + self.capture_mode_for_expr(condition, captured_slot, context, implicit, scan); + self.capture_mode_for_stmts(body, captured_slot, context, implicit, scan); } } } @@ -319,8 +363,8 @@ impl AvailabilityAnalyzer { expr: &Expr, captured_slot: LocalSlot, context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, + implicit: bool, + scan: &mut CaptureModeScan, ) { match expr { Expr::Null @@ -334,35 +378,42 @@ impl AvailabilityAnalyzer { | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) => { if *index == captured_slot { - *seen = true; - *mode = (*mode).max(context); + scan.seen = true; + scan.mode = scan.mode.max(context); + // A bare by-value read (not wrapped in an explicit + // `.copy()` or borrow) is an implicit read: availability + // treats it as a move for movable source bindings even + // though the runtime clones the value into the capture. + if implicit && context == CaptureBindingMode::Copy { + scan.implicit_read = true; + } } } Expr::MoveVar(index) => { if *index == captured_slot { - *seen = true; - *mode = CaptureBindingMode::Move; + scan.seen = true; + scan.mode = CaptureBindingMode::Move; } } Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { if *root == captured_slot { - *seen = true; - *mode = CaptureBindingMode::Move; + scan.seen = true; + scan.mode = CaptureBindingMode::Move; } } Expr::OptionalGet { container, key, .. } => { - self.capture_mode_for_expr(container, captured_slot, context, mode, seen); - self.capture_mode_for_expr(key, captured_slot, context, mode, seen); + self.capture_mode_for_expr(container, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(key, captured_slot, context, implicit, scan); } Expr::OptionUnwrapOr { value, fallback, .. } => { - self.capture_mode_for_expr(value, captured_slot, context, mode, seen); - self.capture_mode_for_expr(fallback, captured_slot, context, mode, seen); + self.capture_mode_for_expr(value, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(fallback, captured_slot, context, implicit, scan); } Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { - self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); + self.capture_mode_for_expr(arg, captured_slot, context, implicit, scan); } } Expr::Closure(closure) => { @@ -372,16 +423,16 @@ impl AvailabilityAnalyzer { &closure.body, *nested_captured_slot, CaptureBindingMode::Move, - mode, - seen, + true, + scan, ); } } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); + self.capture_mode_for_expr(&closure.body, captured_slot, context, implicit, scan); } Expr::ClosureCall(closure, args) => { for arg in args { - self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); + self.capture_mode_for_expr(arg, captured_slot, context, implicit, scan); } for (nested_source_slot, nested_captured_slot) in &closure.capture_copies { if *nested_source_slot == captured_slot { @@ -389,12 +440,12 @@ impl AvailabilityAnalyzer { &closure.body, *nested_captured_slot, CaptureBindingMode::Move, - mode, - seen, + true, + scan, ); } } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); + self.capture_mode_for_expr(&closure.body, captured_slot, context, implicit, scan); } Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) @@ -406,19 +457,22 @@ impl AvailabilityAnalyzer { | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - self.capture_mode_for_expr(lhs, captured_slot, context, mode, seen); - self.capture_mode_for_expr(rhs, captured_slot, context, mode, seen); + self.capture_mode_for_expr(lhs, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(rhs, captured_slot, context, implicit, scan); } Expr::Neg(inner) | Expr::Not(inner) => { - self.capture_mode_for_expr(inner, captured_slot, context, mode, seen); + self.capture_mode_for_expr(inner, captured_slot, context, implicit, scan); } Expr::ToOwned(inner) => { + // Explicit `.copy()`: the value is duplicated on purpose and + // never consumes the source binding, so the inner read is not + // an implicit read. self.capture_mode_for_expr( inner, captured_slot, CaptureBindingMode::Copy, - mode, - seen, + false, + scan, ); } Expr::Borrow(inner) => { @@ -426,8 +480,8 @@ impl AvailabilityAnalyzer { inner, captured_slot, CaptureBindingMode::Borrow, - mode, - seen, + false, + scan, ); } Expr::BorrowMut(inner) => { @@ -435,8 +489,8 @@ impl AvailabilityAnalyzer { inner, captured_slot, CaptureBindingMode::BorrowMut, - mode, - seen, + false, + scan, ); } Expr::IfElse { @@ -444,9 +498,9 @@ impl AvailabilityAnalyzer { then_expr, else_expr, } => { - self.capture_mode_for_expr(condition, captured_slot, context, mode, seen); - self.capture_mode_for_expr(then_expr, captured_slot, context, mode, seen); - self.capture_mode_for_expr(else_expr, captured_slot, context, mode, seen); + self.capture_mode_for_expr(condition, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(then_expr, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(else_expr, captured_slot, context, implicit, scan); } Expr::Match { value_slot, @@ -461,18 +515,21 @@ impl AvailabilityAnalyzer { .iter() .any(|(pattern, _)| pattern.binding_slot() == Some(captured_slot)) { - *seen = true; - *mode = (*mode).max(context); + scan.seen = true; + scan.mode = scan.mode.max(context); + if implicit && context == CaptureBindingMode::Copy { + scan.implicit_read = true; + } } - self.capture_mode_for_expr(value, captured_slot, context, mode, seen); + self.capture_mode_for_expr(value, captured_slot, context, implicit, scan); for (_, arm_expr) in arms { - self.capture_mode_for_expr(arm_expr, captured_slot, context, mode, seen); + self.capture_mode_for_expr(arm_expr, captured_slot, context, implicit, scan); } - self.capture_mode_for_expr(default, captured_slot, context, mode, seen); + self.capture_mode_for_expr(default, captured_slot, context, implicit, scan); } Expr::Block { stmts, expr } => { - self.capture_mode_for_stmts(stmts, captured_slot, context, mode, seen); - self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); + self.capture_mode_for_stmts(stmts, captured_slot, context, implicit, scan); + self.capture_mode_for_expr(expr, captured_slot, context, implicit, scan); } } } diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 42f5fed8..c33667e3 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], } } @@ -320,14 +307,47 @@ impl LivenessRewriter { } fn compute_live_before_block(&self, stmts: &[Stmt], live_out: &LiveSet) -> LiveSet { + self.compute_live_before_block_impl(stmts, live_out, true) + } + + /// Like `compute_live_before_block` but without the conservative + /// dynamic-local-call fill: the slot allocator needs the actual live + /// sets, not the drop-insertion safety margin, so a `LocalCall` does not + /// turn every statement's live set (and therefore the interference + /// graph) into the whole program. + fn compute_live_before_block_precise(&self, stmts: &[Stmt], live_out: &LiveSet) -> LiveSet { + self.compute_live_before_block_impl(stmts, live_out, false) + } + + fn compute_live_before_block_impl( + &self, + stmts: &[Stmt], + live_out: &LiveSet, + conservative: bool, + ) -> LiveSet { let mut live = live_out.clone(); for stmt in stmts.iter().rev() { - live = self.compute_live_before_stmt(stmt, &live); + live = self.compute_live_before_stmt_impl(stmt, &live, conservative); } live } fn compute_live_before_stmt(&self, stmt: &Stmt, live_after: &LiveSet) -> LiveSet { + self.compute_live_before_stmt_impl(stmt, live_after, true) + } + + /// Like `compute_live_before_stmt` but without the conservative + /// dynamic-local-call fill (see `compute_live_before_block_precise`). + fn compute_live_before_stmt_precise(&self, stmt: &Stmt, live_after: &LiveSet) -> LiveSet { + self.compute_live_before_stmt_impl(stmt, live_after, false) + } + + fn compute_live_before_stmt_impl( + &self, + stmt: &Stmt, + live_after: &LiveSet, + conservative: bool, + ) -> LiveSet { match stmt { Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => live_after.clone(), Stmt::FuncDecl { @@ -349,13 +369,23 @@ impl LivenessRewriter { } Stmt::Expr { expr, .. } => { let mut live_before = live_after.clone(); - self.union_inplace(&mut live_before, &self.uses_expr(expr)); + let uses = if conservative { + self.uses_expr(expr) + } else { + self.uses_expr_precise(expr) + }; + self.union_inplace(&mut live_before, &uses); live_before } Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { let mut live_before = live_after.clone(); self.kill_slot(&mut live_before, *index); - self.union_inplace(&mut live_before, &self.uses_expr(expr)); + let uses = if conservative { + self.uses_expr(expr) + } else { + self.uses_expr_precise(expr) + }; + self.union_inplace(&mut live_before, &uses); live_before } Stmt::ClosureLet { closure, .. } => { @@ -372,21 +402,33 @@ impl LivenessRewriter { else_branch, .. } => { - let then_live = self.compute_live_before_block(then_branch, live_after); - let else_live = self.compute_live_before_block(else_branch, live_after); + let then_live = + self.compute_live_before_block_impl(then_branch, live_after, conservative); + let else_live = + self.compute_live_before_block_impl(else_branch, live_after, conservative); let mut live_before = then_live; self.union_inplace(&mut live_before, &else_live); - self.union_inplace(&mut live_before, &self.uses_expr(condition)); + let cond_uses = if conservative { + self.uses_expr(condition) + } else { + self.uses_expr_precise(condition) + }; + self.union_inplace(&mut live_before, &cond_uses); live_before } Stmt::While { condition, body, .. } => { - let cond_uses = self.uses_expr(condition); + let cond_uses = if conservative { + self.uses_expr(condition) + } else { + self.uses_expr_precise(condition) + }; let mut live_cond = live_after.clone(); self.union_inplace(&mut live_cond, &cond_uses); loop { - let body_live = self.compute_live_before_block(body, &live_cond); + let body_live = + self.compute_live_before_block_impl(body, &live_cond, conservative); let mut next = live_after.clone(); self.union_inplace(&mut next, &cond_uses); self.union_inplace(&mut next, &body_live); @@ -404,12 +446,18 @@ impl LivenessRewriter { body, .. } => { - let cond_uses = self.uses_expr(condition); + let cond_uses = if conservative { + self.uses_expr(condition) + } else { + self.uses_expr_precise(condition) + }; let mut live_cond = live_after.clone(); self.union_inplace(&mut live_cond, &cond_uses); loop { - let post_live = self.compute_live_before_stmt(post, &live_cond); - let body_live = self.compute_live_before_block(body, &post_live); + let post_live = + self.compute_live_before_stmt_impl(post, &live_cond, conservative); + let body_live = + self.compute_live_before_block_impl(body, &post_live, conservative); let mut next = live_after.clone(); self.union_inplace(&mut next, &cond_uses); self.union_inplace(&mut next, &body_live); @@ -418,7 +466,7 @@ impl LivenessRewriter { } live_cond = next; } - self.compute_live_before_stmt(init, &live_cond) + self.compute_live_before_stmt_impl(init, &live_cond, conservative) } } } @@ -429,7 +477,24 @@ impl LivenessRewriter { live } + /// Like `uses_expr` but without the conservative dynamic-local-call + /// fill: `Expr::LocalCall` contributes only its target slot and argument + /// uses. The liveness *rewriter* keeps the conservative fill so captured + /// slots are never cleared before a dynamic call executes; the slot + /// *allocator* uses this precise variant so a single closure- or + /// callable-variable call does not turn the whole program's live sets + /// (and therefore the interference graph) into one complete clique. + fn uses_expr_precise(&self, expr: &Expr) -> LiveSet { + let mut live = self.empty_set(); + self.add_expr_uses_impl(expr, &mut live, false); + live + } + fn add_expr_uses(&self, expr: &Expr, live: &mut LiveSet) { + self.add_expr_uses_impl(expr, live, true); + } + + fn add_expr_uses_impl(&self, expr: &Expr, live: &mut LiveSet, conservative: bool) { match expr { Expr::Null | Expr::Int(_) @@ -452,8 +517,8 @@ impl LivenessRewriter { } => { self.mark_live(live, *container_slot); self.mark_live(live, *key_slot); - self.add_expr_uses(container, live); - self.add_expr_uses(key, live); + self.add_expr_uses_impl(container, live, conservative); + self.add_expr_uses_impl(key, live, conservative); } Expr::OptionUnwrapOr { value, @@ -461,17 +526,17 @@ impl LivenessRewriter { fallback, } => { self.mark_live(live, *value_slot); - self.add_expr_uses(value, live); - self.add_expr_uses(fallback, live); - } - Expr::Call(index, _, args) => { + self.add_expr_uses_impl(value, live, conservative); + self.add_expr_uses_impl(fallback, live, conservative); + } + 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); + self.add_expr_uses_impl(arg, live, conservative); } } // Resolved module calls (pre-merge only) contribute their @@ -479,34 +544,39 @@ impl LivenessRewriter { // footprint is folded in by the post-merge call lowering. Expr::ModuleCall(_, _, args) => { for arg in args { - self.add_expr_uses(arg, live); + self.add_expr_uses_impl(arg, live, conservative); } } Expr::LocalCall(index, _, args) => { self.mark_live(live, *index); for arg in args { - self.add_expr_uses(arg, live); + self.add_expr_uses_impl(arg, live, conservative); + } + if conservative { + // Local-call targets can be inline closures whose captured + // slots are not directly visible from the call expression. + // Keep locals live conservatively so closure captures are + // not cleared before the call executes. The allocator's + // precise variant (used for interference constraints) + // skips this fill so a dynamic call cannot collapse the + // whole program into one interference clique. + live.fill(true); } - // Local-call targets can be inline closures whose captured - // slots are not directly visible from the call expression. - // Keep locals live conservatively so closure captures are not - // cleared before the call executes. - live.fill(true); } Expr::Closure(closure) => { for (source_slot, _) in &closure.capture_copies { self.mark_live(live, *source_slot); } - self.add_expr_uses(&closure.body, live); + self.add_expr_uses_impl(&closure.body, live, conservative); } Expr::ClosureCall(closure, args) => { for arg in args { - self.add_expr_uses(arg, live); + self.add_expr_uses_impl(arg, live, conservative); } for (source_slot, _) in &closure.capture_copies { self.mark_live(live, *source_slot); } - self.add_expr_uses(&closure.body, live); + self.add_expr_uses_impl(&closure.body, live, conservative); } Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) @@ -518,22 +588,22 @@ impl LivenessRewriter { | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - self.add_expr_uses(lhs, live); - self.add_expr_uses(rhs, live); + self.add_expr_uses_impl(lhs, live, conservative); + self.add_expr_uses_impl(rhs, live, conservative); } Expr::Neg(inner) | Expr::Not(inner) | Expr::ToOwned(inner) | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => self.add_expr_uses(inner, live), + | Expr::BorrowMut(inner) => self.add_expr_uses_impl(inner, live, conservative), Expr::IfElse { condition, then_expr, else_expr, } => { - self.add_expr_uses(condition, live); - self.add_expr_uses(then_expr, live); - self.add_expr_uses(else_expr, live); + self.add_expr_uses_impl(condition, live, conservative); + self.add_expr_uses_impl(then_expr, live, conservative); + self.add_expr_uses_impl(else_expr, live, conservative); } Expr::Match { value, @@ -541,15 +611,23 @@ impl LivenessRewriter { default, .. } => { - self.add_expr_uses(value, live); + self.add_expr_uses_impl(value, live, conservative); for (_, arm) in arms { - self.add_expr_uses(arm, live); + self.add_expr_uses_impl(arm, live, conservative); } - self.add_expr_uses(default, live); + self.add_expr_uses_impl(default, live, conservative); } Expr::Block { stmts, expr } => { - let live_out = self.uses_expr(expr); - let live_before = self.compute_live_before_block(stmts, &live_out); + let live_out = if conservative { + self.uses_expr(expr) + } else { + self.uses_expr_precise(expr) + }; + let live_before = if conservative { + self.compute_live_before_block(stmts, &live_out) + } else { + self.compute_live_before_block_precise(stmts, &live_out) + }; self.union_inplace(live, &live_before); } } @@ -626,359 +704,25 @@ 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) + /// Precise variant of `function_body_live_out` for the slot allocator + /// (no conservative dynamic-local-call fill, see + /// `compute_live_before_block_precise`). + fn function_body_live_out_precise( + &self, + body_expr: &Expr, + capture_copies: &[(LocalSlot, LocalSlot)], + persistent_slots: &[LocalSlot], + ) -> LiveSet { + let mut live_out = self.uses_expr_precise(body_expr); + for (_, captured_slot) in capture_copies { + self.mark_live(&mut live_out, *captured_slot); } - Expr::Block { stmts, expr } => { - stmts.iter().any(stmt_contains_local_call) || expr_contains_local_call(expr) + for slot in persistent_slots { + self.mark_live(&mut live_out, *slot); } + live_out } } - fn stmt_line(stmt: &Stmt) -> u32 { match stmt { Stmt::Noop { line } @@ -1001,8 +745,14 @@ pub(super) struct LocalSlotAllocator { liveness: LivenessRewriter, function_impls: HashMap, adjacency: Vec>, - function_footprint_cache: HashMap, full_footprint: LiveSet, + /// True while collecting a closure body's constraints. Closure bodies run + /// in their own callee frame, so the conservative dynamic-local-call + /// cross-live (which exists to keep unknown callable targets separated at + /// the call site) must not spread into closure collection: there it would + /// turn the closure body's slots into a program-wide clique and destroy + /// compaction (and can push frames past the 256-slot limit spuriously). + in_closure_body: bool, } impl LocalSlotAllocator { @@ -1017,8 +767,8 @@ 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], + in_closure_body: false, } } @@ -1028,16 +778,77 @@ impl LocalSlotAllocator { for slot in &persistent_slots { self.liveness.mark_live(&mut live_out, *slot); } - let _ = self.collect_block(&ir.stmts, &live_out)?; + let _ = self.collect_block(&ir.stmts, &live_out, &[])?; for function_impl in ir.function_impls.values() { - let live_after = self.liveness.function_body_live_out( + let mut live_after = self.liveness.function_body_live_out_precise( &function_impl.body_expr, &function_impl.capture_copies, &persistent_slots, ); + // Parameters are written by the caller at frame entry and may be + // read at any point in the body, so every parameter must interfere + // with every other slot in the function for the WHOLE body, not + // only with the slots live at body entry. A local that is defined + // after entry (and is therefore absent from the entry live set) + // must still never be colored onto a parameter slot: when it is, + // the callee frame reads the wrong slot while evaluating call + // arguments and the VM callable-schema check fails + // (`type mismatch: expected string`) even though every value is + // correctly typed. + // + // This invariant is deliberately conservative. Body statements + // *can* define parameter slots: an `Assign` may target a + // parameter, and the liveness rewriter may emit `Drop` + // statements for parameter slots after their last use. The + // rule is therefore not "the body never defines a parameter + // slot"; it is a safety rule: parameter slots are + // caller-written frame-entry state that the callee frame may + // read at any point (directly, through captures, or through + // nested closures), so the allocator treats them as live for + // the entire body no matter what the body does to them. + // `collect_block` re-marks the current function's parameter + // slots after every statement, so the backward sweep can never + // let a body-local share a parameter's physical slot, while + // non-parameter locals keep sharing physical slots exactly as + // before. + // + // Closures execute in their own callee frame whose slot layout + // is drawn from the same flat slot space, so the same full-body + // rule applies to every closure: each closure's own parameter + // slots are seeded into its own body live-out + // (`collect_closure_body_constraints`) and kept live for the + // whole closure body regardless of body Assign/Drop statements. + // Nested closures are traversed recursively, and each closure's + // protection is scoped to its own body: an inner closure's + // parameters never leak into the outer closure's or the + // enclosing function's interference sets, and vice versa, + // because each closure body is collected against its own fresh + // live-out. + for slot in &function_impl.param_slots { + self.liveness.mark_live(&mut live_after, *slot); + } self.add_live_clique(&live_after); - self.collect_expr_constraints(&function_impl.body_expr, &live_after)?; - let _ = self.collect_block(&function_impl.body_stmts, &live_after)?; + self.collect_expr_constraints( + &function_impl.body_expr, + &live_after, + &function_impl.param_slots, + )?; + let body_live_in = self.collect_block( + &function_impl.body_stmts, + &live_after, + &function_impl.param_slots, + )?; + // Parameters stay live from body entry to the end, so the entry + // clique must keep every parameter mutually interfering as well + // (a parameter the body never uses has no other liveness edges + // and the colorer would otherwise alias distinct parameters onto + // one physical slot, corrupting operand placement at every call + // site that targets the function). + let mut entry_live = body_live_in; + for slot in &function_impl.param_slots { + self.liveness.mark_live(&mut entry_live, *slot); + } + self.add_live_clique(&entry_live); } let (mapping, compacted_local_count) = self.color_slots()?; @@ -1045,14 +856,28 @@ impl LocalSlotAllocator { Ok(ir) } - fn collect_block(&mut self, stmts: &[Stmt], live_out: &LiveSet) -> Result { + fn collect_block( + &mut self, + stmts: &[Stmt], + live_out: &LiveSet, + protected_slots: &[LocalSlot], + ) -> Result { let mut live_after = live_out.clone(); self.add_live_clique(&live_after); for stmt in stmts.iter().rev() { - let live_before = self.liveness.compute_live_before_stmt(stmt, &live_after); + let mut live_before = self + .liveness + .compute_live_before_stmt_precise(stmt, &live_after); + // Parameter slots stay live for the whole body no matter what the + // statement does to them (see `allocate`); re-mark them so the + // interference invariants never depend on def-use precision for + // caller-written frame-entry state. + for slot in protected_slots { + self.liveness.mark_live(&mut live_before, *slot); + } self.add_live_clique(&live_before); self.add_stmt_def_edges(stmt, &live_after); - self.collect_stmt_constraints(stmt, &live_before, &live_after)?; + self.collect_stmt_constraints(stmt, &live_before, &live_after, protected_slots)?; live_after = live_before; } Ok(live_after) @@ -1063,6 +888,7 @@ impl LocalSlotAllocator { stmt: &Stmt, live_before: &LiveSet, live_after: &LiveSet, + protected_slots: &[LocalSlot], ) -> Result<(), ParseError> { match stmt { Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } | Stmt::Drop { .. } => {} @@ -1078,13 +904,14 @@ impl LocalSlotAllocator { } } Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { - self.collect_expr_constraints(expr, live_before)?; + self.collect_expr_constraints(expr, live_before, protected_slots)?; } Stmt::ClosureLet { closure, .. } => { for (source_slot, captured_slot) in &closure.capture_copies { self.add_slot_live_edges(*source_slot, live_before); self.add_slot_live_edges(*captured_slot, live_before); } + self.collect_closure_body_constraints(closure)?; } Stmt::IfElse { condition, @@ -1092,18 +919,20 @@ impl LocalSlotAllocator { else_branch, .. } => { - self.collect_expr_constraints(condition, live_before)?; - let _ = self.collect_block(then_branch, live_after)?; - let _ = self.collect_block(else_branch, live_after)?; + self.collect_expr_constraints(condition, live_before, protected_slots)?; + let _ = self.collect_block(then_branch, live_after, protected_slots)?; + let _ = self.collect_block(else_branch, live_after, protected_slots)?; } Stmt::While { condition, body, .. } => { - let cond_uses = self.liveness.uses_expr(condition); + let cond_uses = self.liveness.uses_expr_precise(condition); let mut live_cond = live_after.clone(); self.liveness.union_inplace(&mut live_cond, &cond_uses); loop { - let body_live = self.liveness.compute_live_before_block(body, &live_cond); + let body_live = self + .liveness + .compute_live_before_block_precise(body, &live_cond); let mut next = live_after.clone(); self.liveness.union_inplace(&mut next, &cond_uses); self.liveness.union_inplace(&mut next, &body_live); @@ -1112,8 +941,8 @@ impl LocalSlotAllocator { } live_cond = next; } - self.collect_expr_constraints(condition, &live_cond)?; - let _ = self.collect_block(body, &live_cond)?; + self.collect_expr_constraints(condition, &live_cond, protected_slots)?; + let _ = self.collect_block(body, &live_cond, protected_slots)?; } Stmt::For { init, @@ -1122,12 +951,16 @@ impl LocalSlotAllocator { body, .. } => { - let cond_uses = self.liveness.uses_expr(condition); + let cond_uses = self.liveness.uses_expr_precise(condition); let mut live_cond = live_after.clone(); self.liveness.union_inplace(&mut live_cond, &cond_uses); loop { - let post_live = self.liveness.compute_live_before_stmt(post, &live_cond); - let body_live = self.liveness.compute_live_before_block(body, &post_live); + let post_live = self + .liveness + .compute_live_before_stmt_precise(post, &live_cond); + let body_live = self + .liveness + .compute_live_before_block_precise(body, &post_live); let mut next = live_after.clone(); self.liveness.union_inplace(&mut next, &cond_uses); self.liveness.union_inplace(&mut next, &body_live); @@ -1136,20 +969,32 @@ impl LocalSlotAllocator { } live_cond = next; } - let post_live_before = self.liveness.compute_live_before_stmt(post, &live_cond); - self.collect_expr_constraints(condition, &live_cond)?; - self.collect_stmt_constraints(post, &post_live_before, &live_cond)?; - let _ = self.collect_block(body, &post_live_before)?; - self.collect_stmt_constraints(init, live_before, &live_cond)?; + let post_live_before = self + .liveness + .compute_live_before_stmt_precise(post, &live_cond); + self.collect_expr_constraints(condition, &live_cond, protected_slots)?; + self.collect_stmt_constraints( + post, + &post_live_before, + &live_cond, + protected_slots, + )?; + let _ = self.collect_block(body, &post_live_before, protected_slots)?; + self.collect_stmt_constraints(init, live_before, &live_cond, protected_slots)?; } } Ok(()) } - fn collect_expr_constraints(&mut self, expr: &Expr, live: &LiveSet) -> Result<(), ParseError> { + fn collect_expr_constraints( + &mut self, + expr: &Expr, + live: &LiveSet, + protected_slots: &[LocalSlot], + ) -> Result<(), ParseError> { let mut live_during = live.clone(); self.liveness - .union_inplace(&mut live_during, &self.liveness.uses_expr(expr)); + .union_inplace(&mut live_during, &self.liveness.uses_expr_precise(expr)); match expr { Expr::Null | Expr::Int(_) @@ -1174,8 +1019,8 @@ impl LocalSlotAllocator { } => { self.add_slot_live_edges(*container_slot, &live_during); self.add_slot_live_edges(*key_slot, &live_during); - self.collect_expr_constraints(container, &live_during)?; - self.collect_expr_constraints(key, &live_during)?; + self.collect_expr_constraints(container, &live_during, protected_slots)?; + self.collect_expr_constraints(key, &live_during, protected_slots)?; } Expr::OptionUnwrapOr { value, @@ -1183,39 +1028,57 @@ impl LocalSlotAllocator { fallback, } => { self.add_slot_live_edges(*value_slot, &live_during); - self.collect_expr_constraints(value, &live_during)?; - self.collect_expr_constraints(fallback, &live_during)?; - } - Expr::Call(index, _, args) => { + self.collect_expr_constraints(value, &live_during, protected_slots)?; + self.collect_expr_constraints(fallback, &live_during, protected_slots)?; + } + 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); + self.collect_expr_constraints(arg, &live_during, protected_slots)?; } } // Resolved module calls (pre-merge only) constrain their // arguments; the callee's footprint is folded in post-merge. Expr::ModuleCall(_, _, args) => { for arg in args { - self.collect_expr_constraints(arg, &live_during)?; + self.collect_expr_constraints(arg, &live_during, protected_slots)?; } } Expr::LocalCall(index, _, args) => { self.add_slot_live_edges(*index, &live_during); for arg in args { - self.collect_expr_constraints(arg, &live_during)?; + self.collect_expr_constraints(arg, &live_during, protected_slots)?; } - let full_footprint = self.full_footprint.clone(); - self.add_cross_live_with_set(&live_during, &full_footprint); + if !self.in_closure_body { + // Dynamic local-call targets may be closures whose + // capture state is not visible from the call expression; + // keep the caller-side interference conservative outside + // closure bodies. Inside a closure body the target still + // runs in its own callee frame (same flat slot space, + // separate frame base), so this program-wide cross-live + // would only turn the closure body's slots into a + // program-wide clique, destroying compaction and + // spuriously failing frames near the 256-slot limit. + let full_footprint = self.full_footprint.clone(); + self.add_cross_live_with_set(&live_during, &full_footprint); + } + } + Expr::Closure(closure) => { + // The closure runs in its own callee frame drawn from the + // same flat slot space; collect its body against a fresh + // live-out seeded with its own parameter slots so the + // full-body parameter rule holds for closures too. + self.collect_closure_body_constraints(closure)?; } - Expr::Closure(_closure) => {} Expr::ClosureCall(closure, args) => { for arg in args { - self.collect_expr_constraints(arg, &live_during)?; + self.collect_expr_constraints(arg, &live_during, protected_slots)?; } + self.collect_closure_body_constraints(closure)?; let mut stack = Vec::new(); let footprint = self.closure_footprint(closure, &mut stack); self.add_cross_live_with_set(&live_during, &footprint); @@ -1230,24 +1093,24 @@ impl LocalSlotAllocator { | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - self.collect_expr_constraints(lhs, &live_during)?; - self.collect_expr_constraints(rhs, &live_during)?; + self.collect_expr_constraints(lhs, &live_during, protected_slots)?; + self.collect_expr_constraints(rhs, &live_during, protected_slots)?; } Expr::Neg(inner) | Expr::Not(inner) | Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { - self.collect_expr_constraints(inner, &live_during)?; + self.collect_expr_constraints(inner, &live_during, protected_slots)?; } Expr::IfElse { condition, then_expr, else_expr, } => { - self.collect_expr_constraints(condition, &live_during)?; - self.collect_expr_constraints(then_expr, &live_during)?; - self.collect_expr_constraints(else_expr, &live_during)?; + self.collect_expr_constraints(condition, &live_during, protected_slots)?; + self.collect_expr_constraints(then_expr, &live_during, protected_slots)?; + self.collect_expr_constraints(else_expr, &live_during, protected_slots)?; } Expr::Match { value_slot, @@ -1258,49 +1121,71 @@ impl LocalSlotAllocator { } => { self.add_slot_live_edges(*value_slot, &live_during); self.add_slot_live_edges(*result_slot, &live_during); - self.collect_expr_constraints(value, &live_during)?; + self.collect_expr_constraints(value, &live_during, protected_slots)?; for (pattern, arm_expr) in arms { if let Some(binding_slot) = pattern.binding_slot() { self.add_slot_live_edges(binding_slot, &live_during); } - self.collect_expr_constraints(arm_expr, &live_during)?; + self.collect_expr_constraints(arm_expr, &live_during, protected_slots)?; } - self.collect_expr_constraints(default, &live_during)?; + self.collect_expr_constraints(default, &live_during, protected_slots)?; } Expr::Block { stmts, expr } => { - self.collect_expr_constraints(expr, &live_during)?; + self.collect_expr_constraints(expr, &live_during, protected_slots)?; let mut block_live_out = live_during.clone(); self.liveness - .union_inplace(&mut block_live_out, &self.liveness.uses_expr(expr)); - let _ = self.collect_block(stmts, &block_live_out)?; + .union_inplace(&mut block_live_out, &self.liveness.uses_expr_precise(expr)); + let _ = self.collect_block(stmts, &block_live_out, protected_slots)?; } } 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(); + /// Collect the interference constraints of a closure body the way a named + /// function body is collected: a fresh live-out seeded ONLY with the + /// closure's own parameter slots and its capture targets. The real tail + /// and body uses are computed by the backward collector itself + /// (`collect_expr_constraints` / `collect_block`); seeding the live-out + /// with `uses_expr(closure.body)` instead would put every slot the body + /// ever touches into the live-out, turning the whole body (and, through + /// a dynamic `LocalCall`'s conservative fill, the whole program) into + /// one interference clique. Nested closures recurse through + /// `collect_expr_constraints`, and each closure's protection is scoped + /// to its own body: an inner closure's parameters never mix with the + /// outer closure's or the enclosing function's interference sets. + fn collect_closure_body_constraints( + &mut self, + closure: &ClosureExpr, + ) -> Result<(), ParseError> { + let mut live_out = self.liveness.empty_set(); + // Capture targets are caller-side state the closure body may read at + // any point (through its capture cells), so they stay live for the + // whole closure body just like the parameters. + for (_, captured_slot) in &closure.capture_copies { + self.liveness.mark_live(&mut live_out, *captured_slot); } - if stack.contains(&index) { - return self.full_footprint.clone(); + for slot in &closure.param_slots { + self.liveness.mark_live(&mut live_out, *slot); } - let Some(function_impl) = self.function_impls.get(&index).cloned() else { - return self.liveness.empty_set(); + self.add_live_clique(&live_out); + let saved_closure_scope = self.in_closure_body; + self.in_closure_body = true; + let result = match &*closure.body { + // Mirror the named-function collection for the common block body: + // the tail expression is collected against the seeded live-out, + // then the statements are swept backward with the tail live-out. + Expr::Block { stmts, expr } => { + self.collect_expr_constraints(expr, &live_out, &closure.param_slots)?; + let mut block_live_out = live_out.clone(); + self.liveness + .union_inplace(&mut block_live_out, &self.liveness.uses_expr_precise(expr)); + let _ = self.collect_block(stmts, &block_live_out, &closure.param_slots)?; + Ok(()) + } + other => self.collect_expr_constraints(other, &live_out, &closure.param_slots), }; - 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 + self.in_closure_body = saved_closure_scope; + result } fn closure_footprint(&mut self, closure: &ClosureExpr, stack: &mut Vec) -> LiveSet { @@ -1419,15 +1304,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..851f2679 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; @@ -31,3 +60,5 @@ pub(super) fn enforce_local_availability_with_entry_locals( enable_local_move_semantics, ) } + +pub(super) use availability::allocate_local_slots; 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 ca8430bb..19a451f5 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,19 @@ 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. + // + // The classification runs BEFORE local-slot compaction: it tracks + // named-function values through slot flows, and merged physical slots + // would collapse distinct flows into one slot, producing spurious + // dynamic-target facts. Pre-compaction slots are the true frame-relative + // value identities, so the classification is strictly more precise on + // the unallocated IR. + let callable_use_facts = materialization::classify_named_callables(&parsed); + let parsed = lifetime::allocate_local_slots(parsed).map_err(SourceError::Parse)?; let type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); let FrontendIr { stmts, @@ -405,6 +418,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 +476,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 +508,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, }) } @@ -582,7 +619,9 @@ fn schema_is_fully_known(schema: &TypeSchema) -> bool { | TypeSchema::GenericParam(_) => true, TypeSchema::Optional(inner) => schema_is_fully_known(inner), TypeSchema::Named(_, type_args) => type_args.iter().all(schema_is_fully_known), - TypeSchema::Array(item) | TypeSchema::Map(item) => schema_is_fully_known(item), + TypeSchema::Array(item) | TypeSchema::Map(item) => { + matches!(item.as_ref(), TypeSchema::Unknown) || schema_is_fully_known(item) + } TypeSchema::ArrayTuple(items) => items.iter().all(schema_is_fully_known), TypeSchema::ArrayTupleRest { prefix, rest } => { prefix.iter().all(schema_is_fully_known) && schema_is_fully_known(rest) @@ -1461,3 +1500,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/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index 359051ee..aa036b6f 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -219,6 +219,19 @@ pub(super) fn collect_function_types( outputs.optional_slots, &mut context, ); + // The tail expression is stored separately from `body_stmts`; collect it + // so bindings declared inside it (e.g. in expression-if branch blocks) + // are recorded for strict slot validation. + collect_expr_types( + &function_impl.body_expr, + &state, + outputs.local_types, + outputs.local_schemas, + outputs.local_schema_labels, + outputs.callable_slots, + outputs.optional_slots, + &mut context, + ); let _ = context.infer_expr_type(&function_impl.body_expr, &state); } @@ -707,9 +720,15 @@ fn collect_expr_types( optional_slots, context, ); + // Collect each branch under its own refined state, mirroring + // `Stmt::IfElse` and strict validation. `Expr::Block` branches + // clone the state internally, so branch-local bindings never + // leak into the outer state. + let then_state = refine_state_for_condition(state, condition, true); + let else_state = refine_state_for_condition(state, condition, false); collect_expr_types( then_expr, - state, + &then_state, local_types, local_schemas, local_schema_labels, @@ -719,7 +738,7 @@ fn collect_expr_types( ); collect_expr_types( else_expr, - state, + &else_state, local_types, local_schemas, local_schema_labels, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index a373ef20..edee2a6a 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -22,6 +22,80 @@ use super::validate::{ validate_json_encode_argument, validate_signature_overloads, }; +/// Maximum number of times the same named declaration may be re-entered +/// on the active expansion path before `resolve_schema` stops expanding +/// it and emits a cycle marker instead. +/// +/// The seen-set terminates exact cycle re-entries (a key that repeats on +/// the active path), and trip collapse terminates named wrapping +/// (`Node>` re-enters the same collapsed identity). Neither helps +/// when a recursion re-enters the *same declaration* while wrapping its +/// type argument in a container at every re-entry +/// (`Node{ child: Node<[T]> }` resolves to `Node`, `Node<[int]>`, +/// `Node<[[int]]>`, ...): every key is structurally fresh, so the walk +/// would grow without bound. This budget is the hard bound for exactly +/// that case: it counts repeated re-entries of the *same declaration +/// identity* on the active path, so a deep non-recursive chain of +/// distinct structs never consumes it and expands in full, while a +/// recursive family is stopped at the budget. Hitting the budget emits +/// the node - with its fully resolved arguments - as a cycle marker +/// exactly like a seen-trip, so caller cycle keys stay consistent. +/// +/// A marker usually carries concrete arguments, but it may retain raw +/// generic parameters when an argument could not be concretized (an +/// unbound parameter, or a self-referential binding the +/// `schema_mentions_generic_param` guard refuses to expand). Re-resolving +/// such a marker terminates: the retained parameter fails to resolve +/// (the guard leaves it unresolved), so the marker re-renders itself +/// instead of restarting the growth - the failed resolution closes the +/// expansion rather than reopening it. +/// +/// 32 re-entries of one declaration is far beyond any practical JSON +/// payload depth (each level is one more container wrap of the type +/// argument), and the walk cost grows quadratically with the budget +/// (every level re-resolves its own increasingly wrapped arguments), so +/// the bound also keeps the compile-time walk fast and shallow enough +/// for the constrained-stack regression probes. +const MAX_NAMED_SCHEMA_REENTRY: usize = 32; + +/// True when `schema` mentions the generic parameter `name` anywhere. +/// Used to break self-referential bindings (`T` bound to `[T]`): such a +/// binding can only arise from an unbound parameter root, and expanding +/// it would loop through containers forever without ever re-entering a +/// named declaration (so the named re-entry budget would never trip), so +/// the parameter is left unresolved instead - the honest marker for a +/// circular binding. +fn schema_mentions_generic_param(schema: &TypeSchema, name: &str) -> bool { + match schema { + TypeSchema::GenericParam(other) => other == name, + TypeSchema::Named(_, type_args) => type_args + .iter() + .any(|arg| schema_mentions_generic_param(arg, name)), + TypeSchema::Array(element) => schema_mentions_generic_param(element, name), + TypeSchema::ArrayTuple(items) => items + .iter() + .any(|item| schema_mentions_generic_param(item, name)), + TypeSchema::ArrayTupleRest { prefix, rest } => { + prefix + .iter() + .any(|item| schema_mentions_generic_param(item, name)) + || schema_mentions_generic_param(rest, name) + } + TypeSchema::Map(value) => schema_mentions_generic_param(value, name), + TypeSchema::Optional(inner) => schema_mentions_generic_param(inner, name), + TypeSchema::Object(fields) => fields + .values() + .any(|value| schema_mentions_generic_param(value, name)), + TypeSchema::Callable { params, result } => { + params + .iter() + .any(|param| schema_mentions_generic_param(param, name)) + || schema_mentions_generic_param(result, name) + } + _ => false, + } +} + pub(super) struct TypeContext<'a> { pub(super) function_impls: &'a HashMap, pub(super) function_decls: &'a HashMap, @@ -159,78 +233,243 @@ impl<'a> TypeContext<'a> { schema: &TypeSchema, seen: &mut HashSet, ) -> TypeSchema { + self.resolve_schema_with_seen_tripped(schema, seen, &mut HashMap::new()) + .0 + } + + /// Resolves `schema` against `seen`, also reporting the innermost cycle + /// key the resolution re-entered. `Some(key)` means the resolution + /// terminated on a cycle, so the result is a cycle marker for an + /// active ancestor; `None` means it completed without re-entering one. + /// + /// The trip key lets callers build cycle keys from the *identity* of a + /// resolved argument instead of its structural render. A recursive + /// generic whose type arguments wrap the recursion in a named type + /// (`Node>`) re-enters with one more nesting at every level, + /// so a structural key (`Node`, `Node>`, + /// `Node>>`, ...) never repeats and the walk never + /// terminates. Collapsing a wrapped chain to the trip key of its + /// innermost re-entry keeps the key stable across every wrap depth + /// while still distinguishing chains rooted at different ancestors. + /// Containers (`Array`/`Map`/`Object`/tuples/`Optional`/`Callable`) + /// propagate their children's innermost trip, so a resolved argument + /// that *contains* a cycle marker collapses to the same identity + /// instead of re-rendering the marker one nesting deeper per re-entry. + /// + /// `reentries` is the named re-entry budget + /// (`MAX_NAMED_SCHEMA_REENTRY`): it counts how many times each + /// declaration identity is already being expanded on the active path. + /// When a recursion wraps its type argument in a *container* at every + /// re-entry (`Node{ child: Node<[T]> }`), even the collapsed keys + /// stay structurally fresh (`Node<[int]>`, `Node<[[int]]>`, ...), so + /// neither the seen-set nor trip collapse can terminate the walk. The + /// budget is the hard bound for that recursive family: at the limit + /// the node is emitted - with its fully resolved arguments - as a + /// cycle marker, exactly like a seen-trip. Declarations with distinct + /// identities never accumulate a count, so deep non-recursive chains + /// expand in full. + fn resolve_schema_with_seen_tripped( + &mut self, + schema: &TypeSchema, + seen: &mut HashSet, + reentries: &mut HashMap, + ) -> (TypeSchema, Option) { match schema { TypeSchema::GenericParam(name) => { let bound = self.resolve_generic_binding(name).cloned(); - bound.map_or_else( - || schema.clone(), - |bound| { - if bound == *schema { - schema.clone() - } else { - self.resolve_schema_with_seen(&bound, seen) - } - }, - ) + match bound { + Some(bound) + if bound != *schema && !schema_mentions_generic_param(&bound, name) => + { + self.resolve_schema_with_seen_tripped(&bound, seen, reentries) + } + _ => (schema.clone(), None), + } } TypeSchema::Named(name, type_args) => { - let substituted_args = type_args - .iter() - .map(|arg| self.resolve_schema_with_seen(arg, seen)) - .collect::>(); + let mut resolved_args = Vec::with_capacity(type_args.len()); + let mut arg_trips = Vec::with_capacity(type_args.len()); + for arg in type_args { + let (resolved, trip) = + self.resolve_schema_with_seen_tripped(arg, seen, reentries); + resolved_args.push(resolved); + arg_trips.push(trip); + } + let reentry_count = reentries.get(name.as_str()).copied().unwrap_or(0); + if reentry_count >= MAX_NAMED_SCHEMA_REENTRY { + // Container-wrapped recursion has no repeating key to + // trip on; the same declaration has re-entered the + // active path past the budget, so stop expanding and + // emit the node with its fully resolved arguments as a + // cycle marker. Concrete arguments matter: a marker + // with raw generic parameters would push a + // self-referential binding when re-resolved (`T` bound + // to `[T]`). Raw parameters can still appear when an + // argument itself failed to concretize (unbound or + // self-referential binding); re-resolving such a + // marker terminates because the retained parameter + // fails to resolve, closing the expansion instead of + // restarting its growth. + let key = schema_instance_key(name, &resolved_args, &arg_trips); + return (TypeSchema::Named(name.clone(), resolved_args), Some(key)); + } let Some(decl) = self.struct_schemas.get(name) else { - return TypeSchema::Named(name.clone(), substituted_args); + return (TypeSchema::Named(name.clone(), resolved_args), None); }; - if decl.type_params.len() != substituted_args.len() { - return TypeSchema::Named(name.clone(), substituted_args); + if decl.type_params.len() != resolved_args.len() { + return (TypeSchema::Named(name.clone(), resolved_args), None); } - let key = - render_schema_label(&TypeSchema::Named(name.clone(), substituted_args.clone())); + let key = schema_instance_key(name, &resolved_args, &arg_trips); if !seen.insert(key.clone()) { - return TypeSchema::Named(name.clone(), substituted_args); + // Re-entered an active cycle. Report the innermost + // re-entry of the resolved arguments (this node's own + // key when the arguments resolved without a trip) so a + // wrapped chain collapses to one stable identity. + let trip = arg_trips.into_iter().flatten().next().unwrap_or(key); + return (TypeSchema::Named(name.clone(), resolved_args), Some(trip)); } - self.push_generic_bindings(&decl.type_params, &substituted_args); - let resolved = self.resolve_schema_with_seen(&decl.body_schema, seen); + reentries.insert(name.clone(), reentry_count + 1); + self.push_generic_bindings(&decl.type_params, &resolved_args); + let (resolved, body_trip) = + self.resolve_schema_with_seen_tripped(&decl.body_schema, seen, reentries); self.pop_generic_bindings(); seen.remove(&key); - resolved + if reentry_count == 0 { + reentries.remove(name); + } else { + reentries.insert(name.clone(), reentry_count); + } + // The node's own key was fresh, but its body re-entered a + // cycle: the resolved form embeds that cycle marker, so the + // node's identity collapses to the body's innermost trip + // instead of re-rendering the marker one nesting deeper. + (resolved, body_trip) } TypeSchema::Array(element) => { - TypeSchema::Array(Box::new(self.resolve_schema_with_seen(element, seen))) + let (resolved, trip) = + self.resolve_schema_with_seen_tripped(element, seen, reentries); + (TypeSchema::Array(Box::new(resolved)), trip) + } + TypeSchema::ArrayTuple(items) => { + let mut resolved = Vec::with_capacity(items.len()); + let mut innermost_trip = None; + for item in items { + let (resolved_item, trip) = + self.resolve_schema_with_seen_tripped(item, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + resolved.push(resolved_item); + } + (TypeSchema::ArrayTuple(resolved), innermost_trip) + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + let mut resolved_prefix = Vec::with_capacity(prefix.len()); + let mut innermost_trip = None; + for item in prefix { + let (resolved_item, trip) = + self.resolve_schema_with_seen_tripped(item, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + resolved_prefix.push(resolved_item); + } + let (resolved_rest, trip) = + self.resolve_schema_with_seen_tripped(rest, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + ( + TypeSchema::ArrayTupleRest { + prefix: resolved_prefix, + rest: Box::new(resolved_rest), + }, + innermost_trip, + ) } - TypeSchema::ArrayTuple(items) => TypeSchema::ArrayTuple( - items - .iter() - .map(|item| self.resolve_schema_with_seen(item, seen)) - .collect(), - ), - TypeSchema::ArrayTupleRest { prefix, rest } => TypeSchema::ArrayTupleRest { - prefix: prefix - .iter() - .map(|item| self.resolve_schema_with_seen(item, seen)) - .collect(), - rest: Box::new(self.resolve_schema_with_seen(rest, seen)), - }, TypeSchema::Map(value) => { - TypeSchema::Map(Box::new(self.resolve_schema_with_seen(value, seen))) + let (resolved, trip) = + self.resolve_schema_with_seen_tripped(value, seen, reentries); + (TypeSchema::Map(Box::new(resolved)), trip) } TypeSchema::Optional(inner) => { - TypeSchema::Optional(Box::new(self.resolve_schema_with_seen(inner, seen))) + let (resolved, trip) = + self.resolve_schema_with_seen_tripped(inner, seen, reentries); + (TypeSchema::Optional(Box::new(resolved)), trip) + } + TypeSchema::Object(fields) => { + let mut resolved_fields = HashMap::with_capacity(fields.len()); + let mut innermost_trip = None; + // `TypeSchema::Object` is a HashMap, so raw iteration order + // is per-process random. The first trip on the path is the + // one propagated to the parent's cycle key, so which field + // contributes it must be deterministic: visit fields in + // sorted name order. + let mut sorted_fields: Vec<(&String, &TypeSchema)> = fields.iter().collect(); + sorted_fields.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (name, value) in sorted_fields { + let (resolved_value, trip) = + self.resolve_schema_with_seen_tripped(value, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + resolved_fields.insert(name.clone(), resolved_value); + } + (TypeSchema::Object(resolved_fields), innermost_trip) + } + TypeSchema::Callable { params, result } => { + let mut resolved_params = Vec::with_capacity(params.len()); + let mut innermost_trip = None; + for param in params { + let (resolved_param, trip) = + self.resolve_schema_with_seen_tripped(param, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + resolved_params.push(resolved_param); + } + let (resolved_result, trip) = + self.resolve_schema_with_seen_tripped(result, seen, reentries); + if innermost_trip.is_none() { + innermost_trip = trip; + } + ( + TypeSchema::Callable { + params: resolved_params, + result: Box::new(resolved_result), + }, + innermost_trip, + ) } - TypeSchema::Object(fields) => TypeSchema::Object( - fields - .iter() - .map(|(key, value)| (key.clone(), self.resolve_schema_with_seen(value, seen))) - .collect(), - ), - TypeSchema::Callable { params, result } => TypeSchema::Callable { - params: params - .iter() - .map(|param| self.resolve_schema_with_seen(param, seen)) - .collect(), - result: Box::new(self.resolve_schema_with_seen(result, seen)), - }, - _ => schema.clone(), + _ => (schema.clone(), None), + } + } + + /// Cycle key for a named schema: the struct name plus the identity of + /// each type argument resolved through the current context. Arguments + /// that resolve to a cycle marker contribute the key of the innermost + /// re-entry they collapsed to; everything else contributes its fully + /// resolved render. The identity is stable across wrap depths, so + /// different instantiations that re-enter the same cycle class share + /// one key and are not mistaken for fresh expansions. + pub(super) fn schema_cycle_key( + &mut self, + schema: &TypeSchema, + seen: &mut HashSet, + ) -> String { + match schema { + TypeSchema::Named(name, type_args) => { + let mut resolved_args = Vec::with_capacity(type_args.len()); + let mut arg_trips = Vec::with_capacity(type_args.len()); + for arg in type_args { + let (resolved, trip) = + self.resolve_schema_with_seen_tripped(arg, seen, &mut HashMap::new()); + resolved_args.push(resolved); + arg_trips.push(trip); + } + schema_instance_key(name, &resolved_args, &arg_trips) + } + _ => render_schema_label(schema), } } @@ -820,12 +1059,14 @@ impl<'a> TypeContext<'a> { None => self.infer_declared_callable_call_schema(*slot, args, state), }, Expr::IfElse { + condition, then_expr, else_expr, - .. } => { - let then_schema = self.infer_expr_schema(then_expr, state); - let else_schema = self.infer_expr_schema(else_expr, state); + let then_state = refine_state_for_condition(state, condition, true); + let else_state = refine_state_for_condition(state, condition, false); + let then_schema = self.infer_expr_schema(then_expr, &then_state); + let else_schema = self.infer_expr_schema(else_expr, &else_state); match (then_schema, else_schema) { (Some(TypeSchema::Null), rhs) => rhs, (lhs, Some(TypeSchema::Null)) => lhs, @@ -949,12 +1190,14 @@ impl<'a> TypeContext<'a> { infer_unary_type(expr, inner_ty) } Expr::IfElse { - condition: _, + condition, then_expr, else_expr, } => { - let then_ty = self.infer_expr_type(then_expr, state); - let else_ty = self.infer_expr_type(else_expr, state); + let then_state = refine_state_for_condition(state, condition, true); + let else_state = refine_state_for_condition(state, condition, false); + let then_ty = self.infer_expr_type(then_expr, &then_state); + let else_ty = self.infer_expr_type(else_expr, &else_state); if then_ty == else_ty { then_ty } else { @@ -1977,6 +2220,34 @@ impl<'a> TypeContext<'a> { line_context: Option, source_name: Option<&str>, ) -> Result<(), CompileError> { + for (index, param) in signature.params.iter().enumerate() { + let crate::builtins::CallableParamType::Callable(callable) = param.ty else { + continue; + }; + let Some(arg) = args.get(index) else { + continue; + }; + let expected = crate::compiler::TypeSchema::Callable { + params: callable + .params + .iter() + .copied() + .map(callable_param_schema) + .collect(), + result: Box::new(callable_param_schema(*callable.return_type)), + }; + super::validate::validate_callable_expr_against_schema( + &format!("argument '{}'", param.name), + &expected, + arg, + state, + super::validate::DiagnosticSite { + line: line_context, + source_name, + }, + self, + )?; + } if matches!(signature.name.as_str(), "print" | "println") { if args .first() @@ -2210,6 +2481,32 @@ impl<'a> TypeContext<'a> { } } +fn callable_param_schema(param: crate::builtins::CallableParamType) -> crate::compiler::TypeSchema { + use crate::builtins::CallableParamType; + use crate::compiler::TypeSchema; + match param { + CallableParamType::Any => TypeSchema::Unknown, + CallableParamType::Null => TypeSchema::Null, + CallableParamType::Int => TypeSchema::Int, + CallableParamType::Float => TypeSchema::Float, + CallableParamType::Number => TypeSchema::Number, + CallableParamType::Bool => TypeSchema::Bool, + CallableParamType::String => TypeSchema::String, + CallableParamType::Bytes => TypeSchema::Bytes, + CallableParamType::Array => TypeSchema::Array(Box::new(TypeSchema::Unknown)), + CallableParamType::Map => TypeSchema::Map(Box::new(TypeSchema::Unknown)), + CallableParamType::Callable(signature) => TypeSchema::Callable { + params: signature + .params + .iter() + .copied() + .map(callable_param_schema) + .collect(), + result: Box::new(callable_param_schema(*signature.return_type)), + }, + } +} + fn merge_observed_function_param_schema( current: Option, next: Option, @@ -2583,6 +2880,29 @@ pub(crate) fn render_schema_label(schema: &TypeSchema) -> String { } } +/// Cycle key for a named schema instantiation: the struct name plus, for +/// each resolved type argument, the innermost re-entry key it collapsed to +/// (when the argument resolved to a cycle marker) or its fully resolved +/// render. Two wrapped re-entries of the same recursive instantiation +/// (`Node>` re-entered from `Node`) therefore produce the +/// same key, while chains rooted at different ancestors stay distinct. +fn schema_instance_key( + name: &str, + resolved_args: &[TypeSchema], + arg_trips: &[Option], +) -> String { + if resolved_args.is_empty() { + name.to_string() + } else { + let parts = resolved_args + .iter() + .zip(arg_trips) + .map(|(arg, trip)| trip.clone().unwrap_or_else(|| render_schema_label(arg))) + .collect::>(); + format!("{name}<{}>", parts.join(", ")) + } +} + fn builtin_generic_return_schema( builtin: BuiltinFunction, type_args: &[TypeSchema], @@ -2636,6 +2956,73 @@ mod tests { use super::*; use crate::builtins::{CallableParam, CallableParamType}; + #[test] + fn generated_callable_float_schema_remains_distinct_from_number() { + assert_eq!( + callable_param_schema(CallableParamType::Float), + TypeSchema::Float + ); + assert_eq!( + callable_param_schema(CallableParamType::Number), + TypeSchema::Number + ); + } + + #[test] + fn generated_float_callable_metadata_rejects_non_float_callback_results() { + static FLOAT_PARAMS: &[CallableParamType] = &[CallableParamType::Float]; + static FLOAT_RESULT: CallableParamType = CallableParamType::Float; + let signature = HostCallableSignature { + name: "test::float_callback".to_string(), + params: vec![CallableParam { + name: "callback", + ty: CallableParamType::Callable(crate::builtins::CallableType { + params: FLOAT_PARAMS, + return_type: &FLOAT_RESULT, + }), + optional: false, + }], + runtime_builtin: true, + }; + let empty_impls = HashMap::new(); + let empty_decls = HashMap::new(); + let empty_structs = HashMap::new(); + let empty_names = HashMap::new(); + let empty_returns = HashMap::new(); + let empty_signatures = HashMap::new(); + let mut context = TypeContext::new( + &empty_impls, + &empty_decls, + &empty_structs, + &empty_names, + &empty_returns, + &empty_signatures, + TypingMode::StrictRustScript, + ); + let state = LocalTypeState::default(); + let wrong = [Expr::Closure(ClosureExpr { + param_slots: vec![0], + capture_copies: vec![], + body: Box::new(Expr::Int(1)), + })]; + let error = context + .validate_host_argument_types(&signature, &wrong, &state, None, None) + .expect_err("fn(float) -> float metadata must reject an int result"); + assert!( + error.to_string().contains("float") && error.to_string().contains("int"), + "unexpected compiler diagnostic: {error}" + ); + + let valid = [Expr::Closure(ClosureExpr { + param_slots: vec![0], + capture_copies: vec![], + body: Box::new(Expr::Float(1.0)), + })]; + context + .validate_host_argument_types(&signature, &valid, &state, None, None) + .expect("fn(float) -> float metadata must accept a float result"); + } + /// The authoritative `stream::emit` signature: one `any` payload. fn emit_signature(runtime_builtin: bool) -> HostCallableSignature { HostCallableSignature { diff --git a/src/compiler/typing/state.rs b/src/compiler/typing/state.rs index 5ce13470..391db46c 100644 --- a/src/compiler/typing/state.rs +++ b/src/compiler/typing/state.rs @@ -440,5 +440,6 @@ pub(crate) struct HostCallableSignature { /// catalog such as edge ABI host functions. Strict-typing exemptions that /// are tied to a builtin identity must check this marker so a same-name /// function from another catalog cannot inherit them. + #[cfg_attr(not(feature = "runtime"), allow(dead_code))] pub(crate) runtime_builtin: bool, } diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 6a2b61fe..2eab4ddc 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -1,3 +1,5 @@ +use std::collections::{HashMap, HashSet}; + use crate::builtins::{BuiltinFunction, CallableParam, CallableParamType, CallableSignature}; use super::super::CompileError; @@ -223,7 +225,7 @@ fn validate_expr_matches_schema( ) } -fn validate_callable_expr_against_schema( +pub(super) fn validate_callable_expr_against_schema( label: &str, expected_schema: &TypeSchema, expr: &Expr, @@ -295,7 +297,131 @@ fn validate_json_schema( context: &mut TypeContext<'_>, path: &str, ) -> Result<(), String> { - match context.resolve_schema(schema) { + validate_json_schema_with_seen( + schema, + context, + path, + &mut HashSet::new(), + &mut HashMap::new(), + ) +} + +/// Maximum number of times the same named declaration may be re-entered +/// on the active walk path before the `json::encode` compile-time walk +/// accepts the node as a structural recursion edge. The resolver's own +/// budget (`MAX_NAMED_SCHEMA_REENTRY`) bounds every schema it returns, +/// but the walk re-resolves each named node it visits, so a +/// container-wrapped recursion (`Node{ child: Node<[T]> }`) would +/// still descend one bounded tree after another forever. This budget is +/// the walk's own hard bound for exactly that recursive family; it +/// matches the resolver budget so the walk always stops before it could +/// re-resolve a budget marker. +/// +/// The budget counts repeated re-entries of the *same declaration +/// identity* on the active walk path, so distinct declaration names never +/// consume it: a deep non-recursive chain of distinct structs is walked +/// in full and every unsupported field it contains is rejected with its +/// precise path. Hitting the budget accepts the node as a structural +/// recursion edge, per the JSON compile/runtime contract: the node's +/// struct body is the same body already walked at every shallower level +/// of this chain, so fixed unsupported fields (`bytes`, callables) were +/// already rejected at the first level, and fields derived from the type +/// argument are container-wrapped encodables. The runtime encoder +/// remains the final gate for actual values (string keys, bytes, +/// callables, NaN/infinity), and every runtime value of a structurally +/// recursive type is finite. +const MAX_JSON_SCHEMA_VALIDATION_REENTRY: usize = 32; + +/// Walks `schema` for `json::encode` legality. `seen` tracks the named +/// schemas currently being expanded on the active path, so a self- or +/// mutually-recursive struct terminates instead of re-resolving its own +/// cycle marker one level deeper on every descent (the resolver leaves a +/// raw `TypeSchema::Named` marker for the schema already being expanded, +/// and re-entering that marker on the active path is the encodable cycle +/// edge, so it is accepted). `seen` is shared across every recursion - +/// `Array`/`Optional`/`Object`/`Map`/tuples all descend through the same +/// set - and each name is removed on exit, so a name reused by *different* +/// branches of the tree is still fully validated. +/// +/// The key for a named schema is its name plus the type arguments resolved +/// through the current context (`TypeContext::schema_cycle_key`), not the +/// raw render of the node. A raw render would collide across generic +/// parameter shadowing (a struct named `T` and a parameter named `T` both +/// render `Node`) and would grow without bound for wrapped re-entries +/// of a recursive instantiation (`Node>` renders one nesting +/// deeper at every level), so the walk would either short-circuit a +/// different instantiation or never terminate. The resolved-identity key +/// collapses wrapped re-entries into one cycle class while keeping +/// instantiations rooted at different ancestors distinct. +/// +/// The walk matches the raw schema instead of a whole-tree +/// `resolve_schema`: a blanket resolution would re-expand the cycle +/// markers inside already-resolved bodies before the guard could see them. +/// `Named` and `GenericParam` are resolved lazily at their own level, and +/// `Named` bodies resolve with a fresh seen so the first encounter always +/// expands the node before its cycle edge is accepted. `reentries` is the +/// walk's own budget (`MAX_JSON_SCHEMA_VALIDATION_REENTRY`): it counts +/// repeated re-entries of the *same declaration identity* on the active +/// walk path, so container-wrapped recursion is accepted at the budget +/// while distinct declarations are always walked in full; see its +/// documentation for the contract at the boundary. +fn validate_json_schema_with_seen( + schema: &TypeSchema, + context: &mut TypeContext<'_>, + path: &str, + seen: &mut HashSet, + reentries: &mut HashMap, +) -> Result<(), String> { + match schema { + TypeSchema::GenericParam(name) => { + let resolved = context.resolve_schema(schema); + if resolved == *schema { + Err(format!( + "{path} depends on generic schema parameter '{name}', which is not concrete enough for json::encode" + )) + } else { + validate_json_schema_with_seen(&resolved, context, path, seen, reentries) + } + } + TypeSchema::Named(name, _) => { + let reentry_count = reentries.get(name.as_str()).copied().unwrap_or(0); + if reentry_count >= MAX_JSON_SCHEMA_VALIDATION_REENTRY { + // Budget exhausted: this re-entry is a pure structural + // recursion edge (container-wrapped recursion has no + // repeating cycle key to trip the seen-set). Accept it + // per the contract documented on the budget constant: + // its body is the same struct already walked at + // shallower levels, so unsupported sibling fields were + // already rejected there, and the runtime encoder stays + // the final gate for values. + return Ok(()); + } + // The cycle key is the struct name plus the type arguments + // resolved through the current context (see + // `TypeContext::schema_cycle_key`): a raw render would collide + // across generic-parameter shadowing and grow without bound for + // wrapped re-entries of the same recursive instantiation. + let key = context.schema_cycle_key(schema, seen); + if !seen.insert(key.clone()) { + // This named schema is already being expanded on the + // active path: the recursion edge itself is encodable. + return Ok(()); + } + // Resolve the body with a fresh seen: the resolver must expand + // this node at least once even though its cycle key is now on + // the active path, or the first encounter would short-circuit + // as its own cycle edge. + reentries.insert(name.clone(), reentry_count + 1); + let resolved = context.resolve_schema(schema); + let result = validate_json_schema_with_seen(&resolved, context, path, seen, reentries); + seen.remove(&key); + if reentry_count == 0 { + reentries.remove(name); + } else { + reentries.insert(name.clone(), reentry_count); + } + result + } TypeSchema::Unknown => Err(format!("{path} has unknown schema")), TypeSchema::Null | TypeSchema::Int @@ -306,43 +432,76 @@ fn validate_json_schema( TypeSchema::Bytes => Err(format!( "{path} uses bytes, which json::encode does not support" )), - TypeSchema::Optional(inner) => validate_json_schema(&inner, context, path), - TypeSchema::GenericParam(name) => Err(format!( - "{path} depends on generic schema parameter '{name}', which is not concrete enough for json::encode" - )), + TypeSchema::Optional(inner) => { + validate_json_schema_with_seen(inner, context, path, seen, reentries) + } TypeSchema::Callable { .. } => Err(format!( "{path} is callable, which json::encode does not support" )), - TypeSchema::Named(_, _) | TypeSchema::Object(_) => match context.resolve_schema(schema) { - TypeSchema::Object(fields) => { - for (field, value_schema) in &fields { - let child_path = if path.is_empty() { - format!("field '{field}'") - } else { - format!("{path}.{field}") - }; - validate_json_schema(value_schema, context, child_path.as_str())?; - } - Ok(()) + TypeSchema::Object(fields) => { + // `TypeSchema::Object` is a HashMap, so raw iteration order is + // per-process random. The first unsupported field decides the + // rejection path; walking fields in sorted name order keeps + // the diagnostic (and probe assertions on it) deterministic + // across processes and runs. + let mut sorted_fields: Vec<(&String, &TypeSchema)> = fields.iter().collect(); + sorted_fields.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (field, value_schema) in sorted_fields { + let child_path = if path.is_empty() { + format!("field '{field}'") + } else { + format!("{path}.{field}") + }; + validate_json_schema_with_seen( + value_schema, + context, + child_path.as_str(), + seen, + reentries, + )?; } - other => validate_json_schema(&other, context, path), - }, - TypeSchema::Array(element) => validate_json_schema(&element, context, path), + Ok(()) + } + TypeSchema::Array(element) => { + validate_json_schema_with_seen(element, context, path, seen, reentries) + } TypeSchema::ArrayTuple(items) => { for (index, item) in items.iter().enumerate() { - validate_json_schema(item, context, format!("{path}[{index}]").as_str())?; + validate_json_schema_with_seen( + item, + context, + format!("{path}[{index}]").as_str(), + seen, + reentries, + )?; } Ok(()) } TypeSchema::ArrayTupleRest { prefix, rest } => { for (index, item) in prefix.iter().enumerate() { - validate_json_schema(item, context, format!("{path}[{index}]").as_str())?; + validate_json_schema_with_seen( + item, + context, + format!("{path}[{index}]").as_str(), + seen, + reentries, + )?; + } + validate_json_schema_with_seen(rest, context, path, seen, reentries) + } + TypeSchema::Map(inner) => { + // Runtime maps carry no compile-time key type, so key legality + // cannot be proven statically. Admit the map and defer the + // recursive checks: an `Unknown` inner schema means the runtime + // encoder's own string-key and encodable-value checks decide; + // a concrete inner schema is still checked statically so bytes + // and callable values fail at compile time when provable. + if matches!(inner.as_ref(), TypeSchema::Unknown) { + Ok(()) + } else { + validate_json_schema_with_seen(inner, context, path, seen, reentries) } - validate_json_schema(&rest, context, path) } - TypeSchema::Map(_) => Err(format!( - "{path} is a generic map; json::encode in RustScript requires object/struct-shaped data so keys are provably strings" - )), } } @@ -424,6 +583,7 @@ fn param_accepts_bound_type(expected: CallableParamType, actual: BoundType, stri } CallableParamType::Map => matches!(actual, BoundType::Map | BoundType::MapOf(_)), CallableParamType::Number => is_numeric_bound_type(actual), + CallableParamType::Callable(_) => actual == BoundType::Callable, } } @@ -440,9 +600,9 @@ fn format_param_types(params: &[CallableParam]) -> String { .iter() .map(|param| { if param.optional { - format!("{}?: {}", param.name, param.ty.label()) + format!("{}?: {}", param.name, param.ty.display_label()) } else { - format!("{}: {}", param.name, param.ty.label()) + format!("{}: {}", param.name, param.ty.display_label()) } }) .collect::>() 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/instance.rs b/src/vm/instance.rs index d74cbad0..3e6d44e4 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -160,7 +160,6 @@ impl Instance { self.host_return = None; self.queued_callables.clear(); self.completed_callable_results.clear(); - self.owned_callables.clear(); self.draining_queued_callables = false; self.shutdown = false; self.waiting_host_op = None; 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..8d0cb7ee 100644 --- a/src/vm/jit/ir.rs +++ b/src/vm/jit/ir.rs @@ -251,6 +251,17 @@ pub(crate) enum SsaInstKind { import: u16, args: Vec, }, + /// Materialize the fresh environment-free callable for one root callable + /// binding slot of an inlined callee frame. + /// + /// The runtime helper mints a brand-new `Arc` (never a shared constant) + /// and registers it with the VM's owned-callable set on every execution, + /// mirroring `enter_script_frame`'s per-entry re-initialization. No + /// callable identity is ever shared across runs, so a host handle from a + /// previous lifecycle can never be re-legalized by a later run. + MaterializeRootCallable { + prototype_id: u32, + }, IntNeg { input: SsaValueId, @@ -396,6 +407,7 @@ impl SsaInstKind { match self { Self::Constant(_) => Vec::new(), Self::HostCall { args, .. } => args.clone(), + Self::MaterializeRootCallable { .. } => Vec::new(), Self::CloneTagged { input } | Self::ValueIsType { input, .. } @@ -563,6 +575,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 +1065,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)); } @@ -1139,6 +1161,9 @@ fn verify_materialization( fn render_inst_kind(kind: &SsaInstKind) -> String { match kind { SsaInstKind::Constant(value) => format!("const {value:?}"), + SsaInstKind::MaterializeRootCallable { prototype_id } => { + format!("materialize_root_callable {prototype_id}") + } SsaInstKind::CloneTagged { input } => format!("clone_tagged {input}"), SsaInstKind::ValueIsType { input, tag } => { format!("value_is_type {input}, {tag:?}") @@ -1277,6 +1302,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..4283351a 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -17,12 +17,14 @@ 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, map_get_entry_address, map_has_entry_address, map_iter_next_entry_address, map_iter_next_signature, map_iter_take_key_entry_address, map_iter_take_signature, map_iter_take_value_entry_address, map_set_entry_address, map_set_signature, + materialize_root_callable_entry_address, materialize_root_callable_signature, non_yielding_host_call_entry_address, non_yielding_host_call_signature, non_yielding_i64_host_call_entry_address, non_yielding_i64_host_call_signature, non_yielding_scalar_host_call_entry_address, non_yielding_scalar_host_call_signature, @@ -598,6 +600,8 @@ fn try_compile_ssa_trace( non_yielding_scalar_host_call_signature(pointer_type, call_conv); let non_yielding_i64_host_call_sig = non_yielding_i64_host_call_signature(pointer_type, call_conv); + let materialize_root_callable_sig = + materialize_root_callable_signature(pointer_type, call_conv); let value_slot_sig = value_slot_signature(pointer_type, call_conv); let value_eq_sig = value_eq_signature(pointer_type, call_conv); let value_len_sig = value_len_signature(pointer_type, call_conv); @@ -619,6 +623,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); @@ -687,6 +692,7 @@ fn try_compile_ssa_trace( non_yielding_scalar_host_call_ref: b .import_signature(non_yielding_scalar_host_call_sig), non_yielding_i64_host_call_ref: b.import_signature(non_yielding_i64_host_call_sig), + materialize_root_callable_ref: b.import_signature(materialize_root_callable_sig), clear_value_slot_ref: b.import_signature(value_slot_sig), clear_bridge_error_ref: b.import_signature(clear_bridge_error_sig), box_heap_value_ref: b.import_signature(box_heap_value_sig), @@ -702,6 +708,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), }; @@ -713,6 +720,7 @@ fn try_compile_ssa_trace( non_yielding_host_call: non_yielding_host_call_entry_address(), non_yielding_scalar_host_call: non_yielding_scalar_host_call_entry_address(), non_yielding_i64_host_call: non_yielding_i64_host_call_entry_address(), + materialize_root_callable: materialize_root_callable_entry_address(), clear_value_slot: clear_value_slot_entry_address(), clear_bridge_error: clear_bridge_error_entry_address(), box_heap_value: write_heap_value_to_slot_entry_address(), @@ -728,6 +736,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 +789,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 +1066,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 +1161,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 +1182,12 @@ enum SsaExitAction { call_ip: usize, resume_ip: usize, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterruptYield, } @@ -1132,6 +1200,7 @@ struct SsaDeoptHelperRefs { non_yielding_host_call_ref: cranelift_codegen::ir::SigRef, non_yielding_scalar_host_call_ref: cranelift_codegen::ir::SigRef, non_yielding_i64_host_call_ref: cranelift_codegen::ir::SigRef, + materialize_root_callable_ref: cranelift_codegen::ir::SigRef, clear_value_slot_ref: cranelift_codegen::ir::SigRef, clear_bridge_error_ref: cranelift_codegen::ir::SigRef, box_heap_value_ref: cranelift_codegen::ir::SigRef, @@ -1147,6 +1216,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, } @@ -1160,6 +1230,7 @@ struct SsaDeoptHelperAddrs { non_yielding_host_call: usize, non_yielding_scalar_host_call: usize, non_yielding_i64_host_call: usize, + materialize_root_callable: usize, clear_value_slot: usize, clear_bridge_error: usize, box_heap_value: usize, @@ -1175,6 +1246,7 @@ struct SsaDeoptHelperAddrs { restore_virtual_frame: usize, leave_frame: usize, enter_call_value: usize, + enter_call_script: usize, resume_linked_trace: usize, } @@ -1297,6 +1369,7 @@ fn ssa_trace_supported(ssa: &SsaTrace) -> bool { if !matches!( inst.kind, SsaInstKind::Constant(_) + | SsaInstKind::MaterializeRootCallable { .. } | SsaInstKind::CloneTagged { .. } | SsaInstKind::ValueIsType { .. } | SsaInstKind::UnboxHeapPtr { .. } @@ -1602,7 +1675,8 @@ fn borrowed_array_get_outputs(ssa: &SsaTrace) -> BTreeSet { } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } } for exit in &ssa.exits { @@ -1651,6 +1725,7 @@ fn ssa_inst_requires_owned_value_slot(kind: &SsaInstKind) -> bool { matches!( kind, SsaInstKind::CloneTagged { .. } + | SsaInstKind::MaterializeRootCallable { .. } | SsaInstKind::ArrayGet { .. } | SsaInstKind::ArraySet { .. } | SsaInstKind::ArrayPush { .. } @@ -1816,7 +1891,8 @@ fn ssa_backedge_targets( } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } targets } @@ -2080,6 +2156,28 @@ fn lower_ssa_inst( )?; out } + SsaInstKind::MaterializeRootCallable { prototype_id } => { + // Mint a fresh, VM-registered root-binding callable for this + // lifecycle and materialize it into the output value slot. Every + // execution gets a new `Arc`, so no callable identity escapes + // into the host unregistered or survives into a later run. + let out = owned_value_temp_slot_addr( + b, + pointer_type, + owned_value_temps, + SsaTempValueSlotKey::Output(output.id), + )?; + let prototype_id = b.ins().iconst(types::I64, i64::from(*prototype_id)); + ssa_call_status_helper( + b, + exit_block, + pointer_type, + helper_refs.materialize_root_callable_ref, + helper_addrs.materialize_root_callable, + &[vm_ptr, out, prototype_id], + )?; + out + } SsaInstKind::ValueIsType { input, tag } => { let input = *values.get(input).ok_or_else(|| { VmError::JitNative("SSA type predicate input missing".to_string()) @@ -4234,7 +4332,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 +4585,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 +4701,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..d9fed451 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -7,10 +7,13 @@ 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 +215,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 +256,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 +292,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); + // Validate the prototype at record time; the runtime helper re-derives + // the kind from the program on every materialization, so the IR inst + // only needs the id. + program + .callable_prototypes + .get(binding.prototype_id as usize) + .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::MaterializeRootCallable { + prototype_id: binding.prototype_id, + }, + ) + .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 +738,12 @@ enum DecodedOp { argc: u8, resume_ip: usize, }, + CallScript { + ip: usize, + prototype_id: u32, + argc: u8, + resume_ip: usize, + }, } impl DecodedOp { @@ -572,7 +763,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 +791,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 +1091,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 +1157,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 +1182,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 +1775,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 +1843,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 +2095,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 +2136,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 +2363,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 +5421,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 +5663,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..3b5e539b 100644 --- a/src/vm/jit/region.rs +++ b/src/vm/jit/region.rs @@ -213,7 +213,9 @@ fn remap_inst_inputs( }}; } match kind { - SsaInstKind::Constant(_) | SsaInstKind::ArrayNew => {} + SsaInstKind::Constant(_) + | SsaInstKind::MaterializeRootCallable { .. } + | SsaInstKind::ArrayNew => {} SsaInstKind::HostCall { args, .. } => { for arg in args { one!(arg); @@ -447,7 +449,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..b387d72e 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -101,6 +101,10 @@ pub enum VmError { InvalidCallable, InvalidCallablePrototype(u32), + /// A JIT bridge received a callable prototype id outside the valid `u32` + /// index range (e.g. a negative value). Carries the raw value so the + /// error stays accurate instead of masquerading as a truncated id. + InvalidCallablePrototypeId(i64), InvalidBranchTarget { target: usize, }, @@ -109,6 +113,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, }, @@ -166,6 +173,9 @@ impl std::fmt::Display for VmError { VmError::InvalidCallablePrototype(id) => { write!(f, "invalid callable prototype {id}") } + VmError::InvalidCallablePrototypeId(id) => { + write!(f, "invalid callable prototype id {id}") + } VmError::InvalidBranchTarget { target } => { write!( f, @@ -180,6 +190,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 +1108,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 +1208,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 +1221,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 +1284,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 +1334,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 +1356,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 +1370,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 +2721,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) @@ -2732,6 +2835,11 @@ impl Vm { if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } + if !self.owns_callable(&callable) { + return Err(VmError::InvalidFrameState( + "callable does not belong to this vm", + )); + } self.instance.queued_callables.push_back(QueuedCallable { callable, args, @@ -2837,6 +2945,11 @@ impl Vm { if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } + if !self.owns_callable(&callable) { + return Err(VmError::InvalidFrameState( + "callable does not belong to this vm", + )); + } if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "host invocation requires a halted VM", diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 357f6b21..808c7b67 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 } @@ -667,6 +675,64 @@ pub(crate) extern "C" fn pd_vm_native_replace_value_in_slot( STATUS_CONTINUE } +pub(crate) fn materialize_root_callable_entry_address() -> usize { + pd_vm_native_materialize_root_callable as *const () as usize +} + +/// Materializes the fresh environment-free callable for one root callable +/// binding of an inlined JIT callee frame and registers it with the VM's +/// owned-callable set. +/// +/// Every call mints a brand-new `Arc` (never a shared constant) and writes it +/// into `dst`, mirroring the interpreter's `enter_script_frame` +/// re-initialization. Because the identity is fresh per materialization, a +/// host handle from a previous lifecycle can never be re-legalized by a later +/// run, while the fresh handle is immediately legal at every host entry gate. +pub(crate) extern "C" fn pd_vm_native_materialize_root_callable( + vm: *mut Vm, + dst: *mut Value, + prototype_id: i64, +) -> i32 { + let Some(vm) = (unsafe { vm.as_mut() }) else { + store_bridge_error(VmError::JitNative( + "native materialize-root-callable helper received null vm pointer".to_string(), + )); + return STATUS_ERROR; + }; + if dst.is_null() { + store_bridge_error(VmError::JitNative( + "native materialize-root-callable helper received null slot pointer".to_string(), + )); + return STATUS_ERROR; + } + let Ok(prototype_id) = u32::try_from(prototype_id) else { + // Keep the raw (possibly negative) value in a dedicated typed error + // instead of truncating it into `InvalidCallablePrototype(u32::MAX)`. + store_bridge_error(VmError::InvalidCallablePrototypeId(prototype_id)); + return STATUS_ERROR; + }; + let Some(prototype) = vm.program().callable_prototypes.get(prototype_id as usize) else { + store_bridge_error(VmError::InvalidCallablePrototype(prototype_id)); + return STATUS_ERROR; + }; + let callable = Arc::new(crate::bytecode::CallableValue { + prototype_id, + kind: prototype.kind, + env: None, + }); + vm.instance.owned_callables.push(Arc::downgrade(&callable)); + unsafe { + // The owned temp slot may already hold a previous iteration's + // materialized callable (the trace reuses the slot on every loop + // iteration). Overwriting with `ptr::write` would leak the previous + // Arc; replace it and drop the previous value like the other bridge + // slot helpers (`replace_value_in_slot`, `clear_value_slot`). + let previous = std::ptr::replace(dst, Value::Callable(callable)); + drop(previous); + } + STATUS_CONTINUE +} + pub(crate) extern "C" fn pd_vm_native_init_null_value_slot(dst: *mut Value) -> i32 { if dst.is_null() { store_bridge_error(VmError::JitNative( @@ -1005,6 +1071,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 +1454,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 @@ -2122,6 +2269,37 @@ mod tests { ); } + #[test] + fn materialize_root_callable_rejects_negative_prototype_id_typed() { + let mut vm = Vm::new(virtual_frame_program()); + let mut slot = MaybeUninit::::uninit(); + let status = pd_vm_native_materialize_root_callable(&mut vm, slot.as_mut_ptr(), -1); + assert_eq!(status, STATUS_ERROR); + assert!(matches!( + take_bridge_error(), + Some(VmError::InvalidCallablePrototypeId(-1)) + )); + // The error must be the accurate typed variant, never a truncated + // `InvalidCallablePrototype` masquerading as u32::MAX. + let error = take_bridge_error(); + assert!( + !matches!(error, Some(VmError::InvalidCallablePrototype(_))), + "negative prototype id must not masquerade as a u32 prototype: {error:?}" + ); + } + + #[test] + fn materialize_root_callable_rejects_out_of_range_prototype_id() { + let mut vm = Vm::new(virtual_frame_program()); + let mut slot = MaybeUninit::::uninit(); + let status = pd_vm_native_materialize_root_callable(&mut vm, slot.as_mut_ptr(), 99); + assert_eq!(status, STATUS_ERROR); + assert!(matches!( + take_bridge_error(), + Some(VmError::InvalidCallablePrototype(99)) + )); + } + #[test] fn jit_trace_exit_status_round_trips_reserved_range_boundaries() { for exit_id in [0, 1, 7, 255, STATUS_JIT_TRACE_EXIT_MAX_ID] { diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 72d71f48..5258d1ce 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, @@ -262,6 +285,21 @@ pub(crate) fn non_yielding_i64_host_call_signature( sig } +/// Signature of the JIT root-callable materialization helper: +/// `(vm, out: *mut Value, prototype_id: i64) -> i32`. +#[cfg(feature = "cranelift-jit")] +pub(crate) fn materialize_root_callable_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)); + sig.params.push(AbiParam::new(pointer_type)); + sig.params.push(AbiParam::new(types::I64)); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + #[cfg(feature = "cranelift-jit")] pub(crate) fn collection_predicate_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 41b12ae1..6bce5ab5 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -14,15 +14,16 @@ 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, map_get_entry_address, map_has_entry_address, map_iter_next_entry_address, map_iter_take_key_entry_address, map_iter_take_value_entry_address, map_set_entry_address, - non_yielding_host_call_entry_address, non_yielding_i64_host_call_entry_address, - non_yielding_scalar_host_call_entry_address, regex_match_entry_address, - regex_replace_entry_address, replace_value_in_slot_entry_address, + materialize_root_callable_entry_address, non_yielding_host_call_entry_address, + non_yielding_i64_host_call_entry_address, non_yielding_scalar_host_call_entry_address, + regex_match_entry_address, regex_replace_entry_address, replace_value_in_slot_entry_address, restore_active_exit_state_entry_address, restore_active_sparse_exit_state_entry_address, restore_exit_state_entry_address, restore_sparse_exit_state_entry_address, restore_virtual_frame_entry_address, shared_array_from_buffer_entry_address, @@ -36,10 +37,11 @@ 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, + 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, materialize_root_callable_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, @@ -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 = 7; 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 a4a16a41..eb77ef1d 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); "#, ) @@ -2648,3 +2655,53 @@ 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, and + // the JIT inline ownership bridge adds the root-callable materialization + // helper; the native callable ABI revision must reflect both so every + // directly coupled program/native cache is invalidated exactly once. + assert_eq!( + super::native::NATIVE_CALLABLE_ABI_VERSION, + 7, + "native callable ABI revision must cover direct script call and root-callable materialization 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..932c45b0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,7 @@ #![allow(unused_imports)] +use std::path::{Path, PathBuf}; + pub use vm::{ Assembler, BytecodeBuilder, CallOutcome, CapabilityProfile, CompileSourceFileOptions, Compiler, Expr, HostArgsFunction, HostFunction, HostFunctionRegistry, Program, SourceFlavor, @@ -131,6 +133,7 @@ pub enum CompileErrorKind { CallableUsedAsValue, NonCallableLocal, LocalSlotOverflow, + FrameLocalLimitExceeded, CallableArityMismatch, BreakOutsideLoop, ContinueOutsideLoop, @@ -167,6 +170,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, @@ -394,6 +400,44 @@ pub fn make_runtime_sleep() -> Box { Box::new(RuntimeSleep) } +/// Panic-safe temporary module root for module-override tests. +/// +/// The root is canonicalized so module identities and diagnostic paths match +/// under symlinked temp directories, and the directory is removed on drop +/// even when a test panics mid-way. +pub struct TempModuleRoot { + root: PathBuf, +} + +impl TempModuleRoot { + pub fn new(prefix: &str) -> Self { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + // Module identities are canonical for existing files; keep the root + // canonical too so paths match under symlinked temp directories. + let root = root.canonicalize().unwrap_or(root); + Self { root } + } + + pub fn path(&self) -> &Path { + &self.root + } +} + +impl Drop for TempModuleRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + #[test] fn common_helpers_are_referenced() { let _runtime_case = RuntimeCase { @@ -416,6 +460,10 @@ fn common_helpers_are_referenced() { expected_kind: SourceErrorKind::Parse, expected_contains_all: &[], }; + // Constructing a real root exercises the panic-safe guard: it is created + // under the test temp dir and removed again on drop. + let _temp_root = TempModuleRoot::new("common_helpers_are_referenced"); + let _ = _temp_root.path(); let _host_binding = HostBindingCase { name: "x", factory: make_add_one, 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..f8d6d58d 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!( @@ -1089,6 +1090,323 @@ fn rustscript_closure_value_parse_rejection_cases_work() { } } +#[test] +fn closure_mut_capture_updates_outer_local() { + let case = rustscript_runtime_case( + "closure mutation capture updates outer local", + r#" + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + state; + "#, + vec![Value::string("a")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_mut_capture_survives_multiple_calls() { + let case = rustscript_runtime_case( + "closure mutation capture survives multiple calls", + r#" + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + let _ = sink("b"); + let _ = sink("c"); + state; + "#, + vec![Value::string("abc")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_mut_capture_is_visible_after_callback_returns() { + let case = rustscript_runtime_case( + "closure mutation capture visible after callback returns", + r#" + fn invoke(cb, x) { + let _ = cb(x); + null + } + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + invoke(sink, "a"); + invoke(sink, "b"); + state; + "#, + vec![Value::Null, Value::Null, Value::string("ab")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_mut_capture_two_closures_share_one_cell() { + let case = rustscript_runtime_case( + "two closures mutating one captured local observe one value", + r#" + let mut state: string = ""; + let first = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let second = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = first("x"); + let _ = second("y"); + state; + "#, + vec![Value::string("xy")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_copy_capture_keeps_source_reusable() { + let case = rustscript_runtime_case( + "closure copy capture keeps source reusable", + r#" + let a = "x"; + let f = |d| d + a.copy(); + let d = a; + f(d); + "#, + vec![Value::string("xx")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_by_value_move_still_rejects_later_outer_use() { + let case = ParseErrorCase { + name: "closure by-value capture of movable local rejects later outer use", + source: r#" + let a = ""; + let f = |d| d + a; + let _ = f("x"); + a; + "#, + flavor: SourceFlavor::RustScript, + expected_contains_all: &["local 'a'", "moved"], + }; + expect_parse_error_case(&case); +} + +#[test] +fn closure_mut_capture_from_immutable_source_is_rejected() { + let case = ParseErrorCase { + name: "closure mutation capture from immutable source is rejected", + source: r#" + let state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + state; + "#, + flavor: SourceFlavor::RustScript, + expected_contains_all: &["immutable local 'state'"], + }; + expect_parse_error_case(&case); +} + +#[test] +fn closure_mut_capture_compound_add_assign_updates_outer_local() { + let case = rustscript_runtime_case( + "closure `+=` on captured local updates outer local", + r#" + let mut state: int = 0; + let bump = |delta| if true => { + state += delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = bump(1); + let _ = bump(2); + state; + "#, + vec![Value::Int(3)], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_write_only_capture_assignment_overwrites_outer_local() { + let case = rustscript_runtime_case( + "closure write-only capture assignment (RHS does not read the slot) overwrites outer local", + r#" + let mut state: string = "initial"; + let reset = |value| if true => { + state = value; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = reset("after"); + state; + "#, + vec![Value::string("after")], + ); + run_runtime_case(&case); +} + +#[test] +fn closure_mut_capture_compound_and_write_only_modes_stay_shared() { + for (name, source) in [ + ( + "compound `+=` capture is shared-mutable, not a move", + r#" + let mut state: int = 0; + let bump = |delta| if true => { + state += delta; + null + } else => { + null + }; + let _ = bump(1); + state; + "#, + ), + ( + "write-only `=` capture is shared-mutable, not a move", + r#" + let mut state: string = "initial"; + let reset = |value| if true => { + state = value; + null + } else => { + null + }; + let _ = reset("after"); + state; + "#, + ), + ] { + let compiled = vm::compile_source_with_flavor(source, SourceFlavor::RustScript) + .unwrap_or_else(|err| panic!("{name} should compile: {err}")); + let prototype = compiled + .program + .callable_prototypes + .iter() + .find(|prototype| { + prototype.kind == vm::CallableKind::Closure + && prototype + .capture_modes + .contains(&vm::CaptureBindingMode::BorrowMut) + }) + .unwrap_or_else(|| panic!("{name} should carry a BorrowMut capture")); + assert!( + prototype + .capture_modes + .iter() + .all(|mode| *mode != vm::CaptureBindingMode::Move), + "{name} must not be classified as a move" + ); + } +} + +#[test] +fn closure_mut_capture_cell_is_fresh_after_vm_reset() { + // A re-run of the same program on the same VM starts from a fresh + // capture cell: the second run never reads the previous run's cell + // value. + let compiled = vm::compile_source_with_flavor( + r#" + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + let _ = sink("b"); + state; + "#, + SourceFlavor::RustScript, + ) + .expect("mutable capture source should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("ab")], + "first run should accumulate both deltas in the shared cell" + ); + + // Reset must close the run-scoped capture state: the operand stack + // empties and the cell-backed local slot returns to Null. + vm.reset_for_reuse(); + assert!( + vm.stack().is_empty(), + "reset should clear the operand stack" + ); + assert!( + vm.locals().iter().all(|local| *local == Value::Null), + "reset should clear every local slot, including the cell-backed one" + ); + + // The second run starts from a fresh cell: accumulating the same two + // deltas yields exactly "ab", not a value derived from the first run's + // cell contents. + assert_eq!(vm.run().expect("second run should halt"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("ab")], + "a re-run must not read the previous run's capture cell value" + ); +} + +#[test] +fn closure_explicit_move_then_use_inside_body_is_rejected() { + let case = ParseErrorCase { + name: "closure explicit move inside body rejects later use of captured local", + source: r#" + let a = ""; + let f = |d| if true => { + let y = a; + let z = a; + y + z + } else => { + "" + }; + let _ = f("x"); + "#, + flavor: SourceFlavor::RustScript, + // The captured slot is an unnamed hidden local (`#N`), so the moved + // local is reported by its generated name. + expected_contains_all: &["local '#", "moved"], + }; + expect_parse_error_case(&case); +} + #[test] fn rustscript_closure_captured_callable_invocation_works() { let cases = vec![ @@ -3634,6 +3952,46 @@ fn rustscript_explicit_optional_type_annotations_work() { expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), expected_contains_all: &["callable body result expects 'int'", "got bool"], }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure arity", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + stream(|value, extra| value); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &[ + "argument 'handler'", + "fn(map) -> map", + "takes 2 parameters", + ], + }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure parameter type", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + fn handle(value: int) -> map { { action: "continue" } } + stream(handle); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &[ + "argument 'handler' type mismatch", + "arg[0]", + "map", + "int", + ], + }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure return type", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + stream(|value| 1); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &["callable body result type mismatch", "map", "int"], + }, SourceErrorCase { name: "json encode rejects bytes under strict rustscript typing", source: r#" @@ -3831,3 +4189,1345 @@ fn rustscript_strict_stream_emit_accepts_any_payload() { ) .expect("strict stream::emit with any payloads must compile"); } + +#[test] +fn tail_expression_if_collects_annotated_literal_local() { + // Port of the `letif_a.rss` provider repro: an annotated `let` declared + // inside a block used by a tail-position expression-if must be collected + // with the branch's refined state so strict slot validation sees a + // concrete compile-time type. + run_runtime_cases(&[rustscript_runtime_case( + "annotated literal local in tail expression-if else branch", + r#" + fn pick(model: string) -> string { + if model == "" => { + "empty" + } else => { + let body_text: string = "literal"; + body_text + } + } + + pick("x"); + "#, + vec![Value::string("literal")], + )]); +} + +#[test] +fn tail_expression_if_collects_json_encode_local() { + run_runtime_cases(&[rustscript_runtime_case( + "annotated json::encode local in tail expression-if branch", + r#" + use json; + + fn pick(model: string) -> string { + if model == "" => { + "" + } else => { + let encoded: string = json::encode({ text: "literal" }); + encoded + } + } + + pick("x"); + "#, + vec![Value::string("{\"text\":\"literal\"}")], + )]); +} + +#[test] +fn tail_expression_if_collects_module_call_local() { + // Port of the `tailif_root.rss` / `tailif_m2.rss` repro: a local bound to + // a module call inside a tail expression-if branch must resolve to the + // module function's declared return schema. The temp root is canonicalized + // and panic-safe: it is removed on drop even when a later assertion + // panics, so no cleanup call is needed on any path. + let root = TempModuleRoot::new("a3_b2_tailif_module"); + + let main_path = root.path().join("main.rss"); + std::fs::write( + &main_path, + r#" + use self::m2 as adapter; + adapter::call("other"); + "#, + ) + .expect("main source should write"); + + let options = CompileSourceFileOptions::new().with_module_override_source( + "m2.rss", + r#" + pub fn call(request: string) -> string { + if request == "hello" => { + "matched" + } else => { + let transformed: string = inner(request); + transformed + } + } + + fn inner(value: string) -> string { + value + "!" + } + "#, + ); + + let compiled = compile_source_file_with_options(&main_path, options) + .expect("tail expression-if module-call local 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::string("other!")]); +} + +#[test] +fn tail_expression_if_branch_local_does_not_leak_to_sibling_branch() { + // A local declared inside one tail expression-if branch must not be + // visible in the sibling branch: the then branch sees `body_text` as + // unknown and the parser rejects the reference. + let case = SourceErrorCase { + name: "tail if branch local does not leak into sibling branch", + source: r#" + fn pick(model: string) -> string { + if model == "" => { + body_text + } else => { + let body_text: string = "literal"; + body_text + } + } + + pick("x"); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Parse, + expected_contains_all: &["unknown local 'body_text'"], + }; + expect_source_error_case(&case); +} + +#[test] +fn tail_expression_if_branch_local_is_unavailable_after_branch() { + // A local declared inside an expression-if branch stays branch-scoped: + // using it after the branch is rejected as possibly-unavailable on the + // other control-flow path. + let case = SourceErrorCase { + name: "tail if branch local is unavailable after the branch", + source: r#" + fn pick(model: string) -> string { + if model == "" => { + "empty" + } else => { + let body_text: string = "literal"; + body_text + }; + body_text + } + + pick("x"); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Parse, + expected_contains_all: &["local 'body_text'", "may be unavailable"], + }; + expect_source_error_case(&case); +} + +#[test] +fn tail_expression_if_rejects_incompatible_branch_results() { + // Incompatible tail branch results must still be rejected even though + // branch collection is now state-refined. + let case = SourceErrorCase { + name: "tail if rejects incompatible branch result types", + source: r#" + fn pick(model: string) -> string { + if model == "" => { + 1 + } else => { + "literal" + } + } + + pick("x"); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::IfElseBranchTypeMismatch), + expected_contains_all: &["incompatible expression result", "int vs string"], + }; + expect_source_error_case(&case); +} + +#[test] +fn tail_expression_if_unknown_annotation_keeps_strict_diagnostic() { + // A genuinely unknown declaration inside a tail expression-if branch must + // keep the strict typing diagnostic: the branch-state refinement must not + // turn `unknown` annotations into concrete types. + let case = SourceErrorCase { + name: "tail if unknown annotation keeps strict typing diagnostic", + source: r#" + fn pick(model: string) -> string { + if model == "" => { + "empty" + } else => { + let body_text: unknown = "literal"; + body_text + } + } + + pick("x"); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Parse, + expected_contains_all: &[ + "concrete compile-time types", + "'unknown' annotations are not allowed", + ], + }; + expect_source_error_case(&case); +} + +#[test] +fn non_tail_expression_if_annotated_local_control() { + // Control: an annotated local inside a non-tail expression-if branch. + run_runtime_cases(&[rustscript_runtime_case( + "annotated literal local in non-tail expression-if branch", + r#" + fn pick(model: string) -> string { + let label: string = if model == "" => { + "empty" + } else => { + let body_text: string = "literal"; + body_text + }; + label + "!" + } + + pick("x"); + "#, + vec![Value::string("literal!")], + )]); +} + +#[test] +fn tail_expression_if_unannotated_local_control() { + // Control: an unannotated local in a tail expression-if branch executes + // to the same value as the annotated form. + run_runtime_cases(&[rustscript_runtime_case( + "unannotated literal local in tail expression-if else branch", + r#" + fn pick(model: string) -> string { + if model == "" => { + "empty" + } else => { + let body_text = "literal"; + body_text + } + } + + pick("x"); + "#, + vec![Value::string("literal")], + )]); +} + +#[test] +fn tail_match_with_annotated_let_in_arm_branch() { + // Match arm bodies parse as expression syntax (`{ ... }` in an arm is an + // array literal, not a statement block), so the closest supported form of + // "tail match with an annotated let" is an if-expression arm whose branch + // declares the local. It must resolve and execute through the refined + // branch states. + run_runtime_cases(&[rustscript_runtime_case( + "annotated literal local in tail match arm if-branch", + r#" + fn pick(model: string) -> string { + match model { + "" => if model == "x" => { "a" } else => { let body_text: string = "literal"; body_text }, + _ => "other" + } + } + + pick(""); + "#, + vec![Value::string("literal")], + )]); +} + +#[test] +fn json_encode_accepts_string_key_runtime_map() { + // A runtime map annotated as `map` has schema `map`: key + // legality cannot be proven statically, so the compile-time validator + // must admit it and the runtime encoder's string-key check decides. + let compiled = compile_source( + r#" + use json; + let request: map = { + "model": "test-model", + "stream": false, + }; + json::encode(request); + "#, + ) + .expect("string-key runtime maps must compile for json::encode"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("json::encode should run"); + assert_eq!(status, VmStatus::Halted); + let [Value::String(text)] = vm.stack() else { + panic!("expected encoded json string, got {:?}", vm.stack()); + }; + let parsed: serde_json::Value = + serde_json::from_str(text).expect("encoded text must be valid json"); + assert_eq!( + parsed, + serde_json::json!({ "model": "test-model", "stream": false }) + ); +} + +#[test] +fn json_encode_accepts_nested_runtime_maps_and_arrays() { + // Provider-shaped payload: nested maps and arrays inside a runtime map. + // The generated object key order is unspecified, so the assertion parses + // the text and compares semantic JSON. + let compiled = compile_source( + r#" + use json; + let request: map = { + "model": "test-model", + "stream": false, + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "hello" }] } + ], + "tools": [ + { "type": "function", "function": { "name": "read_file", "parameters": { "type": "object" } } } + ] + }; + json::encode(request); + "#, + ) + .expect("nested runtime maps must compile for json::encode"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("json::encode should run"); + assert_eq!(status, VmStatus::Halted); + let [Value::String(text)] = vm.stack() else { + panic!("expected encoded json string, got {:?}", vm.stack()); + }; + let parsed: serde_json::Value = + serde_json::from_str(text).expect("encoded text must be valid json"); + assert_eq!( + parsed, + serde_json::json!({ + "model": "test-model", + "stream": false, + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "hello" }] } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": { "type": "object" } + } + } + ] + }) + ); +} + +#[test] +fn json_encode_preserves_struct_support() { + // Control: struct/object-shaped encoding must remain green while runtime + // maps are admitted. + let compiled = compile_source( + r#" + use json; + struct Inner { name: string } + struct Payload { + answer: int, + ok: bool, + arr: [int], + inner: Inner, + } + let payload = { + answer: 42, + ok: true, + arr: [1, 2], + inner: { name: "pd" }, + }; + json::encode(payload); + "#, + ) + .expect("struct-shaped values must keep compiling for json::encode"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("json::encode should run"); + assert_eq!(status, VmStatus::Halted); + let [Value::String(text)] = vm.stack() else { + panic!("expected encoded json string, got {:?}", vm.stack()); + }; + let parsed: serde_json::Value = + serde_json::from_str(text).expect("encoded text must be valid json"); + assert_eq!( + parsed, + serde_json::json!({ + "answer": 42, + "ok": true, + "arr": [1, 2], + "inner": { "name": "pd" }, + }) + ); +} + +#[test] +fn json_encode_runtime_map_rejects_non_string_key() { + // Non-string keys are not representable in `TypeSchema::Map`, so the + // rejection must come from the runtime encoder, not the compiler. + let compiled = compile_source( + r#" + use json; + let payload = { 1: "one" }; + json::encode(payload); + "#, + ) + .expect("non-string-key maps must compile; runtime must reject them"); + + let mut vm = Vm::new(compiled.program); + let err = vm + .run() + .expect_err("json::encode must reject non-string map keys"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode map keys must be strings"), + "{message}" + ); + } + other => panic!("unexpected vm error: {other}"), + } +} + +#[test] +fn json_encode_runtime_map_rejects_nested_bytes() { + let compiled = compile_source( + r#" + use json; + let payload: map = { "data": b"abc" }; + json::encode(payload); + "#, + ) + .expect("runtime maps with bytes values must compile; runtime must reject them"); + + let mut vm = Vm::new(compiled.program); + let err = vm + .run() + .expect_err("json::encode must reject bytes values inside maps"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode does not support bytes values"), + "{message}" + ); + } + other => panic!("unexpected vm error: {other}"), + } +} + +#[test] +fn json_encode_runtime_map_rejects_nested_callable() { + let compiled = compile_source( + r#" + use json; + fn handler(value: int) -> int { value + 1 } + let payload: map = { "handler": handler }; + json::encode(payload); + "#, + ) + .expect("runtime maps with callable values must compile; runtime must reject them"); + + let mut vm = Vm::new(compiled.program); + let err = vm + .run() + .expect_err("json::encode must reject callable values inside maps"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode does not support callable values"), + "{message}" + ); + } + other => panic!("unexpected vm error: {other}"), + } +} + +#[test] +fn json_encode_rejects_concrete_inner_map_of_bytes_at_compile_time() { + // A map with a concrete `bytes` inner schema is provably non-encodable, + // so the compile-time validator must recurse through the `map` + // arm and reject the program without ever running it. This is the + // compile-time counterpart to `json_encode_runtime_map_rejects_nested_bytes`, + // which uses an `Unknown` inner schema and defers to the runtime. + match compile_source( + r#" + use json; + let payload: map = { "data": b"abc" }; + json::encode(payload); + "#, + ) { + Err(err) => match err { + vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + }) => { + assert!( + detail.contains("builtin 'json::encode' cannot encode this value"), + "{detail}" + ); + // The recursion must reach the bytes check through the map arm + // and report the original value path. + assert!(detail.contains("value uses bytes"), "{detail}"); + } + other => panic!("unexpected compiler error: {other}"), + }, + Ok(_) => panic!("map must be rejected at compile time"), + } +} + +#[test] +fn json_encode_rejects_nested_concrete_inner_maps_at_compile_time() { + // Recursive validation must apply at every map nesting level: the outer + // `map>` arm recurses into the inner `map` arm, which + // recurses into the bytes check. + match compile_source( + r#" + use json; + let payload: map> = { "outer": { "data": b"abc" } }; + json::encode(payload); + "#, + ) { + Err(err) => match err { + vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + }) => { + assert!( + detail.contains("builtin 'json::encode' cannot encode this value"), + "{detail}" + ); + assert!(detail.contains("value uses bytes"), "{detail}"); + } + other => panic!("unexpected compiler error: {other}"), + }, + Ok(_) => panic!("nested concrete map inners must be rejected at compile time"), + } +} + +#[test] +fn json_encode_accepts_concrete_inner_map_of_encodable_values() { + // Control: a map with a concrete encodable inner schema (`map`) + // must pass the recursive compile-time validation and encode at runtime, + // proving the map arm does not blanket-reject concrete inners. + let compiled = compile_source( + r#" + use json; + let payload: map = { "one": 1, "two": 2 }; + json::encode(payload); + "#, + ) + .expect("map must compile for json::encode"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("json::encode should run"); + assert_eq!(status, VmStatus::Halted); + let [Value::String(text)] = vm.stack() else { + panic!("expected encoded json string, got {:?}", vm.stack()); + }; + let parsed: serde_json::Value = + serde_json::from_str(text).expect("encoded text must be valid json"); + assert_eq!(parsed, serde_json::json!({ "one": 1, "two": 2 })); +} + +// --------------------------------------------------------------------------- +// Recursive schema guards for json::encode +// --------------------------------------------------------------------------- +// +// `validate_json_schema` walks the resolved schema of the encoded value. For +// a self- or mutually-recursive struct placed inside a concrete `map` +// inner, the resolver terminates its own expansion by leaving a `Named` +// marker for the schema already being expanded, but the validator used to +// re-resolve at every level with a fresh seen set, so the marker +// re-expanded one level deeper on every descent and the compile-time +// validation recursed without bound: the compiler ground for minutes +// without terminating instead of overflowing quickly. +// +// The regression probes below therefore run the compile inside a +// subprocess. The child is spawned with a constrained stack (so a +// regression aborts in well under a second instead of grinding for +// minutes) and a hard deadline (so a non-terminating compiler can never +// hang the harness); either way the failure surfaces as a normal +// assertion failure in the parent instead of killing the whole test +// process. +// +// The positive probe is the deterministic regression: its literal is +// well-formed only because the declared-schema check admits partial +// objects at recursive re-entries (the innermost `{}` is an `A` whose +// required `b` is filled by nothing at runtime - structs are +// compile-time-typed maps, so the encoded value is exactly the literal +// map as written). The negative control keeps a `bytes` field in the +// cycle; `TypeSchema::Object` is a `HashMap`, so field order is +// randomized per process and the rejection path may be `value.tag` or +// `value.b.a.tag` - the assertion accepts either as long as the `tag` +// field is named. + +/// Runs `child` inline when `probe_env` is set (subprocess mode). The parent +/// path returns immediately and `spawn_json_probe` drives the subprocess. +/// The child prints `sentinel` at probe entry *before* running the closure, +/// so the parent can distinguish "the right test ran but hung" (sentinel +/// present, deadline hit) from "a different test ran" (sentinel absent). +fn run_json_probe_child(probe_env: &str, sentinel: &str, child: impl FnOnce() -> bool) { + if std::env::var_os(probe_env).is_some() { + println!("{sentinel}"); + std::process::exit(if child() { 0 } else { 1 }); + } +} + +/// Spawns this test binary with `--exact ` and `probe_env` set, +/// under a 512 KiB stack limit and a 60 s deadline. The child re-enters the +/// same test, prints `sentinel` via `run_json_probe_child`, runs its probe +/// closure, and exits 0/1. A stack overflow aborts the child with a +/// non-success status, and a non-terminating compiler is killed at the +/// deadline. +/// +/// The parent does not trust the exit status alone: a mistyped filter makes +/// libtest exit 0 while running zero tests, which would silently void the +/// probe. The child's output is therefore captured and must show that +/// exactly one test was selected (`running 1 test`) *and* that the +/// test-specific `sentinel` was printed. The sentinel is unique per test, +/// so a filter that accidentally matches a *different* probe test still +/// fails: that test prints its own sentinel, not the demanded one. The +/// probe closure then exits the child with 0/1, so a successful status +/// plus the matched filter plus the sentinel is the reliable success +/// signal. On any failure the returned error includes the child's output +/// so the regression is diagnosable. +fn spawn_json_probe(probe_env: &str, test_name: &str, sentinel: &str) -> Result<(), String> { + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg("ulimit -s 512; exec \"$0\" --exact \"$1\" --nocapture") + .arg(std::env::current_exe().expect("test binary path")) + .arg(test_name) + .env(probe_env, "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("json probe subprocess should start"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let outcome = loop { + match child + .try_wait() + .expect("json probe subprocess should be waitable") + { + Some(status) => break Ok(status), + None if std::time::Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break Err("child did not finish within 60 s (compiler did not terminate)"); + } + None => std::thread::sleep(std::time::Duration::from_millis(25)), + } + }; + let mut stdout = String::new(); + let mut stderr = String::new(); + use std::io::Read; + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_string(&mut stdout); + } + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_string(&mut stderr); + } + let child_output = format!("--- child stdout ---\n{stdout}--- child stderr ---\n{stderr}"); + match outcome { + Err(reason) => return Err(format!("{reason}\n{child_output}")), + Ok(status) if !status.success() => { + return Err(format!("child exited with {status}\n{child_output}")); + } + _ => {} + } + if !stdout.contains("running 1 test") { + return Err(format!( + "child did not select exactly one test (filter matched nothing?)\n{child_output}" + )); + } + if !stdout.contains(sentinel) { + return Err(format!( + "child did not print the test-specific probe sentinel '{sentinel}' (a different test ran?)\n{child_output}" + )); + } + Ok(()) +} + +#[test] +fn json_encode_accepts_mutually_recursive_structs_inside_concrete_map() { + // A `map` whose inner schema is a mutually recursive struct pair + // (A -> B -> A) must compile and encode: the recursion is structural, + // every runtime value is finite, and the cycle edge itself is + // encodable. The innermost partial literal `{}` (an `A` missing its + // required `b`) is admitted only because the declared-schema check + // allows partial objects at recursive re-entries, so the runtime + // value is exactly the map literal as written and the expected + // encoding is `{"x":{"b":{"a":{"b":{"a":{}}}}}}`. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_ACCEPT_RECURSIVE_MAP"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_ACCEPT_RECURSIVE_MAP"; + run_json_probe_child(PROBE, SENTINEL, || { + let compiled = match compile_source( + r#" + use json; + struct A { b: B } + struct B { a: A } + let payload: map = { "x": { b: { a: { b: { a: {} } } } } }; + json::encode(payload); + "#, + ) { + Ok(compiled) => compiled, + Err(err) => { + eprintln!("mutually recursive map must compile, got: {err}"); + return false; + } + }; + let mut vm = Vm::new(compiled.program); + let status = match vm.run() { + Ok(status) => status, + Err(err) => { + eprintln!("mutually recursive map must run, got: {err}"); + return false; + } + }; + if status != VmStatus::Halted { + eprintln!("mutually recursive map must halt, got: {status:?}"); + return false; + } + let [Value::String(text)] = vm.stack() else { + eprintln!("expected encoded json string, got {:?}", vm.stack()); + return false; + }; + let parsed = match serde_json::from_str::(text) { + Ok(parsed) => parsed, + Err(err) => { + eprintln!("encoded text must be valid json: {err}"); + return false; + } + }; + let expected = serde_json::json!({ "x": { "b": { "a": { "b": { "a": {} } } } } }); + if parsed != expected { + eprintln!("unexpected encoding: {parsed}"); + return false; + } + true + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_accepts_mutually_recursive_structs_inside_concrete_map", + SENTINEL, + ) { + panic!( + "mutually recursive structs in map must compile and encode (probe failed): {reason}" + ); + } +} + +#[test] +fn json_encode_rejects_unsupported_field_in_recursive_struct_inside_concrete_map() { + // Negative control for the cycle contract: the cycle edge itself is + // encodable and must not be rejected, but unsupported field types that + // are reachable from the cycle (here `tag: bytes` as a sibling of the + // recursive field `b`) must still fail at compile time. The literal + // supplies `tag` at every level the declared-schema check inspects + // strictly (the top-level `A` and the first `A` re-entry at `x.b.a`); + // only the innermost `{}` at the cycle marker stays partial, exactly + // like the positive probe. The `json::encode` rejection then always + // names the top-level `tag` field (`value.tag`) - the cycle guard only + // short-circuits the marker edge, never sibling fields. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_REJECT_RECURSIVE_MAP_BYTES"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_REJECT_RECURSIVE_MAP_BYTES"; + run_json_probe_child(PROBE, SENTINEL, || { + match compile_source( + r#" + use json; + struct A { b: B, tag: bytes } + struct B { a: A } + let payload: map = { "x": { b: { a: { b: { a: {} }, tag: b"t" } }, tag: b"t" } }; + json::encode(payload); + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + if detail.contains("uses bytes") && detail.contains("tag") { + true + } else { + eprintln!("unexpected rejection detail: {detail}"); + false + } + } + Err(err) => { + eprintln!("recursive struct with bytes field must be rejected, got: {err}"); + false + } + Ok(_) => { + eprintln!("recursive struct with bytes field must be rejected at compile time"); + false + } + } + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_rejects_unsupported_field_in_recursive_struct_inside_concrete_map", + SENTINEL, + ) { + panic!( + "bytes reachable from a recursive map must be rejected at compile time (probe failed): {reason}" + ); + } +} + +#[test] +fn json_probe_harness_rejects_child_that_runs_no_tests() { + // The probe harness must not treat a child that matched no test as + // success: a mistyped filter makes libtest exit 0 with "running 0 + // tests", which would silently void every probe's regression value. + let ran = spawn_json_probe( + "RUSTSCRIPT_JSON_PROBE_NO_MATCH", + "compiler_rustscript_tests::json_probe_no_such_test_exists", + "json-probe-entered:never-printed", + ); + assert!( + ran.is_err(), + "probe with a non-matching test name must not be reported as success" + ); +} + +#[test] +fn json_encode_rejects_unsupported_field_in_nested_generic_instantiation_map() { + // `Node>` re-enters the recursion wrapped in a *different* + // instantiation at every level. A cycle key built from the raw render + // of the node grows one nesting per re-entry (`Node`, + // `Node>`, `Node>>`, ...) and never repeats, + // so the walk neither terminates nor reaches the unsupported `tag` + // field. The key must collapse the wrapped re-entries to the one cycle + // class and still reject the bytes reachable from the body. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_REJECT_NESTED_INSTANTIATION"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_REJECT_NESTED_INSTANTIATION"; + run_json_probe_child(PROBE, SENTINEL, || { + match compile_source( + r#" + use json; + struct Node { child: Node>, tag: bytes } + fn enc(m: map>) { + json::encode(m); + } + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + if detail.contains("uses bytes") && detail.contains("tag") { + true + } else { + eprintln!("unexpected rejection detail: {detail}"); + false + } + } + Err(err) => { + eprintln!("nested generic instantiation with bytes must be rejected, got: {err}"); + false + } + Ok(_) => { + eprintln!( + "nested generic instantiation with bytes must be rejected at compile time" + ); + false + } + } + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_rejects_unsupported_field_in_nested_generic_instantiation_map", + SENTINEL, + ) { + panic!( + "bytes reachable through a nested generic instantiation must be rejected (probe failed): {reason}" + ); + } +} + +#[test] +fn json_encode_rejects_shadowed_generic_param_with_unsupported_field() { + // The struct named `T` occupies the same name space as a generic + // parameter named `T`: the raw render of `Node` is ambiguous + // between the struct instantiation `Node` and a generic + // instantiation `Node`. The resolved-identity cycle key must + // keep the two readings distinct and still terminate the + // named-wrapped recursion (`Node>` re-enters the same + // collapsed identity), while the bytes reachable through `tagged: T` + // (the struct - the parameter here is named `X`) are rejected at + // compile time. Note that when a same-named parameter is actually in + // scope, the parameter wins, so a struct name colliding with a live + // parameter is unreachable inside that generic's body; this test + // keeps the parameter under a different name to exercise the + // name-space collision itself. This is a regression guard for the + // current behavior, not a claim that the case previously failed. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_REJECT_SHADOWED_PARAM"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_REJECT_SHADOWED_PARAM"; + run_json_probe_child(PROBE, SENTINEL, || { + match compile_source( + r#" + use json; + struct T { tag: bytes } + struct Node { child: Node>, tagged: T } + fn enc(m: map>) { + json::encode(m); + } + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + if detail.contains("uses bytes") && detail.contains("tag") { + true + } else { + eprintln!("unexpected rejection detail: {detail}"); + false + } + } + Err(err) => { + eprintln!("shadowed generic param with bytes must be rejected, got: {err}"); + false + } + Ok(_) => { + eprintln!("shadowed generic param with bytes must be rejected at compile time"); + false + } + } + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_rejects_shadowed_generic_param_with_unsupported_field", + SENTINEL, + ) { + panic!( + "bytes reachable through a shadowed generic parameter must be rejected (probe failed): {reason}" + ); + } +} + +#[test] +fn json_probe_harness_rejects_child_without_matching_sentinel() { + // The harness must demand the test-specific sentinel in addition to + // `running 1 test`: any single test satisfies the latter, so a filter + // typo that matches a *different* probe test would otherwise report + // success while running the wrong closure. Spawn the acceptance probe + // but demand a sentinel it never prints; the child still compiles and + // runs fine, so the failure must come from the sentinel check. + let ran = spawn_json_probe( + "RUSTSCRIPT_JSON_PROBE_ACCEPT_RECURSIVE_MAP", + "compiler_rustscript_tests::json_encode_accepts_mutually_recursive_structs_inside_concrete_map", + "json-probe-entered:this-sentinel-is-never-printed", + ); + assert!( + ran.is_err(), + "probe without its test-specific sentinel must not be reported as success" + ); +} + +#[test] +fn json_encode_reports_unsupported_fields_in_deterministic_sorted_order() { + // `TypeSchema::Object` is a HashMap, so raw iteration order is + // per-process random. The compile-time `json::encode` walk must visit + // object fields in sorted name order: with two unsupported fields the + // rejection path must always name `a` first, never `z`, so error text + // (and therefore probe assertions) are stable across processes and + // runs instead of depending on the process hash seed. + match compile_source( + r#" + use json; + struct S { z: bytes, a: bytes } + fn enc(m: map) { + json::encode(m); + } + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + assert!(detail.contains("value.a uses bytes"), "{detail}"); + assert!(!detail.contains("value.z uses bytes"), "{detail}"); + } + Err(err) => panic!("unexpected compile error: {err}"), + Ok(_) => panic!("map with two bytes fields must be rejected at compile time"), + } +} + +#[test] +fn json_encode_accepts_wrapped_recursion_in_concrete_map() { + // Positive control for container-wrapped recursion. `Node` wraps + // the recursion in an array at every re-entry (`Node`, + // `Node<[int]>`, `Node<[[int]]>`, ...), so the resolved type + // arguments grow one wrapping per level and no cycle key ever + // repeats; only an explicit depth budget keeps the walk terminating. + // The type has no unsupported fields, so it must compile and the + // finite runtime value must encode. The optional base case + // (`child: Node<[T]>?` with `child: null`) is what makes a finite + // literal constructible: the declared-schema check only admits + // partial objects at re-entries whose identity repeats, and wrapped + // re-entries never repeat. An empty map exercises the non-optional + // wrap without needing any value. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_ACCEPT_WRAPPED_RECURSION"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_ACCEPT_WRAPPED_RECURSION"; + run_json_probe_child(PROBE, SENTINEL, || { + // Array wrap with an optional base case: a real value must encode. + // Null map entries are dropped when the literal is built, so the + // optional `child: null` disappears from the encoding and only + // `data` survives - the point is that the wrapped recursion + // compiles (the walk terminates on the budget) and the finite + // value encodes. + let compiled = match compile_source( + r#" + use json; + struct Node { child: Node<[T]>?, data: int } + let payload: map> = { "x": { child: null, data: 1 } }; + json::encode(payload); + "#, + ) { + Ok(compiled) => compiled, + Err(err) => { + eprintln!("wrapped array recursion must compile, got: {err}"); + return false; + } + }; + let mut vm = Vm::new(compiled.program); + let status = match vm.run() { + Ok(status) => status, + Err(err) => { + eprintln!("wrapped array recursion must run, got: {err}"); + return false; + } + }; + if status != VmStatus::Halted { + eprintln!("wrapped array recursion must halt, got: {status:?}"); + return false; + } + let [Value::String(text)] = vm.stack() else { + eprintln!("expected encoded json string, got {:?}", vm.stack()); + return false; + }; + let parsed = match serde_json::from_str::(text) { + Ok(parsed) => parsed, + Err(err) => { + eprintln!("encoded text must be valid json: {err}"); + return false; + } + }; + let expected = serde_json::json!({ "x": { "data": 1 } }); + if parsed != expected { + eprintln!("unexpected encoding: {parsed}"); + return false; + } + + // Same wrap with no optional base and an empty map value: the + // schema walk still has to terminate on the budget. + let compiled = match compile_source( + r#" + use json; + struct Node { child: Node<[T]> } + let payload: map> = {}; + json::encode(payload); + "#, + ) { + Ok(compiled) => compiled, + Err(err) => { + eprintln!("non-optional wrapped recursion must compile, got: {err}"); + return false; + } + }; + let mut vm = Vm::new(compiled.program); + let status = match vm.run() { + Ok(status) => status, + Err(err) => { + eprintln!("non-optional wrapped recursion must run, got: {err}"); + return false; + } + }; + if status != VmStatus::Halted { + eprintln!("non-optional wrapped recursion must halt, got: {status:?}"); + return false; + } + let [Value::String(text)] = vm.stack() else { + eprintln!("expected encoded json string, got {:?}", vm.stack()); + return false; + }; + let parsed = match serde_json::from_str::(text) { + Ok(parsed) => parsed, + Err(err) => { + eprintln!("encoded text must be valid json: {err}"); + return false; + } + }; + if parsed != serde_json::json!({}) { + eprintln!("unexpected encoding: {parsed}"); + return false; + } + true + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_accepts_wrapped_recursion_in_concrete_map", + SENTINEL, + ) { + panic!( + "container-wrapped recursion without unsupported fields must compile and encode (probe failed): {reason}" + ); + } +} + +#[test] +fn json_encode_rejects_wrapped_array_recursion_with_unsupported_sibling() { + // Negative control for container-wrapped recursion: the cycle edge is + // encodable and the depth budget must accept it, but the `tag: bytes` + // sibling is reachable at the very first level and must still be + // rejected at compile time. The budget must never mask the current + // layer's explicitly unsupported fields. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_REJECT_WRAPPED_ARRAY_RECURSION"; + const SENTINEL: &str = + "json-probe-entered:RUSTSCRIPT_JSON_PROBE_REJECT_WRAPPED_ARRAY_RECURSION"; + run_json_probe_child(PROBE, SENTINEL, || { + match compile_source( + r#" + use json; + struct Node { child: Node<[T]>, tag: bytes } + fn enc(m: map>) { + json::encode(m); + } + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + if detail.contains("uses bytes") && detail.contains("tag") { + true + } else { + eprintln!("unexpected rejection detail: {detail}"); + false + } + } + Err(err) => { + eprintln!("wrapped array recursion with bytes must be rejected, got: {err}"); + false + } + Ok(_) => { + eprintln!("wrapped array recursion with bytes must be rejected at compile time"); + false + } + } + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_rejects_wrapped_array_recursion_with_unsupported_sibling", + SENTINEL, + ) { + panic!( + "bytes reachable through array-wrapped recursion must be rejected at compile time (probe failed): {reason}" + ); + } +} + +#[test] +fn json_encode_rejects_wrapped_map_recursion_with_unsupported_sibling() { + // Map-container variant of the wrapped-recursion negative control: + // `Node>` wraps the recursion in a map at every re-entry, so + // the argument grows (`map`, `map>`, ...) and no cycle + // key repeats. The walk must terminate on the depth budget and still + // reject the `tag: bytes` sibling at the first level. + const PROBE: &str = "RUSTSCRIPT_JSON_PROBE_REJECT_WRAPPED_MAP_RECURSION"; + const SENTINEL: &str = "json-probe-entered:RUSTSCRIPT_JSON_PROBE_REJECT_WRAPPED_MAP_RECURSION"; + run_json_probe_child(PROBE, SENTINEL, || { + match compile_source( + r#" + use json; + struct Node { child: Node>, tag: bytes } + fn enc(m: map>) { + json::encode(m); + } + "#, + ) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + if detail.contains("uses bytes") && detail.contains("tag") { + true + } else { + eprintln!("unexpected rejection detail: {detail}"); + false + } + } + Err(err) => { + eprintln!("wrapped map recursion with bytes must be rejected, got: {err}"); + false + } + Ok(_) => { + eprintln!("wrapped map recursion with bytes must be rejected at compile time"); + false + } + } + }); + if let Err(reason) = spawn_json_probe( + PROBE, + "compiler_rustscript_tests::json_encode_rejects_wrapped_map_recursion_with_unsupported_sibling", + SENTINEL, + ) { + panic!( + "bytes reachable through map-wrapped recursion must be rejected at compile time (probe failed): {reason}" + ); + } +} + +/// Builds a pathological but non-recursive chain of `depth` distinct +/// struct declarations (`L0 { f: L1 }` -> `L1 { f: L2 }` -> ... -> +/// `L{depth-1}`), with `deepest` as the type of the single field of the +/// deepest struct. Every declaration name is unique, so the walk must +/// expand the chain in full: the resolution/validation budget only bounds +/// repeated re-entries of the *same* declaration identity (recursive +/// families like `Node<[T]>`), and distinct names must not consume any +/// shared budget. +fn distinct_named_chain_source(depth: usize, deepest: &str) -> String { + let mut source = String::from("use json;\n"); + for index in 0..depth - 1 { + source.push_str(&format!("struct L{index} {{ f: L{} }}\n", index + 1)); + } + source.push_str(&format!("struct L{} {{ f: {deepest} }}\n", depth - 1)); + source.push_str("fn enc(m: map) {\n json::encode(m);\n}\n"); + source +} + +/// The exact `json::encode` rejection path for the deepest field of a +/// `depth`-layer distinct chain: `value` plus one `f` segment per layer. +fn deep_chain_path(depth: usize) -> String { + format!("value.{}", "f.".repeat(depth - 1) + "f") +} + +#[test] +fn json_encode_rejects_deep_distinct_named_chain_with_deepest_bytes() { + // 40 distinct struct declarations chained by a single `f` field, with + // `bytes` reachable only at the deepest level. The chain must expand + // in full and the deepest `bytes` must be rejected at compile time + // with the precise 40-segment path. + let source = distinct_named_chain_source(40, "bytes"); + match compile_source(&source) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + let path = deep_chain_path(40); + assert!(detail.contains(&format!("{path} uses bytes")), "{detail}"); + } + Err(err) => { + panic!("deep distinct chain with deepest bytes must be rejected, got: {err}"); + } + Ok(_) => { + panic!("deep distinct chain with deepest bytes must be rejected at compile time"); + } + } +} + +#[test] +fn json_encode_rejects_deep_distinct_named_chain_with_deepest_callable() { + // Callable counterpart of the deep distinct-chain regression: the + // deepest field is a `fn(int) -> int` callable, which `json::encode` + // must reject at compile time with the precise 40-segment path. + let source = distinct_named_chain_source(40, "fn(int) -> int"); + match compile_source(&source) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + let path = deep_chain_path(40); + assert!(detail.contains(&format!("{path} is callable")), "{detail}"); + } + Err(err) => { + panic!("deep distinct chain with deepest callable must be rejected, got: {err}"); + } + Ok(_) => { + panic!("deep distinct chain with deepest callable must be rejected at compile time"); + } + } +} + +#[test] +fn json_encode_accepts_deep_distinct_named_chain_of_encodable_fields() { + // Positive control for the same 40-declaration chain: with an + // encodable leaf (`int`) the walk must expand the chain in full and + // accept it, proving the re-entry budget never trips on distinct + // declaration names. + let source = distinct_named_chain_source(40, "int"); + compile_source(&source).expect("deep distinct chain of encodable fields must compile"); +} + +#[test] +fn json_encode_rejects_very_deep_distinct_named_chain_with_deepest_bytes() { + // The regression this fix targets: a chain of 1100 distinct struct + // declarations, well past the old global named-depth budget. The + // global budget stopped the walk at 32 nested expansions and accepted + // the chain as a structural recursion edge, so `json::encode` + // compiled even though the deepest field is `bytes` - the compile-time + // diagnostic was masked. The budget must only bound repeated + // re-entries of the *same* declaration, so this chain expands in full + // and the deepest `bytes` is rejected with the precise 1100-segment + // path. + let source = distinct_named_chain_source(1100, "bytes"); + match compile_source(&source) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + let path = deep_chain_path(1100); + assert!(detail.contains(&format!("{path} uses bytes")), "{detail}"); + } + Err(err) => { + panic!("very deep distinct chain with deepest bytes must be rejected, got: {err}"); + } + Ok(_) => { + panic!("very deep distinct chain with deepest bytes must be rejected at compile time"); + } + } +} + +#[test] +fn json_encode_rejects_very_deep_distinct_named_chain_with_deepest_callable() { + // Callable counterpart at the same depth: the deepest field is a + // `fn(int) -> int` callable, which the global named-depth budget used + // to mask exactly like the bytes variant. + let source = distinct_named_chain_source(1100, "fn(int) -> int"); + match compile_source(&source) { + Err(vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { + detail, + .. + })) => { + let path = deep_chain_path(1100); + assert!(detail.contains(&format!("{path} is callable")), "{detail}"); + } + Err(err) => { + panic!("very deep distinct chain with deepest callable must be rejected, got: {err}"); + } + Ok(_) => { + panic!( + "very deep distinct chain with deepest callable must be rejected at compile time" + ); + } + } +} 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..0826684e 100644 --- a/tests/compiler/module_import_tests.rs +++ b/tests/compiler/module_import_tests.rs @@ -911,3 +911,1626 @@ 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); +} + +/// Compile an in-memory root source with inline module overrides and return +/// the compiled program. Module overrides map module paths (relative to the +/// root module directory) to source text. +fn compile_with_module_overrides( + root_source: &str, + overrides: &[(&str, &str)], +) -> vm::CompiledProgram { + let mut options = CompileSourceFileOptions::new(); + for (path, source) in overrides { + options = options.with_module_override_source(*path, *source); + } + vm::compile_source_with_flavor_and_options(root_source, SourceFlavor::RustScript, options) + .expect("root source with module overrides should compile") +} + +/// Assert that EVERY callable prototype whose schema parameters equal +/// `params` declares the same callable schema with result +/// `expected_result`, and that at least one such prototype exists. Returns +/// the number of matching prototypes so callers can pin the expected count. +/// +/// The assertion deliberately covers all matches instead of picking the +/// first one: a merged module graph can contain several functions with +/// identical parameter schemas (the `call`/`dispatch` fixtures are both +/// `(map, map)`), and a first-match lookup would silently validate only +/// one of them. Script prototypes carry no source name, so the strongest +/// available contract is schema identity across every prototype that +/// shares the same declared parameters. +fn assert_all_prototypes_with_params( + program: &vm::Program, + params: &[vm::compiler::TypeSchema], + expected_result: &vm::compiler::TypeSchema, +) -> usize { + let matches = program + .callable_prototypes + .iter() + .filter(|prototype| { + matches!( + prototype.schema.as_ref(), + Some(vm::compiler::TypeSchema::Callable { params: candidate, .. }) + if candidate == params + ) + }) + .collect::>(); + assert!( + !matches.is_empty(), + "no callable prototype carries schema params {params:?}" + ); + for prototype in &matches { + match prototype.schema.as_ref() { + Some(vm::compiler::TypeSchema::Callable { result, .. }) => { + assert_eq!( + result.as_ref(), + expected_result, + "every prototype with schema params {params:?} must declare the same result" + ); + } + other => panic!("unexpected non-callable schema on prototype: {other:?}"), + } + } + matches.len() +} + +fn assert_result_map_kind(vm: &Vm, expected_kind: &str) { + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("kind")), + Some(&Value::string(expected_kind)), + "result map must carry kind {expected_kind:?}" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +fn assert_result_map_has_kind(vm: &Vm) { + match vm.stack().last() { + Some(Value::Map(map)) => { + assert!( + map.get(&Value::string("kind")).is_some(), + "result map must carry a kind key, got {map:?}" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1: a cross-module accessor returning an array, passed into a local +/// script function with a declared `fn(string, array) -> string` schema, +/// must keep the callee's prototype schema and execute. +/// +/// Ports `root_splice.rss` + `chain_m1.rss` from the A3 provider repro set. +#[test] +fn module_callable_schema_preserves_cross_module_array_argument() { + let root_source = r#" + use self::chain_m1 as types; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + let tools: array = types::request_array(request, "tools"); + let body: string = splice("{ }", tools); + { kind: "ok", body: body } + } + + fn splice(body: string, tools: array) -> string { + body + } + + let result: map = run({ + request: { + tools: [ + { name: "read_file", description: "read", schema_json: "{}" } + ] + } + }); + result; + "#; + let chain_m1 = r#" + pub fn request_array(request: map, key: string) -> array { + let mut items: array = []; + if request.has(key) { + if type(request[key]) == "array" { + let coerced: array = request[key]; + items = coerced; + } + } + items + } + "#; + let compiled = compile_with_module_overrides(root_source, &[("chain_m1.rss", chain_m1)]); + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::Array(Box::new(vm::compiler::TypeSchema::Unknown)), + ], + &vm::compiler::TypeSchema::String, + ), + 1, + "only splice declares (string, array) and it must keep its string result" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm + .run() + .expect("cross-module array argument call should run"); + assert_eq!(status, VmStatus::Halted); + assert_result_map_kind(&vm, "ok"); +} + +/// B1 control: the same call with a literal array argument must pass. +/// Ports `root_splice2.rss`. +#[test] +fn module_callable_schema_literal_array_control() { + let root_source = r#" + pub fn run(context: map) -> map { + let body: string = splice("{ }", []); + { kind: "ok", body: body } + } + + fn splice(body: string, tools: array) -> string { + body + } + + let result: map = run({}); + result; + "#; + let compiled = compile_source(root_source).expect("literal array control should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("literal array control should run"); + assert_eq!(status, VmStatus::Halted); + assert_result_map_kind(&vm, "ok"); +} + +/// B1: a two-map module function that string-reads its FIRST map parameter +/// and passes the second onward must keep every declared parameter in source +/// order and execute. Ports the `hop4` behavior (`hop4_root.rss` + +/// `hop4_m2.rss` + `chain_m1.rss`). +#[test] +fn module_callable_schema_preserves_first_map_parameter() { + let root_source = r#" + use self::hop4_m2 as adapter; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + let profile: map = context["profile"]; + adapter::call(request, profile) + } + + let result: map = run({ + request: { model: "m" }, + profile: { base_url: "http://127.0.0.1:1", api_key: "k", provider: "p" } + }); + result; + "#; + let hop4_m2 = r#" + use self::chain_m1 as types; + + pub fn call(request: map, profile: map) -> map { + let stream: bool = false; + if stream => { + { kind: "stream" } + } else => { + dispatch(profile, request) + } + } + + fn dispatch(profile: map, request: map) -> map { + let base_url: string = types::request_string(profile, "base_url"); + let api_key: string = types::request_string(profile, "api_key"); + let provider: string = types::request_string(profile, "provider"); + complete(request, base_url, api_key, provider) + } + + fn complete(request: map, base_url: string, api_key: string, provider: string) -> map { + let model: string = types::request_string(request, "model"); + let body_text: string = local_helper(request, model, false); + if model == "" => { + { kind: "missing" } + } else => { + { kind: "ok", body: body_text, url: base_url, provider: provider } + } + } + + fn local_helper(request: map, model: string, stream: bool) -> string { + "stub" + } + "#; + let chain_m1 = r#" + pub fn request_string(request: map, key: string) -> string { + let mut text: string = ""; + if request.has(key) { + if type(request[key]) == "string" { + let coerced: string = request[key]; + text = coerced; + } + } + text + } + "#; + let compiled = compile_with_module_overrides( + root_source, + &[("hop4_m2.rss", hop4_m2), ("chain_m1.rss", chain_m1)], + ); + // All declared map parameters stay in source order: `dispatch` is + // (map, map), `complete` is (map, string, string, string). The + // (map, map) parameter list is shared by `call` and `dispatch`, so the + // assertion must cover every matching prototype instead of picking the + // first one — both must keep their `(map, map) -> map` schema. + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ], + &vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ), + 2, + "call and dispatch both declare (map, map) -> map and keep their schemas" + ); + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::String, + ], + &vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ), + 1, + "complete must keep its (map, string, string, string) -> map schema" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm + .run() + .expect("first-map-parameter module graph should run"); + assert_eq!(status, VmStatus::Halted); + assert_result_map_kind(&vm, "ok"); +} + +/// B1 control: the same two-map layout reading the SECOND map parameter +/// passes. Ports the `hop13` behavior (`hop13_root.rss` + `hop13_m2.rss` + +/// `chain_m1.rss`). +#[test] +fn module_callable_schema_second_parameter_control() { + let root_source = r#" + use self::hop13_m2 as adapter; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + let profile: map = context["profile"]; + adapter::call(request, profile) + } + + let result: map = run({ + request: { model: "m" }, + profile: { base_url: "http://127.0.0.1:1", api_key: "k", provider: "p" } + }); + result; + "#; + let hop13_m2 = r#" + use self::chain_m1 as types; + + pub fn call(request: map, profile: map) -> map { + let stream: bool = false; + if stream => { + { kind: "stream" } + } else => { + dispatch(profile, request) + } + } + + fn dispatch(profile: map, request: map) -> map { + let model: string = types::request_string(request, "model"); + complete(profile, "u", "k", "p") + } + + fn complete(request: map, base_url: string, api_key: string, provider: string) -> map { + let model: string = types::request_string(request, "model"); + let result: map = if model == "" => { + { kind: "missing" } + } else => { + { kind: "ok", url: base_url, key: api_key, provider: provider } + }; + result + } + "#; + let chain_m1 = r#" + pub fn request_string(request: map, key: string) -> string { + let mut text: string = ""; + if request.has(key) { + if type(request[key]) == "string" { + let coerced: string = request[key]; + text = coerced; + } + } + text + } + "#; + let compiled = compile_with_module_overrides( + root_source, + &[("hop13_m2.rss", hop13_m2), ("chain_m1.rss", chain_m1)], + ); + // Same merged-graph schema contract as the first-map fixture: every + // (map, map) prototype — `call` and `dispatch` — must keep its + // `(map, map) -> map` schema, and `complete` its four-parameter one. + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ], + &vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ), + 2, + "call and dispatch both declare (map, map) -> map and keep their schemas" + ); + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::String, + ], + &vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + ), + 1, + "complete must keep its (map, string, string, string) -> map schema" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("second-map-parameter control should run"); + assert_eq!(status, VmStatus::Halted); + // The fixture's `complete(profile, ...)` reads `model` from the profile + // map (absent), so the semantic result is `missing`; the control's + // contract is that the merged call graph executes without a callable + // schema mismatch. + assert_result_map_has_kind(&vm); +} + +/// B1: VMBC round-trip must preserve every script prototype's callable +/// schema for a merged module graph, and the decoded program must execute. +#[test] +fn callable_schema_survives_vmbc_round_trip_for_merged_modules() { + let root_source = r#" + use self::chain_m1 as types; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + let tools: array = types::request_array(request, "tools"); + let body: string = splice("{ }", tools); + { kind: "ok", body: body } + } + + fn splice(body: string, tools: array) -> string { + body + } + + let result: map = run({ + request: { + tools: [ + { name: "read_file", description: "read", schema_json: "{}" } + ] + } + }); + result; + "#; + let chain_m1 = r#" + pub fn request_array(request: map, key: string) -> array { + let mut items: array = []; + if request.has(key) { + if type(request[key]) == "array" { + let coerced: array = request[key]; + items = coerced; + } + } + items + } + "#; + let compiled = compile_with_module_overrides(root_source, &[("chain_m1.rss", chain_m1)]); + let encoded = vm::encode_program(&compiled.program).expect("merged program should encode"); + let decoded = vm::decode_program(&encoded).expect("merged program should decode"); + assert_eq!( + decoded.callable_prototypes.len(), + compiled.program.callable_prototypes.len(), + "round trip must preserve the prototype count" + ); + for (before, after) in compiled + .program + .callable_prototypes + .iter() + .zip(&decoded.callable_prototypes) + { + assert_eq!( + before.schema, after.schema, + "round trip must preserve prototype schemas" + ); + } + vm::validate_program(&decoded, 0).expect("decoded merged program should validate"); + + let mut vm = Vm::new(decoded); + let status = vm.run().expect("decoded merged program should run"); + assert_eq!(status, VmStatus::Halted); + assert_result_map_kind(&vm, "ok"); +} + +/// B1: a merged module graph must still enforce the callee's callable +/// schema at runtime. The module accessor `request_value` declares no +/// return schema, so the root binding `tools: array` accepts the module's +/// map value statically; the actual runtime value is a map, and the +/// `splice(string, array)` call must fail with the precise +/// `TypeMismatch("callable argument schema")` error instead of passing +/// silently or corrupting operand placement. +#[test] +fn merged_module_graph_wrong_argument_reports_callable_argument_schema_mismatch() { + let root_source = r#" + use self::chain_m1 as types; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + let tools: array = types::request_value(request, "tools"); + let body: string = splice("{ }", tools); + { kind: "ok", body: body } + } + + fn splice(body: string, tools: array) -> string { + body + } + + let result: map = run({ + request: { + tools: { name: "read_file", description: "read", schema_json: "{}" } + } + }); + result; + "#; + let chain_m1 = r#" + pub fn request_value(request: map, key: string) { + request[key] + } + "#; + let compiled = compile_with_module_overrides(root_source, &[("chain_m1.rss", chain_m1)]); + // The merged graph still carries splice's (string, array) -> string schema. + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::String, + vm::compiler::TypeSchema::Array(Box::new(vm::compiler::TypeSchema::Unknown)), + ], + &vm::compiler::TypeSchema::String, + ), + 1, + "splice must keep its (string, array) -> string schema in the merged graph" + ); + let mut vm = Vm::new(compiled.program); + assert!(matches!( + vm.run(), + Err(vm::VmError::TypeMismatch("callable argument schema")) + )); +} + +/// B1: the liveness allocator only compacts once the merged program's +/// local count exceeds `LOCAL_SLOT_ALLOCATOR_COMPAT_THRESHOLD` (8). This +/// fixture proves the frame layout really sits beyond that threshold: +/// `wide` declares ten parameters, and because every parameter stays live +/// for the whole body, compaction must keep ten distinct physical slots +/// (parameter_slots pairwise distinct, compacted frame above 8) and the +/// call site must place each operand in its own slot. The tenth parameter +/// `j` is never used by the body — exactly the dead-parameter shape that +/// used to let the colorer alias two parameters onto one physical slot and +/// corrupt operand placement at the call site. +#[test] +fn wide_frame_exceeds_liveness_compaction_threshold() { + let root_source = r#" + pub fn run(context: map) -> map { + let text: string = wide( + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j" + ); + { kind: "ok", text: text } + } + + fn wide( + a: string, b: string, c: string, d: string, + e: string, f: string, g: string, h: string, + i: string, j: string + ) -> string { + let s1: string = a; + let s2: string = b; + let s3: string = c; + let s4: string = d; + let s5: string = e; + let s6: string = f; + let s7: string = g; + let s8: string = h; + let s9: string = i; + s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9 + } + + let result: map = run({}); + result; + "#; + let compiled = compile_source(root_source).expect("wide-frame fixture should compile"); + let string_params = + std::iter::repeat_n(vm::compiler::TypeSchema::String, 10).collect::>(); + let wide_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| { + matches!( + prototype.schema.as_ref(), + Some(vm::compiler::TypeSchema::Callable { params: candidate, .. }) + if *candidate == string_params + ) + }) + .collect::>(); + assert_eq!( + wide_prototypes.len(), + 1, + "exactly one prototype declares ten string parameters" + ); + let wide = wide_prototypes[0]; + let distinct_param_slots = wide + .parameter_slots + .iter() + .copied() + .collect::>(); + assert_eq!( + distinct_param_slots.len(), + 10, + "every parameter must keep a distinct physical slot, got {:?}", + wide.parameter_slots + ); + assert!( + compiled.program.local_count > 8, + "the compacted frame ({}) must exceed the liveness compaction threshold of 8", + compiled.program.local_count + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("wide-frame call should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!(map.get(&Value::string("kind")), Some(&Value::string("ok"))); + assert_eq!( + map.get(&Value::string("text")), + Some(&Value::string("abcdefghi")), + "operand placement must survive compaction: j is unused, a..i must keep their values" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 A/B contract: a root-module function and an imported-module function +/// with identical signatures must carry identical callable schemas in the +/// merged program, and both call sites must execute. The root `root_ident` +/// and the module `ident` both declare `(map, string) -> string`; the +/// merged graph must contain both prototypes with that schema (the root +/// one and the non-root one), each keeping its declared result. +#[test] +fn root_and_module_functions_share_schema_ab_contract() { + let root_source = r#" + use self::chain_m1 as types; + + pub fn run(context: map) -> map { + let local: string = root_ident(context, "local"); + let remote: string = types::ident(context, "remote"); + { kind: "ok", local: local, remote: remote } + } + + fn root_ident(context: map, key: string) -> string { + let text: string = context[key]; + text + } + + let result: map = run({ + local: "L", + remote: "R" + }); + result; + "#; + let chain_m1 = r#" + pub fn ident(context: map, key: string) -> string { + let text: string = context[key]; + text + } + "#; + let compiled = compile_with_module_overrides(root_source, &[("chain_m1.rss", chain_m1)]); + // Both the root `ident` and the module `ident` share the same + // (map, string) -> string schema; both must be present and identical. + assert_eq!( + assert_all_prototypes_with_params( + &compiled.program, + &[ + vm::compiler::TypeSchema::Map(Box::new(vm::compiler::TypeSchema::Unknown)), + vm::compiler::TypeSchema::String, + ], + &vm::compiler::TypeSchema::String, + ), + 2, + "root and module ident must both keep their (map, string) -> string schema" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("root and module ident calls should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!(map.get(&Value::string("local")), Some(&Value::string("L"))); + assert_eq!(map.get(&Value::string("remote")), Some(&Value::string("R"))); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up: a local defined after body entry must never be colored +/// onto a parameter slot. The five-parameter caller below uses every +/// parameter, defines body locals (`body`, `status`, `tag`, `result`) +/// after entry, and dispatches through a statement-if to the imported +/// parse helper (sibling dispatch between the two imported modules). At +/// `d8cf291` this shape fails the VM callable-schema check with +/// `TypeMismatch("string")` (`type mismatch: expected string`) because a +/// body-defined local aliases a parameter slot, so the callee frame reads +/// the wrong slot while evaluating call arguments even though every value +/// is correctly typed. +/// +/// Minimal cross-module repro: root -> adapter (five-parameter caller) -> +/// parse (schema-typed helper). The two-parameter control variant passes +/// at the same revision, isolating the corruption to the caller's +/// parameter-slot layout. The parse module carries no json/bytes/loop +/// machinery and the dispatch if has no else branch; only the minimal +/// strict-typing accessors remain. +#[test] +fn body_defined_local_never_aliases_parameter_slot() { + let root_source = r#" + use self::param_aliasing_m2 as adapter; + + pub fn run(context: map) -> map { + let request: map = context["request"]; + adapter::chat_send_complete(request, "m", "http://127.0.0.1:1", "k", "p") + } + + let result: map = run({ + request: { model: "m" } + }); + result; + "#; + let m2 = r#" + use self::param_aliasing_parse as parse; + + pub fn chat_send_complete( + request: map, + model: string, + base_url: string, + api_key: string, + provider: string + ) -> map { + let body: map = { + choices: [ + { message: { role: "assistant", content: "hi" } } + ], + usage: { total_tokens: 27 } + }; + let status: int = 200; + let tag: string = model + base_url + api_key; + let mut result: map = {}; + if status >= 200 && status < 300 { + result = parse::parse_body(body, status, provider); + } + result + } + "#; + let parse = r#" + pub fn parse_body(body: map, status: int, provider: string) -> map { + let choices: array = request_array(body, "choices"); + let first: map = array_entry(choices, 0); + let message: map = request_map(first, "message"); + let content: string = request_string(message, "content"); + { ok: true, response: { text: content, provider: provider }, error: {} } + } + + pub fn request_array(request: map, key: string) -> array { + let mut items: array = []; + if request.has(key) { + if type(request[key]) == "array" { + let coerced: array = request[key]; + items = coerced; + } + } + items + } + + pub fn request_string(request: map, key: string) -> string { + let mut text: string = ""; + if request.has(key) { + if type(request[key]) == "string" { + let coerced: string = request[key]; + text = coerced; + } + } + text + } + + pub fn request_map(request: map, key: string) -> map { + let mut items: map = {}; + if request.has(key) { + if type(request[key]) == "map" { + let coerced: map = request[key]; + items = coerced; + } + } + items + } + + pub fn array_entry(items: array, index: int) -> map { + let mut result: map = {}; + if items.has(index) { + if type(items[index].copy()) == "map" { + let coerced: map = items[index].copy(); + result = coerced; + } + } + result + } + "#; + let compiled = compile_with_module_overrides( + root_source, + &[ + ("param_aliasing_m2.rss", m2), + ("param_aliasing_parse.rss", parse), + ], + ); + // The five-parameter caller keeps one distinct physical slot per + // parameter, and every body-defined local (`body`, `status`, `tag`, + // `result`) must land on a slot that no parameter uses: the callee + // frame reads parameter slots while evaluating the parse call's + // arguments, so a body local sharing a parameter slot corrupts the + // operand placement even though every value is correctly typed. + let string_params = + std::iter::repeat_n(vm::compiler::TypeSchema::String, 4).collect::>(); + let caller_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| { + matches!( + prototype.schema.as_ref(), + Some(vm::compiler::TypeSchema::Callable { params: candidate, .. }) + if candidate.len() == 5 + && candidate[1..] == string_params[..] + ) + }) + .collect::>(); + assert_eq!( + caller_prototypes.len(), + 1, + "exactly one prototype declares the five-parameter caller shape" + ); + let param_slots = caller_prototypes[0].parameter_slots.clone(); + let distinct_param_slots = param_slots + .iter() + .copied() + .collect::>(); + assert_eq!( + distinct_param_slots.len(), + 5, + "every parameter must keep a distinct physical slot, got {:?}", + param_slots + ); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + // Imported-module locals carry module-qualified names in debug info + // (e.g. `..._param_aliasing_m2_rss__m1::body`); look up the + // five-parameter caller's body locals by their qualified suffix. + for local in ["body", "status", "tag", "result"] { + let slot = debug + .locals + .iter() + .find(|info| { + info.name.contains("param_aliasing_m2") + && info.name.ends_with(&format!("::{local}")) + }) + .unwrap_or_else(|| panic!("{local} should be in debug info")) + .index as u16; + assert!( + !param_slots.contains(&slot), + "body-defined local {local} must not share its final slot with a parameter: params {param_slots:?}, {local} at {slot}" + ); + } + + let mut vm = Vm::new(compiled.program); + let status = vm + .run() + .expect("five-parameter caller with body-defined locals must run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("ok")), + Some(&Value::Bool(true)), + "the success path must return ok(...)" + ); + match map.get(&Value::string("response")) { + Some(Value::Map(response)) => { + assert_eq!( + response.get(&Value::string("text")), + Some(&Value::string("hi")), + "the parsed response text must survive parameter-slot coloring" + ); + assert_eq!( + response.get(&Value::string("provider")), + Some(&Value::string("p")), + "the fifth parameter must survive parameter-slot coloring" + ); + } + other => panic!("expected a response map, got {other:?}"), + } + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up smoke guard: parameter interference must be scoped to +/// parameter slots, not a global freeze of slot coloring. `mixed` declares +/// six parameters (each live for the whole body, so each needs its own +/// physical slot) and six body locals whose live ranges overlap at most +/// two deep (`s1` dies when `s2` is defined, and so on). The allocator must +/// still compact the locals onto a shared pair of slots, keeping the +/// compacted frame strictly below the twelve slots `mixed` alone declares. +/// +/// Smoke only: on the base compiler (no full-body parameter rule) the +/// locals compact at least as well, so this fixture cannot be RED there — +/// it guards against future over-conservatism (an all-interfere coloring or +/// a disabled allocator) rather than pinning a base defect. +#[test] +fn parameter_interference_preserves_local_slot_compaction_smoke() { + let source = r#" + pub fn run(context: map) -> map { + let text: string = mixed("a", "b", "c", "d", "e", "f"); + { kind: "ok", text: text } + } + + fn mixed( + a: string, b: string, c: string, + d: string, e: string, f: string + ) -> string { + let s1: string = a + "1"; + let s2: string = s1 + b; + let s3: string = s2 + c; + let s4: string = s3 + d; + let s5: string = s4 + e; + let s6: string = s5 + f; + s6 + } + + let result: map = run({}); + result; + "#; + let compiled = compile_source(source).expect("boundary fixture should compile"); + let string_params = + std::iter::repeat_n(vm::compiler::TypeSchema::String, 6).collect::>(); + let mixed_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| { + matches!( + prototype.schema.as_ref(), + Some(vm::compiler::TypeSchema::Callable { params: candidate, .. }) + if *candidate == string_params + ) + }) + .collect::>(); + assert_eq!( + mixed_prototypes.len(), + 1, + "exactly one prototype declares six string parameters" + ); + let distinct_param_slots = mixed_prototypes[0] + .parameter_slots + .iter() + .copied() + .collect::>(); + assert_eq!( + distinct_param_slots.len(), + 6, + "every parameter must keep a distinct physical slot, got {:?}", + mixed_prototypes[0].parameter_slots + ); + // `mixed` alone declares twelve pre-compaction slots (six parameters + + // six locals). Locals with two-deep overlap must share physical slots, + // so the compacted program stays well below twelve; an all-interfere + // coloring or a disabled allocator would exceed it. + assert!( + compiled.program.local_count < 12, + "non-parameter locals must still be compacted: compacted frame {} must stay below the twelve slots mixed declares", + compiled.program.local_count + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("boundary fixture should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!(map.get(&Value::string("kind")), Some(&Value::string("ok"))); + assert_eq!( + map.get(&Value::string("text")), + Some(&Value::string("a1bcdef")), + "chained local values must survive compaction: {:?}", + map + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up: closure parameters must stay live for the whole closure +/// body, exactly like named-function parameters. The closure below declares +/// one parameter (`x`), defines a body local (`local`) before reading the +/// parameter, and returns the concatenation. If the body local were colored +/// onto the parameter's physical slot, invoking the closure would read the +/// local's value instead of the argument, so the returned string would be +/// wrong. +/// +/// The closure is deliberately never invoked from the script: source-level +/// closure invocation lowers to a dynamic `LocalCall`, whose conservative +/// liveness fill (keep every slot live across a dynamic call) masks the +/// aliasing defect on the pre-fix compiler by making every slot interfere +/// with every other slot. `run` takes no parameters and defines its own +/// locals only *after* the closure, so nothing is live at the closure's +/// definition site on the pre-fix compiler: the closure's parameter and its +/// body local receive no interference edges at all and are colored onto the +/// same physical slot. The slot-level assertion pins the compile-time +/// invariant directly: the closure's parameter slot and its body local's +/// final slot stay distinct. +#[test] +fn closure_parameter_stays_live_for_whole_closure_body() { + let source = r#" + pub fn run() -> map { + let f = |x| if true => { + let local: string = "zz"; + local + x + } else => { + "?" + }; + let a: string = "a"; + let b: string = a + "b"; + let c: string = b + "c"; + let d: string = c + "d"; + let out: string = d; + { ok: out } + } + let result: map = run(); + result; + "#; + let compiled = compile_source(source).expect("closure fixture should compile"); + let closure_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.kind == vm::CallableKind::Closure) + .collect::>(); + assert_eq!( + closure_prototypes.len(), + 1, + "exactly one closure prototype should be emitted" + ); + let param_slots = closure_prototypes[0].parameter_slots.clone(); + assert_eq!(param_slots.len(), 1); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let local_slot = debug + .locals + .iter() + .find(|info| info.name == "local") + .expect("body local should be in debug info") + .index as u16; + assert_ne!( + param_slots[0], local_slot, + "the closure body local must not share the parameter's physical slot: param {param_slots:?}, local at {local_slot}" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("closure fixture should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("ok")), + Some(&Value::string("abcd")), + "the enclosing frame must stay correct beside the closure" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up: nested closures keep their parameter protection scoped to +/// their own bodies. The outer closure (`f`, parameter `x`) creates the +/// inner closure (`g`, parameters `y`, `z`) inside its body; `g`'s body +/// writes its own local before reading its parameters. The inner closure's +/// parameters must stay distinct from its own body local, and the outer +/// closure's protection must never leak into the inner closure's frame (and +/// vice versa). +/// +/// Neither closure is invoked from the script (see +/// `closure_parameter_stays_live_for_whole_closure_body` for why +/// source-level invocation would mask the aliasing defect on the pre-fix +/// compiler). The inner closure captures nothing and the outer body's tail +/// is a constant, so on the pre-fix compiler nothing is live at the inner +/// closure's definition site: `g`'s parameters and `inner_local` receive no +/// interference edges and are colored onto the same physical slots. The +/// slot-level assertion pins the invariant directly: each of `g`'s +/// parameter slots stays distinct from `inner_local`'s final slot. +#[test] +fn nested_closure_parameters_stay_scoped_to_own_bodies() { + let source = r#" + pub fn run() -> map { + let f = |x| if true => { + let outer_local: string = "O"; + let g = |y, z| if true => { + let inner_local: string = "I"; + inner_local + y + z + } else => { + "?" + }; + "done" + } else => { + "?" + }; + let a: string = "a"; + let b: string = a + "b"; + let c: string = b + "c"; + let d: string = c + "d"; + let out: string = d; + { ok: out } + } + let result: map = run(); + result; + "#; + let compiled = compile_source(source).expect("nested closure fixture should compile"); + let closure_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.kind == vm::CallableKind::Closure) + .collect::>(); + assert_eq!( + closure_prototypes.len(), + 2, + "exactly two closure prototypes should be emitted" + ); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + // The inner closure declares two parameters; the outer closure declares + // one. Assert the inner closure's parameters stay distinct from its body + // local. + let inner = closure_prototypes + .iter() + .find(|prototype| prototype.parameter_slots.len() == 2) + .expect("inner closure should declare two parameters"); + let inner_param_slots = inner.parameter_slots.clone(); + let inner_local_slot = debug + .locals + .iter() + .find(|info| info.name == "inner_local") + .expect("inner_local should be in debug info") + .index as u16; + assert!( + !inner_param_slots.contains(&inner_local_slot), + "the inner closure body local must not share a parameter's physical slot: params {inner_param_slots:?}, inner_local at {inner_local_slot}" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("nested closure fixture should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("ok")), + Some(&Value::string("abcd")), + "the enclosing frame must stay correct beside nested closures" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up smoke guard: the full-body parameter rule must survive +/// `Assign` statements that target a parameter slot. `mixed` defines a body +/// local before reassigning its parameter, and reads the local afterwards. +/// The allocator keeps parameter slots live for the whole body as a +/// conservative safety rule for caller-written frame-entry state, so the +/// local and the parameter stay distinct and the result survives. +/// +/// Smoke only: on the base compiler the reassignment's def-edge already +/// separates the parameter from anything live after the assignment, so this +/// fixture cannot be RED there — it guards the full-body rule against future +/// regressions rather than pinning a base defect. +#[test] +fn assign_to_parameter_keeps_full_body_interference_smoke() { + let source = r#" + fn mixed(a: string) -> string { + let c: string = "x"; + a = "fixed"; + c + "!" + } + pub fn run(context: map) -> map { + let out: string = mixed("orig"); + let tag: string = "t"; + let t2: string = tag + "!"; + let u1: string = t2 + "u"; + let u2: string = u1 + "v"; + { ok: out, t: u2 } + } + let result: map = run({}); + result; + "#; + let compiled = compile_source(source).expect("assign-to-param fixture should compile"); + // The (string) -> string prototype is uniquely `mixed` (run takes a map). + let string_params = + std::iter::repeat_n(vm::compiler::TypeSchema::String, 1).collect::>(); + let mixed_prototypes = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| { + matches!( + prototype.schema.as_ref(), + Some(vm::compiler::TypeSchema::Callable { params: candidate, .. }) + if *candidate == string_params + ) + }) + .collect::>(); + assert_eq!( + mixed_prototypes.len(), + 1, + "exactly one prototype declares the single-string-parameter shape" + ); + let param_slots = mixed_prototypes[0].parameter_slots.clone(); + assert_eq!(param_slots.len(), 1); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let local_slot = debug + .locals + .iter() + .find(|info| info.name == "c") + .expect("body local c should be in debug info") + .index as u16; + assert_ne!( + param_slots[0], local_slot, + "the body local must not share the parameter's physical slot even after an assign-to-param: param {param_slots:?}, c at {local_slot}" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("assign-to-param fixture should run"); + assert_eq!(status, VmStatus::Halted); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("ok")), + Some(&Value::string("x!")), + "the pre-assign local must survive the parameter reassignment" + ); + assert_eq!( + map.get(&Value::string("t")), + Some(&Value::string("t!uv")), + "the enclosing frame must stay correct beside the assign-to-param" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// B1 follow-up boundary: parameter-heavy frames near the 256-slot limit +/// must fail with the existing typed compile error when coloring cannot +/// proceed — never a panic and never a miscompiled program. 250 +/// parameters (each kept live for the whole body by the full-body +/// parameter rule) plus seven simultaneously live body locals exceed the +/// 256 physical slots the allocator can color, so compilation reports the +/// typed "too many simultaneously live locals" error. +#[test] +fn parameter_heavy_frame_near_boundary_returns_typed_error() { + let param_count = 250; + let local_count = 7; + let mut source = String::from("fn crowded("); + for idx in 0..param_count { + if idx > 0 { + source.push_str(", "); + } + source.push_str(&format!("p{idx}")); + } + source.push_str(") -> int {\n"); + for idx in 0..local_count { + source.push_str(&format!(" let v{idx} = {idx};\n")); + } + source.push_str(" "); + for idx in 0..param_count.min(8) { + if idx > 0 { + source.push_str(" + "); + } + source.push_str(&format!("p{idx}")); + } + for idx in 0..local_count { + source.push_str(&format!(" + v{idx}")); + } + source.push_str(";\n}\ncrowded("); + for idx in 0..param_count { + if idx > 0 { + source.push_str(", "); + } + source.push_str(&format!("{idx}")); + } + source.push_str(");\n"); + + let err = match compile_source(&source) { + Ok(_) => panic!("compile should fail with the frame-local limit"), + 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:?}"), + } +} + +/// B1 follow-up boundary: near the 256-slot limit, non-parameter locals +/// must still compact. `spacious` declares 200 parameters (each keeping +/// its own physical slot under the full-body rule) plus 100 chained body +/// locals whose live ranges overlap at most two deep, and the top level +/// additionally calls a closure through a local binding (a dynamic +/// `LocalCall`). The compacted frame must stay below the 300 declared +/// slots, must fit within the 256-slot frame limit, and the chained values +/// must survive. +/// +/// RED at base: without the full-body parameter rule *and* with the +/// dynamic-local-call liveness fill, every slot in the program interferes +/// with every other slot, so the 300-slot frame cannot color at all and +/// compilation fails with a spurious "too many simultaneously live locals" +/// error even though no frame needs more than ~205 slots. +#[test] +fn non_param_locals_still_compact_beside_wide_parameter_frames() { + let param_count = 200; + let local_count = 100; + let mut source = String::from("fn spacious("); + for idx in 0..param_count { + if idx > 0 { + source.push_str(", "); + } + source.push_str(&format!("p{idx}")); + } + source.push_str(") -> int {\n"); + for idx in 0..local_count { + source.push_str(&format!(" let s{idx} = ")); + if idx == 0 { + source.push_str("p0"); + } else { + source.push_str(&format!("s{} + p{idx}", idx - 1)); + } + source.push_str(";\n"); + } + source.push_str(&format!( + " s{} + p{};\n}}\nspacious(", + local_count - 1, + param_count - 1 + )); + for idx in 0..param_count { + if idx > 0 { + source.push_str(", "); + } + source.push_str(&format!("{idx}")); + } + // A dynamic closure call: without the precise allocator liveness this + // one `LocalCall` fills every slot live and turns the 300-slot program + // into one interference clique, failing the 256-slot limit spuriously. + source.push_str(");\nlet f = |q| q;\nlet g: int = f(1);\n"); + + let compiled = compile_source(&source).expect("wide-parameter program should compile"); + assert!( + compiled.locals < param_count + local_count, + "chained non-parameter locals must still compact: frame {} must stay below the {} declared slots", + compiled.locals, + param_count + local_count + ); + assert!( + compiled.locals <= (u8::MAX as usize) + 1, + "compacted frame {} must fit within the 256-slot frame limit", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("wide-parameter program should run"); + assert_eq!(status, VmStatus::Halted); + let expected: i64 = (0..local_count as i64).sum::() + (param_count as i64 - 1); + assert_eq!(vm.stack(), &[Value::Int(expected)]); +} + +/// P2-2 regression: a closure whose body invokes another closure through a +/// dynamic `LocalCall` must not turn the whole program into one interference +/// clique. `run` declares 250 chained locals whose live ranges overlap at +/// most two deep, then calls the closure `f`, whose body creates and calls +/// the inner closure `g` through a local binding. Before the fix the +/// closure-body live-out was seeded with every slot used in the body, and +/// the dynamic-local-call liveness fill kept every slot live across the +/// call, so the whole program became one clique: the frame could not +/// compact and a spurious "too many simultaneously live locals" error fired +/// even though no frame needs more than a handful of slots. The compacted +/// frame must stay well below the declared slots, must fit within the +/// 256-slot frame limit, and the chained values must survive. +#[test] +fn closure_local_call_keeps_unrelated_locals_compact() { + let local_count = 250; + let mut source = String::from( + "pub fn run(context: map) -> map {\n\ + let f = |x| if true => {\n\ + let g = |y| if true => {\n\ + y + \"?\"\n\ + } else => {\n\ + \"?\"\n\ + };\n\ + g(x) + \"!\"\n\ + } else => {\n\ + \"?\"\n\ + };\n", + ); + source.push_str(" let s0: string = \"a\";\n"); + for idx in 1..local_count { + source.push_str(&format!(" let s{idx}: string = s{} + \"b\";\n", idx - 1)); + } + source.push_str(&format!( + " let out: string = f(s{});\n {{ ok: out }}\n}}\nlet result: map = run({{}});\nresult;\n", + local_count - 1 + )); + let compiled = compile_source(&source).expect("closure LocalCall program should compile"); + assert!( + compiled.locals < local_count, + "unrelated chained locals must still compact beside a closure LocalCall: frame {} must stay well below the {} declared slots", + compiled.locals, + local_count + 8 + ); + assert!( + compiled.locals <= (u8::MAX as usize) + 1, + "compacted frame {} must fit within the 256-slot frame limit", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("closure LocalCall program should run"); + assert_eq!(status, VmStatus::Halted); + let mut expected = String::from("a"); + for _ in 1..local_count { + expected.push('b'); + } + expected.push_str("?!"); + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("ok")), + Some(&Value::string(expected.as_str())), + "chained local values must survive the closure LocalCall" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} + +/// P2 regression: a dynamic `LocalCall` nested inside a plain named-call +/// argument, an optional-access key (`?.[...]`), or an `unwrap_or` +/// fallback must not leak the conservative dynamic-call liveness fill into +/// the allocator's precise path. `run` binds the closure `f` and calls it +/// from exactly those three positions (`helper(f(10))`, `?.[f(20)]`, +/// `.unwrap_or(f(30))`) while 250 chained locals whose live ranges overlap +/// at most two deep are alive. Before the fix `add_expr_uses_impl` +/// descended into `OptionalGet` container/key, `OptionUnwrapOr` +/// value/fallback, and `Expr::Call`/`Expr::ModuleCall` args through the +/// conservative wrapper `add_expr_uses`, so the nested `LocalCall` filled +/// every slot live: the whole program became one interference clique, the +/// chained locals lost compaction, and the frame could not color (a +/// spurious "too many simultaneously live locals" error near the 256-slot +/// limit) even though no frame needs more than a handful of slots. The +/// compacted frame must stay well below the declared slots, must fit +/// within the 256-slot frame limit, and every nested-call result must +/// survive. +#[test] +fn nested_local_call_in_call_arg_optional_key_and_unwrap_fallback_stays_compact() { + let local_count = 250; + let mut source = String::from( + "struct Payload { values: [int] }\n\ + fn helper(x: int) -> int { x + 1 }\n\ + pub fn run(context: map) -> map {\n\ + let f = |x| x + 1;\n\ + let payload: Payload = { values: [1, 2, 3] };\n\ + let a: int = helper(f(10));\n\ + let b: int = payload?.values?.[f(20)].unwrap_or(-1);\n\ + let c: int = payload?.values?.[1].unwrap_or(f(30));\n\ + let s0: string = \"a\";\n", + ); + for idx in 1..local_count { + source.push_str(&format!(" let s{idx}: string = s{} + \"b\";\n", idx - 1)); + } + source.push_str(&format!( + " {{ a: a, b: b, c: c, tail: s{} }}\n}}\nlet result: map = run({{}});\nresult;\n", + local_count - 1 + )); + let compiled = compile_source(&source).expect("nested-LocalCall program should compile"); + assert!( + compiled.locals < local_count, + "unrelated chained locals must still compact beside nested LocalCalls: frame {} must stay well below the {} declared slots", + compiled.locals, + local_count + ); + assert!( + compiled.locals <= (u8::MAX as usize) + 1, + "compacted frame {} must fit within the 256-slot frame limit", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("nested-LocalCall program should run"); + assert_eq!(status, VmStatus::Halted); + let mut expected_tail = String::from("a"); + for _ in 1..local_count { + expected_tail.push('b'); + } + match vm.stack().last() { + Some(Value::Map(map)) => { + assert_eq!( + map.get(&Value::string("a")), + Some(&Value::Int(12)), + "named-call argument LocalCall must evaluate" + ); + assert_eq!( + map.get(&Value::string("b")), + Some(&Value::Int(-1)), + "optional-access key LocalCall must evaluate (out-of-range key unwraps to the fallback)" + ); + assert_eq!( + map.get(&Value::string("c")), + Some(&Value::Int(2)), + "unwrap_or fallback LocalCall must keep the present value" + ); + assert_eq!( + map.get(&Value::string("tail")), + Some(&Value::string(expected_tail.as_str())), + "chained local values must survive the nested LocalCalls" + ); + } + other => panic!("expected a result map on the stack, got {other:?}"), + } +} 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/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index 2ce73ef4..c82f5973 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -3,7 +3,8 @@ mod build_script; use build_script::{ - HostBindingKind, HostExecutionKind, classify_host_binding, infer_host_execution, + HostBindingKind, HostExecutionKind, callable_param_expr, classify_host_binding, + infer_host_execution, type_label, }; use syn::parse_quote; use vm::{ @@ -18,6 +19,23 @@ fn native_jit_supported() -> bool { && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) } +#[test] +fn preserves_typed_callable_host_parameter_schema() { + let ty: syn::Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty), "fn(map) -> map"); + assert_eq!( + callable_param_expr("fn(map) -> map"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Map], return_type: &CallableParamType::Map })" + ); + + let float_ty: syn::Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty), "fn(float) -> float"); + assert_eq!( + callable_param_expr("fn(float) -> float"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Float], return_type: &CallableParamType::Float })" + ); +} + #[test] fn classifies_best_effort_host_bindings_from_signatures() { for function in [ 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/ownership_tests.rs b/tests/vm/ownership_tests.rs index 95c2eefd..fad01cbc 100644 --- a/tests/vm/ownership_tests.rs +++ b/tests/vm/ownership_tests.rs @@ -12,7 +12,9 @@ use common::*; use std::sync::Arc; -use vm::{HostFunctionRegistry, InvocationError, InvocationItem, InvocationPoll, Value, VmStatus}; +use vm::{ + HostFunctionRegistry, InvocationError, InvocationItem, InvocationPoll, Value, VmError, VmStatus, +}; fn non_yielding_returns_zero(_: &[Value]) -> Result { Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(0)))) @@ -405,3 +407,783 @@ fn reset_after_host_error_reruns_cleanly_on_the_same_instance() { "the rerun must execute cleanly after reset, got {items:?}" ); } + +/// A BorrowMut capture cell is instance-scoped: independent instances over +/// the same immutable program never observe each other's cell values, and a +/// fresh instance always starts from fresh cells. +#[test] +fn closure_capture_cells_do_not_leak_between_instances() { + let program = Arc::new( + compile_source( + r#" + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + let _ = sink("b"); + state; + "#, + ) + .expect("source should compile") + .program, + ); + let mut first = Vm::new_shared(Arc::clone(&program)); + assert_eq!( + first.run().expect("first instance should halt"), + VmStatus::Halted + ); + assert_eq!(first.stack(), &[Value::string("ab")]); + + // A fresh instance over the same program starts with fresh capture + // cells: the first instance's accumulated value must not leak into it. + drop(first); + + // A second instance over the same program starts with a fresh cell and + // accumulates only its own deltas: the first instance's cell value must + // not leak into it. + let mut second = Vm::new_shared(Arc::clone(&program)); + assert_eq!( + second.run().expect("second instance should halt"), + VmStatus::Halted + ); + assert_eq!(second.stack(), &[Value::string("ab")]); +} + +/// Reset re-issues fresh callable identity: a host-held `Value::Callable` +/// resolved before the reset must be rejected by every host entry point once +/// the VM halts again, while a freshly resolved post-reset callable remains +/// fully invocable. +#[test] +fn stale_callable_handles_are_rejected_after_reset() { + let program = compile_source( + r#" + pub fn run(input: int) -> int { + input; + } + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + + let stale = vm + .resolve_exported_callable("run") + .expect("pre-reset run callable should resolve"); + vm.reset_for_reuse(); + assert_eq!( + vm.run().expect("root should halt after reset"), + VmStatus::Halted + ); + + // invoke_callable rejects the stale handle before any stack or frame + // state changes. + assert!(matches!( + vm.invoke_callable(stale.clone(), &[Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert!( + vm.stack().is_empty(), + "rejected invocation must not touch the stack" + ); + assert!( + vm.execution_frames().is_empty(), + "rejected invocation must not open frames" + ); + + // start_callable rejects the stale handle. + assert!(matches!( + vm.start_callable(stale.clone(), &[Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + + // queue_callable rejects the stale handle eagerly; nothing is queued. + assert!(matches!( + vm.queue_callable(stale.clone(), vec![Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert_eq!(vm.queued_callable_count(), 0); + + // start_invocation surfaces the stale handle as one typed error item + // followed by the fused end of stream. + { + let mut invocation = vm + .start_invocation(stale, vec![Value::Int(1)]) + .expect("invocation handle should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::InvalidFrameState( + "callable does not belong to this vm" + ))))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + } + + // Control: a freshly resolved post-reset callable is fully invocable + // through the same four entry points. + let fresh = vm + .resolve_exported_callable("run") + .expect("post-reset run callable should resolve"); + assert_eq!( + vm.invoke_callable(fresh.clone(), &[Value::Int(7)]) + .expect("fresh callable should invoke"), + Value::Int(7) + ); + assert_eq!( + vm.start_callable(fresh.clone(), &[Value::Int(8)]) + .expect("fresh callable should start"), + VmStatus::Halted + ); + assert_eq!(vm.take_callable_result(), Some(Value::Int(8))); + vm.queue_callable(fresh.clone(), vec![Value::Int(9)]) + .expect("fresh callable should queue"); + assert_eq!( + vm.drain_callable_queue() + .expect("queued fresh callable should drain"), + vec![Value::Int(9)] + ); + let items = collect_invocation_items(&mut vm, vec![Value::Int(10)]); + assert_eq!(items.len(), 1, "fresh invocation must complete once"); + assert!( + matches!(&items[0], Ok(InvocationItem::Complete(value)) if *value == Value::Int(10)), + "fresh post-reset callable must still complete invocations, got {items:?}" + ); +} + +/// Callable handles are instance-scoped: a handle resolved on one VM over a +/// shared program must be rejected by every host entry point on a second VM +/// over the same program, while the second VM's own handle keeps working. +#[test] +fn callable_handles_do_not_cross_vm_instances() { + let program = Arc::new( + compile_source( + r#" + pub fn run(input: int) -> int { + input; + } + "#, + ) + .expect("source should compile") + .program, + ); + let mut first = Vm::new_shared(Arc::clone(&program)); + let mut second = Vm::new_shared(Arc::clone(&program)); + assert_eq!( + first.run().expect("first root should halt"), + VmStatus::Halted + ); + assert_eq!( + second.run().expect("second root should halt"), + VmStatus::Halted + ); + + let foreign = first + .resolve_exported_callable("run") + .expect("first callable should resolve"); + + assert!(matches!( + second.invoke_callable(foreign.clone(), &[Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert!(matches!( + second.start_callable(foreign.clone(), &[Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert!(matches!( + second.queue_callable(foreign.clone(), vec![Value::Int(1)]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert_eq!(second.queued_callable_count(), 0); + { + let mut invocation = second + .start_invocation(foreign, vec![Value::Int(1)]) + .expect("invocation handle should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::InvalidFrameState( + "callable does not belong to this vm" + ))))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + } + + // Control: the second VM's own handle is unaffected. + let own = second + .resolve_exported_callable("run") + .expect("second callable should resolve"); + assert_eq!( + second + .invoke_callable(own, &[Value::Int(3)]) + .expect("own callable should invoke"), + Value::Int(3) + ); +} + +/// A pre-reset handle to a capture-bearing closure must be rejected after +/// reset: invoking it would otherwise resurrect the previous run's capture +/// cells into the fresh instance instead of observing the fresh cell state. +#[test] +fn stale_capture_callable_cannot_reach_the_previous_runs_cells() { + use std::sync::OnceLock; + + static STASHED: OnceLock>> = OnceLock::new(); + fn stash_callback(args: &[Value]) -> Result { + STASHED + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .expect("stash lock") + .replace(args[0].clone()); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::map( + Vec::new(), + )))) + } + + let program = compile_source( + r#" + fn stash(callback: fn(string) -> map) -> map; + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + let _ = sink("b"); + let _ = stash(sink); + state; + "#, + ) + .expect("capture source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_static_non_yielding_args_function("stash", stash_callback); + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("ab")], + "first run should accumulate both deltas in the shared cell" + ); + + let stale = STASHED + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .expect("stash lock") + .clone() + .expect("host should hold the pre-reset closure"); + assert!( + matches!(stale, Value::Callable(_)), + "host must hold a callable value" + ); + + vm.reset_for_reuse(); + assert_eq!(vm.run().expect("second run should halt"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("ab")], + "second run must start from a fresh cell" + ); + + // The pre-reset closure handle must be rejected before it can touch the + // previous run's cells. + assert!(matches!( + vm.invoke_callable(stale, &[Value::string("c")]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + )); + assert_eq!( + vm.stack(), + &[Value::string("ab")], + "the rejected stale handle must not disturb the fresh run's state" + ); +} +/// P1 ownership contract for JIT-inlined root callable escapes. +/// +/// A root callable binding of an inlined callee frame is materialized by the +/// JIT as a fresh `Value::Callable` per lifecycle. When that callable escapes +/// through a non-yielding host stash, every host entry gate must accept it, +/// because the materialization registered it with the owning VM. After +/// `reset_for_reuse`, a handle from the previous run must be rejected by every +/// gate, and the next run's JIT materialization must mint a *different* Arc so +/// the stale handle can never be re-legalized through JIT constant reuse. +#[test] +fn jit_inlined_callee_root_callable_escapes_through_host_stash() { + use std::sync::{Mutex, OnceLock}; + fn native_jit_supported() -> bool { + (cfg!(target_arch = "x86_64") + && (cfg!(target_os = "windows") || (cfg!(unix) && !cfg!(target_os = "macos")))) + || (cfg!(target_arch = "aarch64") + && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) + } + if !native_jit_supported() { + return; + } + + static STASHED: OnceLock, usize)>> = OnceLock::new(); + fn stash_callback(args: &[Value]) -> Result { + let mut slot = STASHED + .get_or_init(|| Mutex::new((None, 0))) + .lock() + .expect("stash lock"); + slot.0.replace(args[0].clone()); + slot.1 += 1; + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::map( + Vec::new(), + )))) + } + fn stashed_value() -> Value { + STASHED + .get_or_init(|| Mutex::new((None, 0))) + .lock() + .expect("stash lock") + .0 + .clone() + .expect("host must hold a stashed callable") + } + fn stash_call_count() -> usize { + STASHED + .get_or_init(|| Mutex::new((None, 0))) + .lock() + .expect("stash lock") + .1 + } + + let program = compile_source( + r#" + fn stash(callback: fn(map) -> map) -> map; + fn helper(item: map) -> map { { action: "continue" } } + fn identity() -> array { + let mut a = []; + a[0] = helper; + a + } + let mut i: int = 0; + while i < 40 { + i = i + 1; + let _ = stash(identity()[0]); + } + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_static_non_yielding_args_function("stash", stash_callback); + vm.set_jit_config(vm::JitConfig { + enabled: true, + hot_loop_threshold: 4, + max_trace_len: 256, + }); + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + + // The scenario must exercise the native JIT with a hot inline: one trace + // must record the inlined callee's root binding as an owned-materialized + // callable that reaches the stash host call. + assert!( + vm.jit_native_exec_count() > 0, + "the test must drive the native JIT" + ); + assert_eq!( + stash_call_count(), + 40, + "every loop iteration must reach the stash" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names + .iter() + .any(|op| op.starts_with("inline_call"))), + "the recorded trace must inline the callee: {:?}", + snapshot + .traces + .iter() + .map(|trace| trace.op_names.clone()) + .collect::>() + ); + assert!( + snapshot + .traces + .iter() + .any(|trace| trace.ssa_text().contains("host_call")), + "the recorded trace must reach the stash host call:\n{}", + snapshot + .traces + .iter() + .map(|trace| trace.ssa_text()) + .collect::>() + .join("\n---\n") + ); + + let first = stashed_value(); + assert!( + matches!(&first, Value::Callable(_)), + "host must hold a callable value" + ); + + // Every host entry gate must accept the JIT-escaped handle of this run. + assert_eq!( + vm.start_callable(first.clone(), &[Value::map(Vec::new())]) + .expect("start_callable must accept the escaped callable"), + VmStatus::Halted + ); + assert!( + vm.invoke_callable(first.clone(), &[Value::map(Vec::new())]) + .is_ok(), + "invoke_callable must accept the escaped callable" + ); + vm.queue_callable(first.clone(), vec![Value::map(Vec::new())]) + .expect("queue_callable must accept the escaped callable"); + assert_eq!( + vm.drain_callable_queue() + .expect("queued callable should drain") + .len(), + 1 + ); + { + let mut invocation = vm + .start_invocation(first.clone(), vec![Value::map(Vec::new())]) + .expect("start_invocation must accept the escaped callable"); + let mut completed = false; + loop { + match invocation + .poll_next() + .expect("invocation poll should not fail") + { + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(_)))) => { + panic!("escaped callable invocation must not emit events"); + } + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(_)))) => { + completed = true; + } + InvocationPoll::Ready(Some(Err(error))) => { + panic!("escaped callable invocation failed: {error:?}") + } + InvocationPoll::Ready(None) => break, + InvocationPoll::Pending => { + panic!("escaped callable invocation must not pend") + } + } + } + assert!(completed, "escaped callable invocation must complete"); + } + { + let mut store = vm::Store::new(vm, ()); + let callback = store + .script_callback::(first.clone()) + .expect("script_callback must accept the escaped callable"); + // Real contract: the callback mirrors the helper prototype's + // `(map) -> map` callable schema. + assert!(matches!( + callback.schema(), + Some(vm::compiler::TypeSchema::Callable { params, result }) + if *params + == [vm::compiler::TypeSchema::Map(Box::new( + vm::compiler::TypeSchema::Unknown + ))] + && **result + == vm::compiler::TypeSchema::Map(Box::new( + vm::compiler::TypeSchema::Unknown + )) + )); + vm = store.into_vm(); + } + + // Reset invalidates every handle of the previous run. + vm.reset_for_reuse(); + assert!( + matches!( + vm.start_callable(first.clone(), &[Value::map(Vec::new())]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + ), + "stale handle must be rejected by start_callable" + ); + assert!( + matches!( + vm.invoke_callable(first.clone(), &[Value::map(Vec::new())]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + ), + "stale handle must be rejected by invoke_callable" + ); + assert!( + matches!( + vm.queue_callable(first.clone(), vec![Value::map(Vec::new())]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + ), + "stale handle must be rejected by queue_callable" + ); + { + let mut invocation = vm + .start_invocation(first.clone(), vec![Value::map(Vec::new())]) + .expect("invocation handle should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::InvalidFrameState( + "callable does not belong to this vm" + ))))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + } + { + let mut store = vm::Store::new(vm, ()); + assert!( + matches!( + store.script_callback::(first.clone()), + Err(VmError::InvalidFrameState( + "script callable does not belong to this store" + )) + ), + "stale handle must be rejected by script_callback" + ); + vm = store.into_vm(); + } + + // The second run reuses the compiled trace; its escape must mint a fresh + // Arc, succeed at every gate, and leave the stale handle rejected. + let native_exec_after_first = vm.jit_native_exec_count(); + assert_eq!(vm.run().expect("second run should halt"), VmStatus::Halted); + assert!( + vm.jit_native_exec_count() > native_exec_after_first, + "the second run must reuse the native trace ({} -> {})", + native_exec_after_first, + vm.jit_native_exec_count() + ); + let second = stashed_value(); + let (Value::Callable(first_callable), Value::Callable(second_callable)) = (&first, &second) + else { + panic!("both escapes must be callables"); + }; + assert!( + !Arc::ptr_eq(first_callable, second_callable), + "each run's JIT escape must materialize a distinct Arc" + ); + assert!( + matches!( + vm.invoke_callable(first.clone(), &[Value::map(Vec::new())]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + ), + "the stale handle must not be re-legalized by the second run's JIT materialization" + ); + assert_eq!( + vm.start_callable(second.clone(), &[Value::map(Vec::new())]) + .expect("the second run's escaped callable must be accepted"), + VmStatus::Halted + ); + assert!( + vm.invoke_callable(second.clone(), &[Value::map(Vec::new())]) + .is_ok(), + "the second run's escaped callable must invoke" + ); + vm.queue_callable(second.clone(), vec![Value::map(Vec::new())]) + .expect("the second run's escaped callable must queue"); + assert_eq!( + vm.drain_callable_queue() + .expect("queued callable should drain") + .len(), + 1 + ); + { + let mut invocation = vm + .start_invocation(second.clone(), vec![Value::map(Vec::new())]) + .expect("the second run's escaped callable must start"); + let mut completed = false; + loop { + match invocation + .poll_next() + .expect("invocation poll should not fail") + { + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(_)))) => { + panic!("escaped callable invocation must not emit events"); + } + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(_)))) => { + completed = true; + } + InvocationPoll::Ready(Some(Err(error))) => { + panic!("second-run escaped callable invocation failed: {error:?}") + } + InvocationPoll::Ready(None) => break, + InvocationPoll::Pending => { + panic!("second-run escaped callable invocation must not pend") + } + } + } + assert!(completed, "second-run escaped callable must complete"); + } + { + let mut store = vm::Store::new(vm, ()); + store + .script_callback::(second.clone()) + .expect("the second run's escaped callable must register as a script callback"); + vm = store.into_vm(); + } + + assert!( + matches!( + vm.start_callable(first.clone(), &[Value::map(Vec::new())]), + Err(VmError::InvalidFrameState( + "callable does not belong to this vm" + )) + ), + "the stale handle must stay rejected after the second run" + ); +} + +/// P1 drop-contract for JIT root-callable materialization: every loop +/// iteration of the native trace writes a fresh `Value::Callable` Arc into +/// the same owned temp slot. The previous iteration's Arc must be released +/// when the slot is overwritten, exactly like the interpreter drops the +/// previous run's root binding on re-initialization. A `ptr::write`-style +/// overwrite would leak one strong ref per iteration, which stays observable +/// through `Weak` long after the run completes. +#[test] +fn jit_materialize_root_callable_releases_prior_iteration_arcs() { + use std::sync::{Mutex, OnceLock, Weak}; + + type SeenCallables = (Option, Vec>); + static SEEN: OnceLock> = OnceLock::new(); + fn stash_callback(args: &[Value]) -> Result { + let mut slot = SEEN + .get_or_init(|| Mutex::new((None, Vec::new()))) + .lock() + .expect("stash lock"); + if let Value::Callable(callable) = &args[0] { + slot.1.push(Arc::downgrade(callable)); + } + slot.0.replace(args[0].clone()); + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::map( + Vec::new(), + )))) + } + fn native_jit_supported() -> bool { + (cfg!(target_arch = "x86_64") + && (cfg!(target_os = "windows") || (cfg!(unix) && !cfg!(target_os = "macos")))) + || (cfg!(target_arch = "aarch64") + && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) + } + if !native_jit_supported() { + return; + } + + let program = compile_source( + r#" + fn stash(callback: fn(map) -> map) -> map; + fn helper(item: map) -> map { { action: "continue" } } + fn identity() -> array { + let mut a = []; + a[0] = helper; + a + } + let mut i: int = 0; + while i < 40 { + i = i + 1; + let _ = stash(identity()[0]); + } + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_static_non_yielding_args_function("stash", stash_callback); + vm.set_jit_config(vm::JitConfig { + enabled: true, + hot_loop_threshold: 4, + max_trace_len: 256, + }); + assert_eq!(vm.run().expect("run should halt"), VmStatus::Halted); + assert!( + vm.jit_native_exec_count() > 0, + "the test must drive the native JIT" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot + .traces + .iter() + .any(|trace| trace.ssa_text().contains("materialize_root_callable")), + "the recorded trace must contain the materialize inst:\n{}", + snapshot + .traces + .iter() + .map(|trace| trace.ssa_text()) + .collect::>() + .join("\n---\n") + ); + + // Drop the host's stashed strong refs. The VM legitimately retains its + // root-frame callable bindings, so the only surviving callables may be + // exactly those root bindings. Any *other* surviving Arc is a leaked + // JIT temp-slot value: the JIT materializes a fresh Arc per loop + // iteration into the same owned temp slot, and every overwritten + // iteration's Arc must be released (never leaked). + let mut slot = SEEN + .get_or_init(|| Mutex::new((None, Vec::new()))) + .lock() + .expect("stash lock"); + let weaks = std::mem::take(&mut slot.1); + slot.0 = None; + drop(slot); + assert!( + weaks.len() >= 2, + "the loop must stash a distinct callable per iteration" + ); + let root_bindings = vm + .locals() + .iter() + .filter_map(|value| match value { + Value::Callable(arc) => Some(arc.clone()), + _ => None, + }) + .collect::>(); + assert!( + !root_bindings.is_empty(), + "the root frame must retain its callable bindings" + ); + let leaked = weaks + .iter() + .filter(|weak| { + weak.upgrade().is_some_and(|arc| { + !root_bindings + .iter() + .any(|root| std::sync::Arc::ptr_eq(root, &arc)) + }) + }) + .count(); + assert_eq!( + leaked, 0, + "every JIT-materialized root-callable Arc must be released after the run; \ + {leaked} non-root strong refs leaked from overwritten JIT temp slots" + ); +} diff --git a/tests/vm/vm_runtime_tests.rs b/tests/vm/vm_runtime_tests.rs index 92536a81..dafdcfa9 100644 --- a/tests/vm/vm_runtime_tests.rs +++ b/tests/vm/vm_runtime_tests.rs @@ -669,23 +669,90 @@ fn host_function_registry_includes_default_runtime_exit() { #[test] fn json_encode_rejects_non_string_map_keys() { - match compile_source( + // Non-string keys are not representable in `TypeSchema::Map`, so the + // compiler admits the map and the runtime encoder rejects the key. + let compiled = compile_source( r#" use json; let payload = { 1: "one" }; json::encode(payload); "#, - ) { - Err(err) => match err { - vm::SourceError::Compile(vm::CompileError::CallableArgumentTypeMismatch { - detail, - .. - }) => { - assert!(detail.contains("provably strings"), "{detail}"); + ) + .expect("non-string-key maps must compile; runtime must reject them"); + + let mut vm = Vm::new(compiled.program); + let err = vm + .run() + .expect_err("json::encode must reject non-string map keys"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode map keys must be strings"), + "{message}" + ); + } + other => panic!("unexpected vm error: {other}"), + } +} + +#[test] +fn json_encode_rejects_nan_at_runtime() { + // NaN has no JSON representation. The compiler cannot prove the value + // non-finite, so the runtime encoder must reject it. + let compiled = compile_source( + r#" + use json; + use math; + let payload: float = math::nan(); + json::encode(payload); + "#, + ) + .expect("nan float must compile; runtime must reject it"); + + let mut vm = Vm::new(compiled.program); + let err = vm.run().expect_err("json::encode must reject NaN"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode does not support NaN or infinity"), + "{message}" + ); + } + other => panic!("unexpected vm error: {other}"), + } +} + +#[test] +fn json_encode_rejects_infinite_floats_at_runtime() { + // Positive and negative infinity have no JSON representation; the + // runtime encoder must reject both. + for source in [ + r#" + use json; + use math; + let payload: float = math::inf(); + json::encode(payload); + "#, + r#" + use json; + use math; + let payload: float = math::neg_inf(); + json::encode(payload); + "#, + ] { + let compiled = + compile_source(source).expect("infinite float must compile; runtime must reject it"); + let mut vm = Vm::new(compiled.program); + let err = vm.run().expect_err("json::encode must reject infinity"); + match err { + vm::VmError::HostError(message) => { + assert!( + message.contains("json_encode does not support NaN or infinity"), + "{message}" + ); } - other => panic!("unexpected compiler error: {other}"), - }, - Ok(_) => panic!("RustScript should reject generic-map json::encode at compile time"), + other => panic!("unexpected vm error: {other}"), + } } } 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..6fe52943 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 } @@ -196,6 +204,56 @@ fn callable_metadata_roundtrips_vmbc_v11() { validate_program(&decoded, 0).expect("decoded program should validate"); } +#[test] +fn closure_shared_capture_vmbc_round_trip() { + let compiled = vm::compile_source_with_flavor( + r#" + let mut state: string = ""; + let sink = |delta| if true => { + state = state + delta; + { action: "continue" } + } else => { + { action: "skip" } + }; + let _ = sink("a"); + state; + "#, + vm::SourceFlavor::RustScript, + ) + .expect("mutable capture source should compile"); + let sink_prototype = compiled + .program + .callable_prototypes + .iter() + .find(|prototype| { + prototype.kind == vm::CallableKind::Closure + && prototype + .capture_modes + .contains(&vm::CaptureBindingMode::BorrowMut) + }) + .expect("closure prototype should carry a BorrowMut capture"); + assert!( + sink_prototype + .capture_modes + .iter() + .all(|mode| *mode != vm::CaptureBindingMode::Move), + "mutation capture must not be classified as a move" + ); + let encoded = encode_program(&compiled.program).expect("encode shared capture program"); + let decoded = decode_program(&encoded).expect("decode shared capture program"); + assert_eq!( + decoded.callable_prototypes, compiled.program.callable_prototypes, + "capture modes must survive the VMBC round trip" + ); + validate_program(&decoded, 0).expect("decoded program should validate"); + let mut runtime = vm::Vm::new(decoded); + assert_eq!( + runtime.run().expect("decoded program should run"), + vm::VmStatus::Halted + ); + assert_eq!(runtime.stack(), &[Value::string("a")]); +} + #[test] fn callvalue_roundtrips_validation_and_disassembly() { let mut bc = BytecodeBuilder::new(); @@ -481,3 +539,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); +}