From 990afdc6e8260f517fd8301552d01bcb8aac6a3c Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 03:27:48 +0800 Subject: [PATCH 01/18] plan(vm): define first-class function values --- plans/function_as_value_plan.md | 83 ++++ plans/real_script_call_frames_plan.md | 525 ++++++-------------------- 2 files changed, 203 insertions(+), 405 deletions(-) create mode 100644 plans/function_as_value_plan.md diff --git a/plans/function_as_value_plan.md b/plans/function_as_value_plan.md new file mode 100644 index 00000000..8ed3ff4b --- /dev/null +++ b/plans/function_as_value_plan.md @@ -0,0 +1,83 @@ +# Function as Value Implementation Plan + +**Goal:** Add first-class RSS function values with Rust-like semantics, existing RustScript capture behavior, Program-owned definitions, generic typing, deterministic lifecycle, and Rust-host callbacks. + +## 1. Fix the semantic contract + +**Target:** Define behavior before choosing runtime instructions. + +**Complete:** + +- Add characterization tests for named functions, closure expressions, nested captures, recursion, assignment, return, containers, and generics. +- Model a capture-free named `fn` as a Program-owned function item. Model a capture-bearing value as a closure with an environment. Preserve capturing nested named functions as a RustScript extension. +- Select runtime representation from capture/environment requirements, never from `named` or `anonymous` source spelling. +- Keep the model close to Rust: function-item identity remains available to inference; closure instances preserve capture state; `TypeSchema::Callable` remains Fn-like until distinct function-pointer and `Fn`/`FnMut`/`FnOnce` types exist. +- Document the implementation comparison: Rust separates function items, pointers, and closures; CLR uses methods/function pointers plus delegate/environment objects; Lua instantiates function prototypes with `OP_CLOSURE`. + +**Acceptance:** Tests define identity, capture sharing, mutation, recursion, coercion, and diagnostics without assuming a closure opcode. + +## 2. Implement callable values and real frames + +**Target:** Execute function values without retaining old Programs. + +**Complete:** + +- Add Program-owned callable prototypes, script entry points, parameter/local layouts, capture layouts, source metadata, and instantiated generic signatures. +- Add `Value::Callable { program_instance, prototype_id, kind, env }`; it must not own `Arc`. +- Add frame-relative locals, a real execution-frame stack, and one `Ret` rule: complete the active frame and follow its typed continuation. Root execution uses an explicit `Halt` continuation; nested RSS calls use `ResumeBytecode`; Rust invocation uses `ReturnToHost`. Never infer return meaning from an empty call stack. +- Add `CallValue(argc)` as the common RSS function-item/closure invocation path; retain existing `Call` for direct host/builtin imports. +- Initialize capture-free named bindings from Program metadata. +- Add one shared environment-binding path for every capture-bearing callable. Introduce a dedicated runtime operation only if existing VM operations cannot bind captures. +- Validate `ProgramInstanceId` before prototype lookup; replaced/reset Program handles return `StaleCallable`. + +**Acceptance:** Named calls, recursion, closure calls, arity failures, stale handles, return values, yield/pending, and resume all use real frames. + +## 3. Integrate captures, types, and generics + +**Target:** Preserve lifecycle analysis while allowing callable values to flow through the compiler. + +**Complete:** + +- Convert current `CaptureBindingMode::{Copy, Borrow, BorrowMut, Move}` analysis into explicit environment layouts: + - `Copy` stores an independent value in the closure environment; + - `Borrow` and `BorrowMut` hoist the source local into one shared cell used by the outer frame and every capturing closure, matching C# display-class sharing and Rust reference-capture behavior; + - `Move` transfers the value into a closure-owned cell and invalidates the source according to existing move rules. +- Route `Ldloc`/`Stloc` through frame or captured cells. Aliases of one closure share cells, separate closure evaluations receive separate environments, and outer-frame reads/writes observe borrowed capture updates. +- Add coarse runtime callable typing and precise parameter/result/capture schemas. +- Merge compatible callable signatures across branches, matches, and loops; keep exact target identity as inference/optimization metadata only. +- Support explicit `name::` values, unambiguous contextual resolution, substituted schemas, generic higher-order functions, and erased runtime bodies. +- Add focused errors for unresolved generics, incompatible signatures, invalid calls, callable map keys, and callable constant serialization. + +**Acceptance:** Escaping closures, mutable captures, branch merges, strict/dynamic checks, and generic function values pass compiler and runtime tests. + +## 4. Complete lifecycle and Rust callback APIs + +**Target:** Support host-retained UI/event callbacks and Program replacement safely. + +**Complete:** + +- Release environments correctly on clone, overwrite, `Drop`, unwind, collection removal, return, cancellation, and reset; reject ownership cycles until cycle collection exists. +- On Program replacement, REPL installation, or reuse reset: cancel callback work, remove listeners, clear callable roots/persisted callables, drop the old Program, and invalidate all external old handles. +- Add Rust APIs to resolve exported RSS functions and invoke function items or closures through one resumable path. +- Add typed `ScriptCallback` with Store identity, Program instance ID, and copied callable schema. +- Serialize callbacks through the Store queue; define FIFO/coalescing, cross-thread enqueue, unsubscribe, teardown, error, and return-value policies. +- Verify that the final Rust-held callback releases captures exactly once. + +**Acceptance:** A Rust UI host can retain, invoke, suspend/resume, unsubscribe, and invalidate RSS callbacks without retaining old executable code. + +## 5. Finish backend parity and verification + +**Target:** Apply one semantic contract across every supported runtime path. + +**Complete:** + +- Apply a hard internal format break: bump VMBC, AOT artifact and native ABI, debugger recording, and Program/native trace cache revisions; accept only the new formats, regenerate fixtures, and add no backward decoder or migration path. +- Audit existing opcode interactions: keep `Call` host-only, constrain branches to one root/function region, resolve `Ldloc`/`Stloc` through one slot-location map, define callable `Ceq` identity and `Pop`/`Dup` lifecycle, reject callable `Ldc` constants, and remove or redesign the current `Call + Ret` fusion. +- Implement callable creation, environment access, `CallValue`, script frame entry/return, recursion, errors, and suspension/resume completely in both Trace JIT and AOT. +- Trace JIT must record and compile through callable operations and callee returns. `CallValue`, closure environments, and callable frame transitions must not terminate recording, produce a feature side exit, or hand execution to the interpreter. +- Lower dynamic callable dispatch to native target resolution/calls that return to the same compiled trace. AOT must emit equivalent callable/frame/environment operations with no rejection or interpreter fallback. +- Update REPL replacement, debugger frames, stack traces, formatting, recording/replay policy, and diagnostics. +- Replace old function-value rejection tests only after equivalent positive and focused negative coverage exists. +- Run focused stage tests, native trace-continuity and AOT parity tests, then `cargo fmt --all -- --check`, callable/compiler/type/lifecycle/wire/no-std suites, `cargo test --workspace`, Clippy with `-D warnings`, and the release build. + +**Acceptance:** Interpreter, Trace JIT, and AOT produce identical results and lifecycle behavior. Callable creation, invocation, nested calls, recursion, captures, and returns remain inside native execution without feature-induced side exits, trace interruption, rejection, or interpreter fallback. diff --git a/plans/real_script_call_frames_plan.md b/plans/real_script_call_frames_plan.md index ab51c2e7..227b735b 100644 --- a/plans/real_script_call_frames_plan.md +++ b/plans/real_script_call_frames_plan.md @@ -1,405 +1,120 @@ -# Implementation Plan: Real Script Call Frames - -## Goal - -Introduce real runtime call frames for script-defined `fn` declarations so a function body is compiled once and invoked many times without duplicating its bytecode at each call site. - -This plan is only for real script calls and frames. - -Non-goals for this effort: - -- First-class callable `Value`s -- `call_indirect`-style dynamic dispatch -- Returning/storing callables in arrays or maps -- Full closure/runtime environment objects in the first milestone -- Tail-call optimization - -## Current State - -Today the compiler and VM behave like this: - -- Frontend IR keeps function definitions in `FunctionImpl`, plus `Expr::Call`, `Expr::FunctionRef`, and `Expr::LocalCall`. -- Codegen inlines any call whose target has a `FunctionImpl`. -- Final bytecode only emits `OpCode::Call` for builtins and host imports. -- The VM interprets `OpCode::Call` as builtin/host dispatch only. -- `OpCode::Ret` always halts the program; there is no intra-program return path. -- `Program` stores one code blob, one `local_count`, and one import table. It has no script-function table or entry-point metadata. -- Trace JIT and native AOT assume the same host-only call model. - -This means: - -- Repeated script calls duplicate the callee body in emitted bytecode. -- Recursive script functions are impossible under the current lowering. -- There is no real script stack frame, return address, or frame-local lifetime. - -## Recommended End State - -### Runtime model - -Add real script frames while keeping host/builtin calls unchanged: - -- Keep `OpCode::Call` for builtin/host import dispatch. -- Add a new `OpCode::CallScript` with operands `(script_function_id: u16, argc: u8)`. -- Reuse `OpCode::Ret`: - - if no script caller exists, halt the program - - otherwise pop one script frame, restore the caller frame, and continue at the saved return IP -- Add a VM-side `CallFrame` stack. - -### Program model - -Add a script-function table to `Program`: - -```rust -pub struct ScriptFunction { - pub decl_index: u16, - pub name: String, - pub arity: u8, - pub entry_ip: u32, - pub frame_local_count: u16, - pub param_slots: Vec, -} -``` - -Recommended first version: - -- Keep existing local-slot numbering from the compiler. -- Make `Ldloc`/`Stloc` frame-relative at runtime. -- For each separately emitted script function, set `frame_local_count` to `max(slot_in_footprint) + 1`. -- Do not attempt a dense per-function local remap in the first milestone. - -That choice keeps the first implementation small because it does not require rewriting the current lifetime/coloring passes, which still operate on one global local-slot space. - -### Code layout - -Compile the root body first, then append separately emitted script function bodies to the same `Program.code` blob: - -1. Root statements -2. Root `ret` -3. Function body A -4. Function `ret` -5. Function body B -6. Function `ret` -7. ... - -Execution still starts at `ip = 0`. Appended function bodies are only reachable through `CallScript`. - -## Scope Split - -This feature is safest as a staged rollout. - -### Phase 1 scope - -Support real script frames for direct named functions that do not require runtime capture environments: - -- Top-level functions -- Nested functions with `capture_copies.is_empty()` -- `LocalCall` sites whose callable is statically known to be one of the above functions - -Keep current inline lowering for: - -- Closures -- Capturing nested `fn` declarations -- Any callable path that still depends on declaration-time capture snapshots - -This already delivers the main win: function bodies stop being duplicated in bytecode for the most common direct-call cases. - -### Later scope - -Extend real frames to capturing nested `fn` declarations by adding declaration environments. That is a second feature, not part of the first milestone. - -## Design Details - -### 1. Bytecode and metadata - -Files: - -- `src/bytecode.rs` -- `src/assembler.rs` -- `src/vmbc.rs` -- `src/vm/jit/aot.rs` - -Changes: - -- Add `ScriptFunction` metadata to `Program`. -- Add `OpCode::CallScript`. -- Update opcode encoding/decoding and assembler helpers. -- Update VMBC serialization/deserialization for the new function table. -- Bump the VMBC wire version. -- Bump `AOT_VERSION` if AOT bundles persist the expanded `Program` layout. - -Recommendation: - -- Keep `Program.imports` exactly for host imports. -- Add a separate `Program.script_functions`. -- Do not overload the existing import table to carry both concepts. - -### 2. VM frame stack - -Files: - -- `src/vm/mod.rs` -- `src/vm/host.rs` - -Add: - -```rust -struct CallFrame { - return_ip: usize, - prev_frame_base: usize, - prev_locals_len: usize, - function_id: u16, -} -``` - -And VM state: - -- `frame_base: usize` -- `call_frames: Vec` - -Execution model: - -- `Ldloc index` reads `locals[frame_base + index]` -- `Stloc index` writes `locals[frame_base + index]` -- `CallScript`: - - validate arity against `Program.script_functions` - - save caller frame state - - extend `locals` by `frame_local_count`, filled with `Null` - - set `frame_base` to the new frame start - - move/copy stack args into `param_slots` - - jump `ip` to `entry_ip` -- `Ret`: - - if `call_frames` is empty, halt - - otherwise truncate locals to `prev_locals_len`, restore `frame_base`, set `ip = return_ip` - -Notes: - -- This integrates cleanly with the current `Value` stack model. -- Host/builtin calls still use the current stack-based ABI. -- `call_depth()` should be redefined to report total logical call depth, not only host-call nesting. - -### 3. Compiler lowering - -Files: - -- `src/compiler/codegen.rs` -- `src/compiler/pipeline.rs` -- `src/compiler/mod.rs` - -Add a new compiler path for separately emitted script functions: - -- Partition `FunctionImpl`s into: - - `script_callable`: emit once, invoked by `CallScript` - - `inline_only`: keep current behavior -- `compile_function_call` becomes: - - builtin/host import -> `Call` - - script function in `script_callable` -> `CallScript` - - script function in `inline_only` -> existing inline path -- After compiling root statements, emit all `script_callable` bodies once and record `entry_ip` - -Important compiler choice: - -- The first implementation should keep current slot numbers inside function bodies. -- Use `collect_function_frame_slots` to compute `frame_local_count`. -- Do not attempt to renumber locals per function yet. - -That avoids touching: - -- parser local numbering -- lifetime availability -- liveness/coloring -- most type-inference slot bookkeeping - -### 4. Function entry type state - -Separate function-body emission cannot depend on caller-time `type_state`. - -Files: - -- `src/compiler/typing.rs` -- `src/compiler/typing/context.rs` -- `src/compiler/typing/collect.rs` -- `src/compiler/codegen.rs` - -Required addition: - -- Record a function-entry typing snapshot for each separately emitted script function. - -At minimum this snapshot must provide: - -- known parameter types -- optional/schema state for parameter slots -- callable bindings that are valid at function entry - -Reason: - -- current codegen uses `type_state` when emitting operand type hints and some type-directed lowering decisions -- inline codegen gets this state from the caller naturally; separate function emission does not - -If a full `LocalTypeState` snapshot per function is too expensive initially, a narrower `FunctionEntryState` is acceptable as long as it seeds all type-directed codegen paths that exist today. - -### 5. Capturing nested functions - -This is the main semantic trap. Current nested `fn` declarations capture outer locals at declaration time, and inline lowering materializes those capture copies into the flat local space. - -Recommendation for the first milestone: - -- do not move capturing `fn` declarations to `CallScript` -- keep them inline-only until a declaration-environment design is added - -Recommended later design: - -- add a per-activation declaration environment table, keyed by function declaration index -- executing `Stmt::FuncDecl` snapshots the captures into the current activation -- calling a capturing nested function loads its declaration environment into the callee frame before jumping - -That later work is closure-adjacent, even if callables are still not first-class `Value`s. - -## Backend Strategy - -### Interpreter first - -Land the interpreter and compiler support before touching the native backends. - -### Trace JIT - -Files: - -- `src/vm/jit/trace.rs` -- `src/vm/jit/runtime.rs` -- `src/vm/jit/native/bridge.rs` -- `src/vm/jit/native/codegen.rs` - -First cut: - -- mark `CallScript` as unsupported in trace recording/codegen -- bail out to the interpreter when a trace would include `CallScript` - -That keeps the feature deliverable without blocking on native frame support. - -Later: - -- teach `TraceStep` about script calls and returns -- add frame-stack aware native bridge/runtime handling - -### Native AOT - -First cut: - -- either reject programs containing `CallScript` for native-only AOT -- or force interpreter fallback when script-call opcodes are present - -Later: - -- add script frame metadata to the AOT bundle -- teach generated native code how to enter/leave script frames - -## Debugger and tooling - -Files: - -- `src/debug_info.rs` -- `src/debugger/mod.rs` -- `src/bin/pd-vm-run.rs` - -Current debug info is flat and local-index based. Real frames need frame-aware introspection. - -Recommended additions: - -- function entry/range metadata in `DebugInfo` -- a VM API to inspect the script call stack -- debugger views that show locals for the current frame, not the entire flat local array - -This does not need to block the first executable version, but it should be part of the same effort before calling the feature complete. - -## Public API and compatibility - -Expected breaking or additive changes: - -- `Program` grows a `script_functions` table -- VMBC version bump -- possibly AOT version bump -- `CompiledProgram.functions` should stop meaning "all non-inlined declared functions" - -Recommended compiler API cleanup: - -- keep `CompiledProgram.functions` for host-visible imports only, or rename it -- if script function metadata is exposed publicly, return it in a separate field - -This avoids breaking CLI/runtime code that currently treats `CompiledProgram.functions` as host bindings to register. - -## Rollout Plan - -### Milestone 1: metadata and interpreter semantics - -- Add `CallScript`, `ScriptFunction`, and VM frame stack support -- Update `Ret` semantics -- Add direct unit tests for recursive and repeated script calls at the bytecode level -- Keep compiler unchanged except for a temporary bytecode constructor test path if needed - -### Milestone 2: compiler emission for non-capturing functions - -- Partition script-callable vs inline-only `FunctionImpl`s -- Emit root code plus appended function bodies -- Emit `CallScript` for direct and statically-known local calls to script-callable functions -- Preserve inline lowering for closures and capturing nested functions - -### Milestone 3: typing and debug correctness - -- Add function-entry type snapshots -- Restore operand type hints and any type-directed codegen inside separately emitted bodies -- Make debugger/frame inspection useful again - -### Milestone 4: backend containment - -- Make trace JIT and AOT explicitly reject or side-exit on `CallScript` -- Add tests to ensure no silent miscompile occurs when JIT/AOT sees the new opcode - -### Milestone 5: capturing nested functions - -- Add declaration environments -- Migrate capturing nested `fn` declarations off the inline path -- Revisit closure alignment after nested `fn` semantics are stable - -## Test Matrix - -Add tests for: - -- repeated direct calls do not duplicate function bytecode bodies -- direct recursion works -- mutual recursion works for script-callable functions -- locals are isolated between caller and callee frames -- returned values still flow through the existing operand stack -- host calls inside a script-called function still work -- host `Yield` and `Pending` inside a script-called function preserve the script call stack correctly -- inline-only paths still behave exactly as before -- JIT/AOT reject or fall back cleanly when `CallScript` appears - -## Risks - -### Risk 1: capture semantics creep - -Trying to support capturing nested functions in the first patch will turn this into a closure-runtime project. Avoid that. - -### Risk 2: type-state regressions - -Separately emitted function bodies no longer inherit caller context. If function-entry typing is not restored, operand type hints and type-directed lowering will silently degrade. - -### Risk 3: debugger confusion - -Flat local displays will become misleading once multiple frames exist. - -### Risk 4: backend skew - -If interpreter support lands without explicit JIT/AOT rejection, native paths may mis-handle the new opcode. - -## Recommendation - -Implement this as an interpreter-first, non-capturing-function-first change. - -That gives the project: - -- real script call frames -- recursive direct function support -- elimination of bytecode duplication for common direct-call cases - -without forcing the much larger "callable as real runtime value" design or full closure environments into the same patch series. +# Real Script Call Frames Implementation Plan + +**Goal:** Compile every RSS function body once and execute direct calls, function values, closures, recursion, suspension, and Rust invocation through one real-frame model with identical interpreter, Trace JIT, and AOT behavior. + +First-class value typing, generic schemas, lifecycle, and retained callback APIs are completed by [function_as_value_plan.md](function_as_value_plan.md). This plan defines the shared bytecode/frame contract they use. + +## 1. Freeze the opcode and return contracts + +**Target:** Add one opcode and remove context-dependent return semantics. + +**Complete:** + +- Keep `Call(index: u16, argc: u8)` exclusively for direct builtin/host-import dispatch. +- Add only `CallValue(argc: u8)` for every RSS function-item/closure invocation and host function used as a value. +- Define the `CallValue` stack ABI: before execution the stack tail is `callee, arg0, ..., argN`; the opcode removes that segment, enters the selected target, and eventually replaces it with exactly one result (`Null` for unit). +- Give `Ret` one semantic: complete the active execution frame and transfer its normalized result to that frame's continuation. +- Represent root execution explicitly as a frame whose continuation is `Halt`; represent nested RSS calls with `ResumeBytecode { return_ip, ... }`; represent Rust/API invocation roots with `ReturnToHost`. +- Never define `Ret` by checking whether the script-frame stack happens to be empty. Root `Ret` produces `VmStatus::Halted` because its explicit continuation says so. +- Reuse existing root bytecode ending in `Ret`; no `Halt` or `CallScript` opcode is added. +- Treat `CallValue` as a cross-cutting bytecode ABI change, with these required edits: + - opcode definition/decoding: `src/bytecode.rs` (`OpCode`, `TryFrom`, operand length, mnemonic, decoded instruction metadata); + - assembly: `src/assembler.rs` builders, textual parser, disassembler output, truncation/error tests; + - Program/value model: callable prototypes, script-function regions, `Value::Callable`, `ValueType::Callable`, clone/drop/equality/formatting, Program cache hashing; + - compiler: `src/compiler/ir.rs`, `codegen.rs`, `pipeline.rs`, typing, lifetime/capture analysis, linker remapping, source/debug metadata; + - interpreter/runtime: `src/vm/mod.rs`, `host.rs`, `store.rs`, frame entry/return, suspension/resume, reset/replacement, Rust invocation roots; + - Trace JIT: recorder/decoder, trace IR, SSA/liveness/deopt, native lowering, runtime dispatch, cache keys, diagnostics and trace-continuity counters; + - AOT: CFG function regions, IR/SSA, native compile/runtime bridges, artifact metadata, dynamic target dispatch and continuation handling; + - tooling: VMBC validation, no-std decode/execution, debugger/recording/replay, CLI inspection, REPL state, tests and fixtures. +- Apply a hard internal format break; no backward decoder, migration, opcode reinterpretation, or compatibility branch is required: + - bump VMBC `ENCODE_VERSION` from 8 to 9 and accept only version 9; + - bump AOT artifact `VERSION` from 2 to 3 and native `ABI_VERSION` from 1 to 2 because Program/VM/native layouts and return control flow change; + - bump debugger recording `PDRC` from 2 to 3, accept only version 3, and remove legacy version-1 recording acceptance; + - add the bytecode ABI revision to Program/native trace cache keys together with callable tables, function regions, and slot layouts so old in-memory entries cannot match; + - regenerate internal fixtures/artifacts and make every old format fail with its existing unsupported-version error. + +**Acceptance:** `Ret` has one frame-completion rule in the interpreter, Trace IR, AOT IR, debugger, and public invocation API; root halt and nested return are selected only by typed continuation metadata. + +## 2. Add Program-owned targets and real frames + +**Target:** Separate executable definitions from runtime callable instances without retaining old Programs. + +**Complete:** + +- Add `Program.script_functions` and `Program.callable_prototypes` containing entry IP, arity, frame-local count, parameter slots, slot-location layout, capture layout, source metadata, and instantiated generic signature. +- Add `Value::Callable { program_instance, prototype_id, kind, env }`; it stores no `Arc`. +- Assign a fresh `ProgramInstanceId` on Program installation/replacement/reset. `CallValue` validates it before prototype lookup and returns `StaleCallable` for old handles. +- Add an execution-frame stack. Each frame records: + - typed continuation (`Halt`, `ResumeBytecode`, or `ReturnToHost`); + - prototype/function identity; + - frame base and local extent; + - operand-stack base; + - active callable/environment root; + - recursion, fuel/epoch, suspension, and debug state required for exact resume. +- Resolve logical locals through metadata such as `SlotLocation::Frame(offset)` or `SlotLocation::Capture(cell)`. A logical slot must map to exactly one storage class in each prototype. +- On `CallValue`, validate value kind, Program instance, arity, and recursion limit; preserve caller state; allocate callee locals; bind arguments/self/environment; then enter the target. +- On `Ret`, normalize the result, release callee roots/locals, restore the typed continuation, and either resume bytecode, return to Rust, or report halt. +- Emit root code and every RSS function body once in one code blob, with explicit root/function regions. +- Validate `Br` and `Brfalse` targets remain inside their current root/function region; cross-function control flow is valid only through `CallValue` and `Ret`. + +**Acceptance:** Repeated calls share one body; direct recursion, mutual recursion, nested calls, local isolation, return values, errors, yield/pending, cancellation, and resume preserve exact frame and ownership state. + +## 3. Lower every RSS callable path to the shared ABI + +**Target:** Remove script-body inlining as a runtime fallback while preserving capture behavior. + +**Complete:** + +- Record one Program-owned prototype for every function item or closure body and one script-function entry for every emitted RSS body. +- Initialize capture-free named bindings from Program metadata without a creation opcode. +- Lower direct or indirect RSS calls by loading the applicable callable value and emitting `CallValue`; keep `Call` only for direct host/builtin imports. +- Preserve source-order visibility: Program binding initialization must not make a reference before its declaration valid. +- Bind recursive self-reference through prototype/frame metadata, not a separate opcode. +- Convert `capture_copies` into environment/slot layouts while preserving `CaptureBindingMode`: + - `Copy` receives an independent captured value; + - `Borrow`/`BorrowMut` share one cell with the outer frame and aliases; + - `Move` transfers the value into the environment and invalidates the source. +- Give separate closure evaluations separate environments and aliases of one callable the same environment. +- Seed separately emitted bodies from function-entry typing snapshots instead of caller-time `type_state`. +- Keep generic substitution in schemas/prototypes and runtime dispatch erased. +- Allow implementation sequencing internally, but do not mark the feature complete while any RSS function/closure call still depends on inline-body fallback. + +**Acceptance:** Named functions, closure expressions, capturing nested functions, returned/stored callables, branch/loop merges, generics, recursion, and escaping environments all use `CallValue` plus real frames. + +## 4. Resolve interactions with existing opcodes and native backends + +**Target:** Prevent hidden semantic conflicts and keep calls inside native execution. + +**Complete:** + +- Audit existing opcodes under callable values: + - `Call` retains its host-import index namespace and never dispatches RSS prototypes; + - `Ldloc`/`Stloc` use the current prototype's unambiguous slot-location map; + - `Br`/`Brfalse` cannot cross root/function regions; + - `Ldc` cannot deserialize Program-bound callable constants; + - `Pop` and `Dup` apply normal callable/environment clone/drop ownership; + - `Ceq` compares function items by Program instance/prototype identity and closure aliases by callable/environment identity; separate closure evaluations compare unequal; + - arithmetic, bitwise, ordering, and shift opcodes reject callable operands through existing type-error paths. +- Remove or redesign the current interpreter `Call + Ret` fusion. It may run only when typed continuation/frame semantics, fuel ticks, epoch checks, result normalization, and lifecycle behavior remain identical to unfused execution. +- Change the current interpreter mapping from unconditional `Ret -> Halted` to frame completion. +- Change AOT CFG/SSA so `Ret` terminates the current function region and returns to its native continuation; only root continuation reports halt. +- Change Trace JIT so `CallValue`, callee execution, and `Ret` remain in one compiled trace; recording resumes at the caller continuation after return. +- Lower dynamic callable target resolution to native dispatch. Target variation, nested calls, captures, errors, and suspension must not produce feature side exits, trace interruption, interpreter handoff, or AOT rejection. +- Preserve frame bases, locals, operand state, callable/environment roots, fuel/epoch accounting, host-call continuation, and suspension state in all three backends. + +**Acceptance:** Opcode behavior is determined by explicit Program/frame metadata, and interpreter, Trace JIT, and AOT produce identical values, errors, ticks, traces, and drops without callable-induced fallback. + +## 5. Complete tooling and verification + +**Target:** Make the new frame model observable, version-safe, and releasable. + +**Complete:** + +- Update assembler/disassembler, operand decoding, VMBC validation, no-std execution, AOT bundles, REPL replacement, recording/replay, formatter output, and source diagnostics. +- Add function-region/prototype/frame data to debug info, stack traces, current-frame local inspection, and Rust invocation status. +- Add focused tests for root `Ret`, nested `Ret`, Rust-root `Ret`, `CallValue` stack shape, stale handles, arity/type errors, region-confined branches, slot-location validation, callable equality, `Pop`/`Dup` lifecycle, and rewritten `Call + Ret` optimization. +- Add interpreter/JIT/AOT parity tests for direct/dynamic calls, recursion, captures, host calls, errors, yield/pending/resume, cancellation, reset, and exact drop counts. +- Instrument native tests to assert zero callable-induced side exits, trace breaks, interpreter handoffs, and rejected valid AOT Programs. +- Run focused suites during implementation, then formatting, workspace tests, Clippy with `-D warnings`, and release builds. + +**Acceptance:** All RSS callable paths use real frames, `Ret` has no context-dependent bytecode meaning, existing opcodes have explicit callable/frame contracts, and every supported backend passes the same behavior and lifecycle gates. From 870e3ae33fcfb58e83cb2c27989ed6fed1212352 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 16:31:02 +0800 Subject: [PATCH 02/18] feat(vm): add real script call frames --- README.md | 57 +- docs/callable-runtime.md | 43 + examples/mini_bench.rs | 64 +- pd-vm-nostd/README.md | 4 +- pd-vm-nostd/src/error.rs | 10 + pd-vm-nostd/src/lib.rs | 7 +- pd-vm-nostd/src/program.rs | 86 +- pd-vm-nostd/src/value.rs | 47 +- pd-vm-nostd/src/vm.rs | 290 +++- pd-vm-nostd/src/vmbc.rs | 113 +- pd-vm-nostd/tests/embedded_host.rs | 15 + pd-vm-nostd/tests/embedded_vmbc.rs | 4 +- src/assembler.rs | 38 + src/builtins/runtime/core.rs | 44 +- src/builtins/runtime/json.rs | 3 + src/builtins/runtime/print.rs | 5 + src/bytecode.rs | 133 +- src/cli.rs | 9 + src/compiler/codegen.rs | 1180 ++++++++--------- src/compiler/ir.rs | 10 +- src/compiler/lifetime/availability.rs | 13 +- .../lifetime/availability/captures.rs | 2 +- .../lifetime/availability/consumption.rs | 4 +- src/compiler/lifetime/liveness.rs | 12 +- src/compiler/linker.rs | 3 +- src/compiler/parser/expressions.rs | 37 +- src/compiler/parser/mod.rs | 118 ++ src/compiler/parser/statements.rs | 65 +- src/compiler/pipeline.rs | 9 +- src/compiler/source_loader/line_map.rs | 2 +- src/compiler/typing/collect.rs | 2 +- src/compiler/typing/context.rs | 50 +- src/compiler/typing/helpers.rs | 16 +- src/compiler/typing/state.rs | 8 +- src/compiler/typing/validate.rs | 40 +- src/debugger/recording.rs | 265 +++- src/debugger/tests.rs | 29 + src/lib.rs | 9 +- src/vm/aot/artifact.rs | 6 +- src/vm/aot/cfg.rs | 13 +- src/vm/aot/compile.rs | 19 + src/vm/aot/ir.rs | 10 + src/vm/aot/runtime.rs | 7 +- src/vm/aot/ssa.rs | 43 +- src/vm/jit/native/lower.rs | 7 +- src/vm/jit/recorder.rs | 48 +- src/vm/jit/runtime.rs | 6 + src/vm/mod.rs | 765 ++++++++++- src/vm/native/bridge.rs | 1 + src/vm/store.rs | 290 +++- src/vm/tests.rs | 300 +++++ src/vmbc.rs | 349 ++++- tests/compiler/compiler_common_tests.rs | 8 +- tests/compiler/compiler_rustscript_tests.rs | 226 +++- tests/compiler/module_import_tests.rs | 2 +- tests/compiler/type_inference_tests.rs | 9 +- tests/jit/jit_tests.rs | 35 +- tests/vm/runtime_state_edge_tests.rs | 12 +- tests/wire/assembler_vmbc_edge_tests.rs | 12 +- tests/wire/wire_tests.rs | 103 ++ 60 files changed, 4169 insertions(+), 948 deletions(-) create mode 100644 docs/callable-runtime.md diff --git a/README.md b/README.md index 9ec5dcbd..b68f5a23 100644 --- a/README.md +++ b/README.md @@ -341,10 +341,10 @@ Browser playground wasm runtime is provided by sibling crate `pd-vm-wasm` built ### `no_std` Embedded Runtime -The sibling crate [`pd-vm-nostd`](pd-vm-nostd) provides the VMBC v8 decoder and compact interpreter -using only `core` and `alloc`. It supports direct bytecode execution, synchronous host callbacks, -and instruction fuel while leaving source compilation, CLI, debugger, JIT/AOT, async host -operations, and operating-system integrations in `pd-vm`. +The sibling crate [`pd-vm-nostd`](pd-vm-nostd) provides the VMBC v9 decoder and compact interpreter +using only `core` and `alloc`. It supports direct bytecode execution, script call frames and callable +values, synchronous host callbacks, and instruction fuel while leaving source compilation, CLI, +debugger, JIT/AOT, async host operations, and operating-system integrations in `pd-vm`. RP2040 compile check: @@ -654,38 +654,39 @@ Directives: - `.local NAME [INDEX]` defines a named local -#### Builtins and Bridged `call` Opcode +#### Host Calls and Callable Values -The compiler uses one call shape (`Expr::Call` -> `OpCode::Call`) and distinguishes targets by call -index. +The compiler keeps direct host dispatch separate from script callable dispatch. 1. Builtin calls (fixed reserved indices) - - Builtins use `BuiltinFunction::call_index()` - - parser lowering emits these for helpers such as `len`, `get`, `set`, `slice`, `count`, - `type_of`, `assert`, and `io::*`/`re::*`/`json::*`/`jit::*` + - Builtins use `BuiltinFunction::call_index()`. + - Parser lowering emits these for helpers such as `len`, `get`, `set`, `slice`, `count`, + `type_of`, `assert`, and `io::*`/`re::*`/`json::*`/`jit::*`. 2. Runtime host imports (per-program remapped indices) - - non-inlined runtime imports are remapped to dense import slots (`call_index_remap`) - - emitted as `call , ` - - exposed as `Program.imports` and bound via `HostFunctionRegistry` - - `runtime::sleep(ms)` is available as a default host import; native runtimes block for the requested duration and wasm runtimes return `true` immediately -3. Inlined RustScript function bodies - - calls to targets with `FunctionImpl` are inlined (no emitted `call`) - -At runtime, `call` is bridged through `Vm::execute_host_call`: - -- builtin call indices dispatch to `vm/builtin_runtime.rs` -- non-builtin indices dispatch to bound host imports -- trace-JIT records supported hot paths into SSA and falls back to the interpreter for call-heavy - or otherwise unsupported traces, preserving interpreter semantics + - Runtime imports are remapped to dense import slots (`call_index_remap`). + - Direct calls are emitted as `call , `. + - Host functions used as values are Program-owned callable prototypes and execute through + `callvalue `. +3. RustScript function items and closures + - Every script body is emitted once in a function region. + - Named functions and closure evaluations produce callable values; script invocation uses + `callvalue ` and a real execution frame. + - `ret` completes the active typed continuation: root halt, caller resume, or host return. + +At runtime, direct `call` uses `Vm::execute_host_call`, while `callvalue` validates the callable's +Program instance, prototype, schema, arity, and frame layout before dispatch. VMBC v9 carries the +script-function, prototype, function-region, and root-binding tables. See +[`docs/callable-runtime.md`](docs/callable-runtime.md) for the bytecode, lifecycle, callback, and +optimized-backend contracts. #### Current Compiler Subset Limitations Core compiler/IR: -- callable locals can be passed and called, but callables are not runtime `Value`s -- callable values cannot currently be stored in arrays/maps or returned from functions +- callable locals can be passed, returned, called, and stored as array or map values +- callable values cannot be used as map keys or serialized as constants - callable return-type inference propagates through direct named calls, callable locals, closures, - and callable parameters when the compiler can still identify the callee + and callable parameters when the compiler can identify a compatible signature - known `if`/`else` expression results and branch-local merges with incompatible concrete types are compile errors - RustScript uses explicit nullable schemas such as `int?` and `Profile?`; non-optional declared @@ -695,8 +696,8 @@ Core compiler/IR: that binds `Some(name)` - after optional handling, the compiler and wasm lint keep the concrete inner type instead of degrading back to `unknown` -- recursive RustScript function declarations are not supported by current inlining-based lowering -- function declarations can be nested and implicitly capture outer locals (closure-like snapshot at declaration time) +- direct and mutual recursion are supported with a 1,024-frame script recursion limit +- function declarations can be nested and implicitly capture outer locals at declaration time - in RustScript move-semantics mode, implicit captures follow expression semantics (`x` may move, `x.copy()` copies, `&x`/`&mut x` capture borrowed views) - `match` patterns are limited to int/string/null literals, `None`, `Some(name)`, `_`, and type constructors (`Some(TypeName)`) - `break` and `continue` are only valid inside loops diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md new file mode 100644 index 00000000..866098ec --- /dev/null +++ b/docs/callable-runtime.md @@ -0,0 +1,43 @@ +# Script call frames and callable values + +RustScript bytecode format version 9 introduces runtime script call frames and first-class callable values. + +## Bytecode contract + +- `call ` remains the direct host/builtin operation. +- `callvalue ` consumes a stack segment in `callee, arg0, ..., argN` order. +- `makecallable ` consumes the capture values declared by the prototype and pushes a callable value. +- `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 v9 is a hard format boundary. Decoders reject older versions. The stream includes script-function entry ranges, callable prototypes, function regions, and root callable bindings. PDRC recordings and AOT artifacts use their corresponding bumped format/ABI versions and include callable metadata in cache identity. + +## Runtime model + +Each script invocation owns: + +- a typed continuation (`ResumeBytecode`, `ReturnToHost`, or root `Halt`); +- operand-stack and local-stack bases; +- 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. + +Branches are restricted to the active function region. Validation rejects cross-region targets before execution, and the interpreter repeats the check at runtime. + +## Callable identity and lifetime + +A callable contains the owning program-instance ID, prototype ID, kind, and optional environment. Capture-free function items compare by program/prototype identity. Closures compare by runtime identity. Callable constants are forbidden; functions are initialized from program metadata and closures are materialized at their declaration site. + +Resetting or replacing the VM program assigns a new program-instance ID. Calling a value from an older instance reports `StaleCallable`. + +`Vm::invoke_callable` is the synchronous host-entry API for a callable retained after the root program halts. For resumable work, `Vm::start_callable` returns `VmStatus`; after `Yielded` or `Waiting`, continue with `Vm::resume` and read the completed value with `Vm::take_callable_result`. `ScriptCallback::start` and `Store::take_callback_result` expose the same flow for typed callbacks. `Store::script_callback` validates the Store identity, Program instance, arity, and copied callable schema and returns a typed `ScriptCallback`. A callback can invoke directly, create a `Send` queued invocation on another thread, unsubscribe all aliases, or enter the Store FIFO through `enqueue_callback`; queue errors propagate and no implicit coalescing occurs. `Vm::shutdown` clears queued work, runtime values and host resources before invalidating every exported callable. + +PDRC recordings preserve full execution-frame metadata. Callable environments use identity-table encoding, so aliases still share one environment after decode. + +## Optimized backends + +Whole-program AOT treats callable creation and invocation as verified interpreter boundaries: live stack/local state is materialized and execution resumes at the boundary opcode. Trace JIT records the same operations as explicit side exits. Script-frame bodies currently execute through the frame-aware interpreter after that boundary; the backend never treats `callvalue` as an ordinary host call. + +## Embedded runtime + +`pd-vm-nostd` decodes the same VMBC v9 callable metadata and executes `makecallable`, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`. diff --git a/examples/mini_bench.rs b/examples/mini_bench.rs index 2ec4c0e9..379f25da 100644 --- a/examples/mini_bench.rs +++ b/examples/mini_bench.rs @@ -18,6 +18,7 @@ const DEFAULT_RUN_TRIALS: usize = 7; const DEFAULT_RSS_VM_COUNT: usize = 256; const DEFAULT_HOT_LOOP_INNER: i64 = 40_000; const DEFAULT_HOT_LOOP_OUTER: i64 = 8; +const DEFAULT_CALLBACK_ITERS: usize = 50_000; const LOAD_HOST_COUNTS: [usize; 6] = [0, 1, 10, 50, 100, 500]; fn main() { @@ -34,11 +35,16 @@ fn real_main() -> Result<(), String> { println!("{}", sample.to_child_line()); return Ok(()); } + if config.callback_only { + benchmark_retained_callbacks(&config)?; + return Ok(()); + } print_banner(&config); benchmark_compile(&config)?; benchmark_load(&config)?; benchmark_runtime(&config)?; + benchmark_retained_callbacks(&config)?; benchmark_rss(&config)?; Ok(()) } @@ -91,6 +97,8 @@ struct BenchConfig { rss_vm_count: usize, hot_loop_inner: i64, hot_loop_outer: i64, + callback_iters: usize, + callback_only: bool, rss_child_mode: Option, } @@ -105,6 +113,8 @@ impl Default for BenchConfig { rss_vm_count: DEFAULT_RSS_VM_COUNT, hot_loop_inner: DEFAULT_HOT_LOOP_INNER, hot_loop_outer: DEFAULT_HOT_LOOP_OUTER, + callback_iters: DEFAULT_CALLBACK_ITERS, + callback_only: false, rss_child_mode: None, } } @@ -141,6 +151,10 @@ impl BenchConfig { "--hot-loop-outer" => { config.hot_loop_outer = parse_i64_flag("--hot-loop-outer", args.next())?; } + "--callback-iters" => { + config.callback_iters = parse_usize_flag("--callback-iters", args.next())?; + } + "--callback-only" => config.callback_only = true, "--rss-child" => { let value = args .next() @@ -164,6 +178,7 @@ impl BenchConfig { || config.load_iters == 0 || config.run_trials == 0 || config.rss_vm_count == 0 + || config.callback_iters == 0 { return Err("iteration counts must be >= 1".to_string()); } @@ -199,12 +214,14 @@ fn print_help() { println!(" --rss-vms "); println!(" --hot-loop-inner "); println!(" --hot-loop-outer "); + println!(" --callback-iters "); + println!(" --callback-only"); } fn print_banner(config: &BenchConfig) { println!("pd-vm mini benchmark platform"); println!( - "config: compile_iters={} compile_stress_lines={} load_iters={} load_locals={} run_trials={} rss_vms={} hot_loop_inner={} hot_loop_outer={} native_jit_supported={}", + "config: compile_iters={} compile_stress_lines={} load_iters={} load_locals={} run_trials={} rss_vms={} hot_loop_inner={} hot_loop_outer={} callback_iters={} native_jit_supported={}", config.compile_iters, config.compile_stress_lines, config.load_iters, @@ -213,6 +230,7 @@ fn print_banner(config: &BenchConfig) { config.rss_vm_count, config.hot_loop_inner, config.hot_loop_outer, + config.callback_iters, native_jit_supported() ); println!(); @@ -322,6 +340,50 @@ fn benchmark_runtime(config: &BenchConfig) -> Result<(), String> { Ok(()) } +fn benchmark_retained_callbacks(config: &BenchConfig) -> Result<(), String> { + println!("[callback]"); + let compiled = compile_source_with_flavor( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one; + "#, + SourceFlavor::RustScript, + ) + .map_err(|err| format!("callback compile failed: {err}"))?; + let mut vm = Vm::new(compiled.program); + let status = vm + .run() + .map_err(|err| format!("callback root run failed: {err}"))?; + if status != VmStatus::Halted { + return Err(format!("callback root returned {status:?}")); + } + let callable = vm + .stack() + .last() + .cloned() + .ok_or_else(|| "callback root did not return a callable".to_string())?; + + let start = Instant::now(); + for value in 0..config.callback_iters { + let expected = i64::try_from(value).map_err(|_| "callback iteration overflow")? + 1; + let result = vm + .invoke_callable(callable.clone(), &[Value::Int(expected - 1)]) + .map_err(|err| format!("callback invocation failed: {err}"))?; + if result != Value::Int(expected) { + return Err(format!("callback returned {result:?}, expected {expected}")); + } + } + let elapsed = start.elapsed(); + println!( + " retained iters={:<10} total_ms={:<10} ns_per_call={:.1}", + config.callback_iters, + elapsed.as_millis(), + elapsed.as_nanos() as f64 / config.callback_iters as f64, + ); + println!(); + Ok(()) +} + fn benchmark_runtime_workload( label: &str, program: &Program, diff --git a/pd-vm-nostd/README.md b/pd-vm-nostd/README.md index 89b629c7..73554ffa 100644 --- a/pd-vm-nostd/README.md +++ b/pd-vm-nostd/README.md @@ -6,8 +6,8 @@ compiler, parser, CLI, debugger, JIT/AOT backends, filesystem support, and opera ## Runtime surface -- VMBC v8 decoding with type/debug metadata skipped after validation -- stack and local execution for direct bytecode opcodes +- VMBC v9 decoding with script-call and callable metadata +- 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 - `Rc`-backed strings, bytes, arrays, and maps for single-threaded targets diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index a611c60d..528d4bab 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -12,6 +12,10 @@ pub enum VmError { InvalidConstant(u32), InvalidLocal(u8), InvalidCall(u16), + InvalidCallable, + StaleCallable, + InvalidCallablePrototype(u32), + CallStackOverflow, InvalidCallArity { import: String, expected: u8, @@ -44,6 +48,12 @@ impl fmt::Display for VmError { Self::InvalidConstant(index) => write!(f, "invalid constant index: {index}"), Self::InvalidLocal(index) => write!(f, "invalid local index: {index}"), Self::InvalidCall(index) => write!(f, "invalid call index: {index}"), + Self::InvalidCallable => f.write_str("callvalue operand is not callable"), + Self::StaleCallable => f.write_str("callable belongs to another program instance"), + Self::InvalidCallablePrototype(index) => { + write!(f, "invalid callable prototype: {index}") + } + Self::CallStackOverflow => f.write_str("script call stack overflow"), Self::InvalidCallArity { import, expected, diff --git a/pd-vm-nostd/src/lib.rs b/pd-vm-nostd/src/lib.rs index e6821b94..948a66ca 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -16,8 +16,11 @@ mod vmbc; pub use error::{VmError, WireError}; pub use host::{HostBinding, HostDispatcher, HostError, HostFunction}; -pub use program::{HostImport, OpCode, Program, ValueType}; -pub use value::Value; +pub use program::{ + CallablePrototype, CallableTarget, FunctionRegion, HostImport, OpCode, Program, + RootCallableBinding, ScriptFunction, ValueType, +}; +pub use value::{CallableEnvironment, CallableKind, CallableValue, ProgramInstanceId, Value}; pub use vm::{Vm, VmResult, VmStatus}; pub use vmbc::decode_program; diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index d790a7bd..2bfd38d9 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -15,6 +15,7 @@ pub enum ValueType { Bytes = 6, Array = 7, Map = 8, + Callable = 9, } impl TryFrom for ValueType { @@ -31,11 +32,48 @@ impl TryFrom for ValueType { 6 => Ok(Self::Bytes), 7 => Ok(Self::Array), 8 => Ok(Self::Map), + 9 => Ok(Self::Callable), _ => Err(()), } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CallableTarget { + ScriptFunction(u32), + HostImport(u16), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScriptFunction { + pub entry_ip: u32, + pub end_ip: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallablePrototype { + pub kind: super::CallableKind, + pub target: CallableTarget, + pub arity: u8, + pub frame_local_count: usize, + pub parameter_slots: Vec, + pub capture_slots: Vec, + pub self_slot: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FunctionRegion { + pub start_ip: u32, + pub end_ip: u32, + pub prototype_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RootCallableBinding { + pub local_slot: u16, + pub prototype_id: u32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostImport { pub name: String, @@ -49,6 +87,10 @@ pub struct Program { code: Vec, local_count: usize, imports: Vec, + script_functions: Vec, + callable_prototypes: Vec, + function_regions: Vec, + root_callable_bindings: Vec, } impl Program { @@ -59,14 +101,48 @@ impl Program { code, local_count, imports, + script_functions: Vec::new(), + callable_prototypes: Vec::new(), + function_regions: Vec::new(), + root_callable_bindings: Vec::new(), } } pub(crate) fn with_local_count(mut self, local_count: usize) -> Self { - self.local_count = local_count; + self.local_count = self.local_count.max(local_count); self } + pub(crate) fn with_callable_metadata( + mut self, + script_functions: Vec, + callable_prototypes: Vec, + function_regions: Vec, + root_callable_bindings: Vec, + ) -> Self { + self.script_functions = script_functions; + self.callable_prototypes = callable_prototypes; + self.function_regions = function_regions; + self.root_callable_bindings = root_callable_bindings; + self + } + + pub fn script_functions(&self) -> &[ScriptFunction] { + &self.script_functions + } + + pub fn callable_prototypes(&self) -> &[CallablePrototype] { + &self.callable_prototypes + } + + pub fn function_regions(&self) -> &[FunctionRegion] { + &self.function_regions + } + + pub fn root_callable_bindings(&self) -> &[RootCallableBinding] { + &self.root_callable_bindings + } + pub fn constants(&self) -> &[Value] { &self.constants } @@ -112,13 +188,15 @@ pub enum OpCode { Or = 0x16, Not = 0x17, Lshr = 0x18, + CallValue = 0x19, + MakeCallable = 0x1a, } impl OpCode { pub const fn operand_len(self) -> usize { match self { - Self::Ldc | Self::Br | Self::Brfalse => 4, - Self::Ldloc | Self::Stloc => 1, + Self::Ldc | Self::Br | Self::Brfalse | Self::MakeCallable => 4, + Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, _ => 0, } @@ -155,6 +233,8 @@ impl TryFrom for OpCode { 0x16 => Ok(Self::Or), 0x17 => Ok(Self::Not), 0x18 => Ok(Self::Lshr), + 0x19 => Ok(Self::CallValue), + 0x1a => Ok(Self::MakeCallable), _ => Err(()), } } diff --git a/pd-vm-nostd/src/value.rs b/pd-vm-nostd/src/value.rs index 66058c7d..8a74265f 100644 --- a/pd-vm-nostd/src/value.rs +++ b/pd-vm-nostd/src/value.rs @@ -1,13 +1,32 @@ use alloc::rc::Rc; use alloc::string::String; use alloc::vec::Vec; +use core::cell::RefCell; pub type SharedString = Rc; pub type SharedBytes = Rc>; pub type SharedArray = Rc>; pub type SharedMap = Rc>; +pub type ProgramInstanceId = u64; +pub type SharedCallable = Rc; +pub type CallableEnvironment = Rc>>; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CallableKind { + FunctionItem, + Closure, + HostFunction, +} + +#[derive(Clone, Debug)] +pub struct CallableValue { + pub program_instance: ProgramInstanceId, + pub prototype_id: u32, + pub kind: CallableKind, + pub env: Option, +} + +#[derive(Clone, Debug)] pub enum Value { Null, Int(i64), @@ -17,6 +36,32 @@ pub enum Value { Bytes(SharedBytes), Array(SharedArray), Map(SharedMap), + Callable(SharedCallable), +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Null, Self::Null) => true, + (Self::Int(lhs), Self::Int(rhs)) => lhs == rhs, + (Self::Float(lhs), Self::Float(rhs)) => lhs.to_bits() == rhs.to_bits(), + (Self::Bool(lhs), Self::Bool(rhs)) => lhs == rhs, + (Self::String(lhs), Self::String(rhs)) => lhs == rhs, + (Self::Bytes(lhs), Self::Bytes(rhs)) => lhs == rhs, + (Self::Array(lhs), Self::Array(rhs)) => lhs == rhs, + (Self::Map(lhs), Self::Map(rhs)) => lhs == rhs, + (Self::Callable(lhs), Self::Callable(rhs)) => { + if lhs.env.is_none() && rhs.env.is_none() { + lhs.program_instance == rhs.program_instance + && lhs.prototype_id == rhs.prototype_id + && lhs.kind == rhs.kind + } else { + Rc::ptr_eq(lhs, rhs) + } + } + _ => false, + } + } } impl Value { diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 95bd65ed..e30ebde0 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -1,11 +1,13 @@ +use core::cell::RefCell; + use alloc::rc::Rc; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; use super::{ - HostBinding, HostDispatcher, HostFunction, OpCode, Program, Value, VmError, - resolve_host_functions, + CallableEnvironment, CallableTarget, CallableValue, HostBinding, HostDispatcher, HostFunction, + OpCode, Program, Value, VmError, resolve_host_functions, }; pub type VmResult = Result; @@ -21,6 +23,16 @@ enum NumericValue { Float(f64), } +#[derive(Clone, Debug)] +struct ExecutionFrame { + return_ip: usize, + operand_stack_base: usize, + local_base: usize, + local_count: usize, + prototype_id: u32, + active_callable: Rc, +} + pub struct Vm { program: Program, ip: usize, @@ -30,6 +42,8 @@ pub struct Vm { host_dispatcher: Option>, context: C, fuel: Option, + program_instance: u64, + frames: Vec, } impl Vm<()> { @@ -63,7 +77,7 @@ impl Vm { host_dispatcher: Option>, ) -> Self { let local_count = program.local_count(); - Self { + let mut vm = Self { program, ip: 0, stack: Vec::new(), @@ -72,7 +86,229 @@ impl Vm { host_dispatcher, context, fuel: None, + program_instance: 1, + frames: Vec::new(), + }; + vm.initialize_root_callables(); + vm + } + + fn initialize_root_callables(&mut self) { + for binding in self.program.root_callable_bindings() { + let Some(prototype) = self + .program + .callable_prototypes() + .get(binding.prototype_id as usize) + else { + continue; + }; + let slot = binding.local_slot as usize; + if let Some(local) = self.locals.get_mut(slot) { + *local = Value::Callable(Rc::new(CallableValue { + program_instance: self.program_instance, + prototype_id: binding.prototype_id, + kind: prototype.kind, + env: None, + })); + } + } + } + + fn active_local_base(&self) -> usize { + self.frames.last().map_or(0, |frame| frame.local_base) + } + + fn absolute_local(&self, index: u8) -> VmResult { + let absolute = self.active_local_base().saturating_add(index as usize); + (absolute < self.locals.len()) + .then_some(absolute) + .ok_or(VmError::InvalidLocal(index)) + } + + fn make_callable(&mut self, prototype_id: u32) -> VmResult<()> { + let prototype = self + .program + .callable_prototypes() + .get(prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + let capture_count = prototype.capture_slots.len(); + if self.stack.len() < capture_count { + return Err(VmError::StackUnderflow); + } + let captures = self.stack.split_off(self.stack.len() - capture_count); + let env: CallableEnvironment = Rc::new(RefCell::new(captures)); + self.stack.push(Value::Callable(Rc::new(CallableValue { + program_instance: self.program_instance, + prototype_id, + kind: prototype.kind, + env: Some(env), + }))); + Ok(()) + } + + fn call_value(&mut self, argc: u8) -> VmResult<()> { + if self.frames.len() >= 1024 { + return Err(VmError::CallStackOverflow); + } + let operand_count = argc as usize + 1; + if self.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let stack_base = self.stack.len() - operand_count; + let mut operands = self.stack.split_off(stack_base); + let callable = match operands.remove(0) { + Value::Callable(callable) => callable, + _ => return Err(VmError::InvalidCallable), + }; + if callable.program_instance != self.program_instance { + return Err(VmError::StaleCallable); + } + let prototype = self + .program + .callable_prototypes() + .get(callable.prototype_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + if prototype.arity != argc || prototype.parameter_slots.len() != operands.len() { + return Err(VmError::InvalidCallArity { + import: String::from("script callable"), + expected: prototype.arity, + got: argc, + }); + } + match prototype.target { + CallableTarget::HostImport(index) => { + self.stack.extend(operands); + self.call_host(index, argc) + } + CallableTarget::ScriptFunction(function_id) => { + let function = self + .program + .script_functions() + .get(function_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(callable.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() { + if let Some(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 { + program_instance: self.program_instance, + 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(callable.prototype_id)); + } + self.locals[local_base + slot] = argument; + } + if let Some(env) = &callable.env { + for (slot, value) in prototype + .capture_slots + .iter() + .zip(env.borrow().iter().cloned()) + { + let slot = *slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidCallablePrototype(callable.prototype_id)); + } + self.locals[local_base + slot] = value; + } + } + if let Some(slot) = prototype.self_slot { + let slot = slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidCallablePrototype(callable.prototype_id)); + } + self.locals[local_base + slot] = Value::Callable(callable.clone()); + } + self.frames.push(ExecutionFrame { + return_ip: self.ip, + operand_stack_base: stack_base, + local_base, + local_count: prototype.frame_local_count, + prototype_id: callable.prototype_id, + active_callable: callable, + }); + 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); + }; + if self.stack.len() < frame.operand_stack_base { + return Err(VmError::StackUnderflow); + } + let result = if self.stack.len() > frame.operand_stack_base { + self.stack.pop().unwrap_or(Value::Null) + } else { + Value::Null + }; + self.stack.truncate(frame.operand_stack_base); + if let Some(environment) = frame.active_callable.env.as_ref() + && let Some(prototype) = self + .program + .callable_prototypes() + .get(frame.prototype_id as usize) + { + let mut cells = environment.borrow_mut(); + for (cell, slot) in cells.iter_mut().zip(&prototype.capture_slots) { + if prototype.self_slot == Some(*slot) { + continue; + } + let absolute = frame.local_base.saturating_add(*slot as usize); + let value = self + .locals + .get(absolute) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(frame.prototype_id))?; + *cell = value; + } } + self.locals.truncate(frame.local_base); + self.ip = frame.return_ip; + self.stack.push(result); + Ok(true) } pub fn run(&mut self) -> VmResult { @@ -82,7 +318,11 @@ impl Vm { let opcode = OpCode::try_from(raw).map_err(|()| VmError::InvalidOpcode(raw))?; match opcode { OpCode::Nop => {} - OpCode::Ret => return Ok(VmStatus::Halted), + OpCode::Ret => { + if !self.return_from_frame()? { + return Ok(VmStatus::Halted); + } + } OpCode::Ldc => { let index = self.read_u32()?; let value = self @@ -128,23 +368,29 @@ impl Vm { } OpCode::Ldloc => { let index = self.read_u8()?; - let value = self - .locals - .get(usize::from(index)) - .cloned() - .ok_or(VmError::InvalidLocal(index))?; - self.stack.push(value); + let absolute = self.absolute_local(index)?; + self.stack.push(self.locals[absolute].clone()); } OpCode::Stloc => { let index = self.read_u8()?; + let absolute = self.absolute_local(index)?; let value = self.pop()?; - self.store_local(index, value)?; + self.locals[absolute] = value; } + OpCode::Call => { let index = self.read_u16()?; let arity = self.read_u8()?; self.call_host(index, arity)?; } + OpCode::CallValue => { + let arity = self.read_u8()?; + self.call_value(arity)?; + } + OpCode::MakeCallable => { + let prototype_id = self.read_u32()?; + self.make_callable(prototype_id)?; + } OpCode::Shl => { let rhs = self.pop_shift()?; let lhs = self.pop_int()?; @@ -480,11 +726,8 @@ impl Vm { } fn store_local(&mut self, index: u8, value: Value) -> VmResult<()> { - let slot = self - .locals - .get_mut(usize::from(index)) - .ok_or(VmError::InvalidLocal(index))?; - *slot = value; + let absolute = self.absolute_local(index)?; + self.locals[absolute] = value; Ok(()) } @@ -493,6 +736,21 @@ impl Vm { if target_index >= self.program.code().len() { return Err(VmError::InvalidJump(target)); } + if !self.program.function_regions().is_empty() { + let expected_prototype = self.frames.last().map(|frame| frame.prototype_id); + let target_prototype = self + .program + .function_regions() + .iter() + .find(|region| { + region.start_ip as usize <= target_index + && target_index < region.end_ip as usize + }) + .and_then(|region| region.prototype_id); + if target_prototype != expected_prototype { + return Err(VmError::InvalidJump(target)); + } + } self.ip = target_index; Ok(()) } diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index e8aa00ca..3d4512c2 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -1,10 +1,13 @@ use alloc::string::String; use alloc::vec::Vec; -use super::{HostImport, Program, Value, ValueType, WireError}; +use super::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, HostImport, Program, + RootCallableBinding, ScriptFunction, Value, ValueType, WireError, +}; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V8: u16 = 8; +const VERSION_V9: u16 = 9; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; @@ -16,7 +19,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V8 { + if version != VERSION_V9 { return Err(WireError::UnsupportedVersion(version)); } let flags = cursor.read_u16()?; @@ -54,15 +57,23 @@ pub fn decode_program(bytes: &[u8]) -> Result { let encoded_local_count = skip_type_map(&mut cursor)?; skip_debug_info(&mut cursor)?; + let (script_functions, callable_prototypes, function_regions, root_callable_bindings) = + read_callable_metadata(&mut cursor)?; if !cursor.is_empty() { return Err(WireError::TrailingBytes); } let program = Program::new(constants, code, imports); - Ok(match encoded_local_count { + let program = match encoded_local_count { Some(local_count) => program.with_local_count(local_count), None => program, - }) + }; + Ok(program.with_callable_metadata( + script_functions, + callable_prototypes, + function_regions, + root_callable_bindings, + )) } fn reserve(items: &mut Vec, field: &'static str, count: usize) -> Result<(), WireError> { @@ -167,6 +178,98 @@ fn skip_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { } } +type CallableMetadata = ( + Vec, + Vec, + Vec, + Vec, +); + +fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result { + let function_count = cursor.read_u32()? as usize; + let mut script_functions = Vec::new(); + reserve(&mut script_functions, "script functions", function_count)?; + for _ in 0..function_count { + script_functions.push(ScriptFunction { + entry_ip: cursor.read_u32()?, + end_ip: cursor.read_u32()?, + }); + } + + let prototype_count = cursor.read_u32()? as usize; + let mut prototypes = Vec::new(); + reserve(&mut prototypes, "callable prototypes", prototype_count)?; + for _ in 0..prototype_count { + let kind = match cursor.read_u8()? { + 0 => CallableKind::FunctionItem, + 1 => CallableKind::Closure, + 2 => CallableKind::HostFunction, + value => return Err(WireError::InvalidValueType(value)), + }; + let target_tag = cursor.read_u8()?; + let target_id = cursor.read_u32()?; + let target = match target_tag { + 0 => CallableTarget::ScriptFunction(target_id), + 1 => CallableTarget::HostImport(u16::try_from(target_id).map_err(|_| { + WireError::LengthTooLarge("host callable target", target_id as usize) + })?), + value => return Err(WireError::InvalidValueType(value)), + }; + let arity = cursor.read_u8()?; + let frame_local_count = cursor.read_u32()? as usize; + let parameter_count = cursor.read_u32()? as usize; + let mut parameter_slots = Vec::new(); + reserve(&mut parameter_slots, "callable parameters", parameter_count)?; + for _ in 0..parameter_count { + parameter_slots.push(cursor.read_u16()?); + } + let capture_count = cursor.read_u32()? as usize; + let mut capture_slots = Vec::new(); + reserve(&mut capture_slots, "callable captures", capture_count)?; + for _ in 0..capture_count { + capture_slots.push(cursor.read_u16()?); + } + let self_slot = cursor.read_bool()?.then(|| cursor.read_u16()).transpose()?; + if cursor.read_bool()? { + skip_schema(cursor, 0)?; + } + prototypes.push(CallablePrototype { + kind, + target, + arity, + frame_local_count, + parameter_slots, + capture_slots, + self_slot, + }); + } + + let region_count = cursor.read_u32()? as usize; + let mut regions = Vec::new(); + reserve(&mut regions, "function regions", region_count)?; + for _ in 0..region_count { + let start_ip = cursor.read_u32()?; + let end_ip = cursor.read_u32()?; + let prototype_id = cursor.read_bool()?.then(|| cursor.read_u32()).transpose()?; + regions.push(FunctionRegion { + start_ip, + end_ip, + prototype_id, + }); + } + + let binding_count = cursor.read_u32()? as usize; + let mut bindings = Vec::new(); + reserve(&mut bindings, "root callable bindings", binding_count)?; + for _ in 0..binding_count { + bindings.push(RootCallableBinding { + local_slot: cursor.read_u16()?, + prototype_id: cursor.read_u32()?, + }); + } + Ok((script_functions, prototypes, regions, bindings)) +} + fn skip_debug_info(cursor: &mut Cursor<'_>) -> Result<(), WireError> { match cursor.read_u8()? { 0 => Ok(()), diff --git a/pd-vm-nostd/tests/embedded_host.rs b/pd-vm-nostd/tests/embedded_host.rs index e732ace0..1bdd9649 100644 --- a/pd-vm-nostd/tests/embedded_host.rs +++ b/pd-vm-nostd/tests/embedded_host.rs @@ -40,6 +40,21 @@ fn dispatch_host( gpio_set(state, args) } +#[test] +fn script_call_frames_execute_in_embedded_runtime() { + let program = compile_embedded( + r#" + fn inc(x) { x + 1 } + fn twice(x) { inc(inc(x)) } + twice(40); + "#, + ); + let mut vm = EmbeddedVm::new(program); + + assert_eq!(vm.run(), Ok(VmStatus::Halted)); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(42)]); +} + #[test] fn static_host_binding_mutates_board_context() { let program = compile_embedded( diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 2dbc6c58..632fba82 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -26,9 +26,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v8() { +fn embedded_decoder_reads_host_generated_v9() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v8"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v9"); assert_eq!( program.code(), diff --git a/src/assembler.rs b/src/assembler.rs index defb61e1..04cdbf02 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -298,6 +298,16 @@ impl Assembler { self.emit_u16(index); self.emit_u8(argc); } + + pub fn call_value(&mut self, argc: u8) { + self.emit_opcode(OpCode::CallValue); + self.emit_u8(argc); + } + + pub fn make_callable(&mut self, prototype_id: u32) { + self.emit_opcode(OpCode::MakeCallable); + self.emit_u32(prototype_id); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -441,6 +451,16 @@ impl BytecodeBuilder { self.emit_u16(index); self.emit_u8(argc); } + + pub fn call_value(&mut self, argc: u8) { + self.emit_opcode(OpCode::CallValue); + self.emit_u8(argc); + } + + pub fn make_callable(&mut self, prototype_id: u32) { + self.emit_opcode(OpCode::MakeCallable); + self.emit_u32(prototype_id); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -734,6 +754,17 @@ pub fn assemble(source: &str) -> Result { let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; assembler.call(index, argc); } + OpCode::CallValue => { + let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; + assembler.call_value(argc); + } + OpCode::MakeCallable => { + let prototype_id = parse_u32( + next_token(&mut parts, line_no, "callable prototype id")?, + line_no, + )?; + assembler.make_callable(prototype_id); + } OpCode::Shl => assembler.shl(), OpCode::Shr => assembler.shr(), OpCode::Lshr => assembler.lshr(), @@ -792,6 +823,13 @@ fn parse_u16(token: &str, line_no: usize) -> Result { }) } +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 { line: line_no, diff --git a/src/builtins/runtime/core.rs b/src/builtins/runtime/core.rs index 747cbe9d..d47eb921 100644 --- a/src/builtins/runtime/core.rs +++ b/src/builtins/runtime/core.rs @@ -358,7 +358,10 @@ pub(super) fn builtin_has(args: &[Value]) -> VmResult { }; Ok(return_one(present)) } - Value::Map(entries) => Ok(return_one(entries.get(key).is_some())), + Value::Map(entries) => { + ensure_supported_map_key(key)?; + Ok(return_one(entries.get(key).is_some())) + } _ => Err(VmError::TypeMismatch("bytes/array/map")), } } @@ -381,6 +384,7 @@ pub(super) fn builtin_get(args: &[Value]) -> VmResult { })?)) } Value::Map(entries) => { + ensure_supported_map_key(key)?; Ok(return_one(entries.get(key).cloned().ok_or_else(|| { VmError::HostError("map key not found".to_string()) })?)) @@ -419,6 +423,7 @@ pub(super) fn builtin_type_of_impl(value: VmValueRef<'_>) -> String { Value::Bytes(_) => "bytes", Value::Array(_) => "array", Value::Map(_) => "map", + Value::Callable(_) => "callable", } .to_string() } @@ -474,6 +479,11 @@ fn render_value_for_display(value: &Value) -> String { .join(", "); format!("{{{parts}}}") } + Value::Callable(callable) => match callable.kind { + crate::CallableKind::FunctionItem => format!("", callable.prototype_id), + crate::CallableKind::Closure => format!("", callable.prototype_id), + crate::CallableKind::HostFunction => format!("", callable.prototype_id), + }, } } @@ -512,7 +522,7 @@ impl FormatArgument for Value { Value::String(_) | Value::Bytes(_) => { matches!(specifier.format, Format::Display | Format::Debug) } - Value::Array(_) | Value::Map(_) => { + Value::Array(_) | Value::Map(_) | Value::Callable(_) => { matches!(specifier.format, Format::Display | Format::Debug) } } @@ -525,7 +535,7 @@ impl FormatArgument for Value { Value::Float(value) => std::fmt::Display::fmt(value, f), Value::Bool(value) => std::fmt::Display::fmt(value, f), Value::String(value) => std::fmt::Display::fmt(value.as_str(), f), - Value::Bytes(_) | Value::Array(_) | Value::Map(_) => { + Value::Bytes(_) | Value::Array(_) | Value::Map(_) | Value::Callable(_) => { f.write_str(render_value_for_display(self).as_str()) } } @@ -553,6 +563,7 @@ impl FormatArgument for Value { } map.finish() } + Value::Callable(_) => f.write_str(render_value_for_display(self).as_str()), } } @@ -690,12 +701,24 @@ pub(crate) fn builtin_set_map_shared_impl( entries } +fn ensure_supported_map_key(key: &Value) -> VmResult<()> { + if matches!(key, Value::Callable(_)) { + return Err(VmError::HostError( + "callable values are not supported as map keys".to_string(), + )); + } + Ok(()) +} + pub(crate) fn builtin_set_owned(container: Value, key: Value, value: Value) -> VmResult { match container { Value::Array(values) => { builtin_set_array_shared_impl(values, key.as_int()?, value).map(Value::Array) } - Value::Map(entries) => Ok(Value::Map(builtin_set_map_shared_impl(entries, key, value))), + Value::Map(entries) => { + ensure_supported_map_key(&key)?; + Ok(Value::Map(builtin_set_map_shared_impl(entries, key, value))) + } _ => Err(VmError::TypeMismatch("array/map")), } } @@ -878,6 +901,19 @@ mod tests { } } + #[test] + fn callable_map_keys_are_rejected() { + let callable = Value::Callable(Arc::new(crate::CallableValue { + program_instance: 1, + prototype_id: 0, + kind: crate::CallableKind::FunctionItem, + env: None, + })); + let err = builtin_set_owned(Value::map(Vec::new()), callable, Value::Int(1)) + .expect_err("callable map key should fail"); + assert!(err.to_string().contains("not supported as map keys")); + } + #[test] fn array_push_detaches_shared_array_before_write() { let shared = Value::array(vec![Value::Int(1)]); diff --git a/src/builtins/runtime/json.rs b/src/builtins/runtime/json.rs index d09d465f..504d1602 100644 --- a/src/builtins/runtime/json.rs +++ b/src/builtins/runtime/json.rs @@ -60,6 +60,9 @@ fn vm_to_json_value(value: &Value) -> VmResult { } Ok(JsonValue::Object(out)) } + Value::Callable(_) => Err(VmError::HostError( + "json_encode does not support callable values".to_string(), + )), } } diff --git a/src/builtins/runtime/print.rs b/src/builtins/runtime/print.rs index db8a98b4..4a839b8f 100644 --- a/src/builtins/runtime/print.rs +++ b/src/builtins/runtime/print.rs @@ -24,6 +24,11 @@ pub fn format_value(value: &Value) -> String { .join(", "); format!("{{{parts}}}") } + Value::Callable(callable) => match callable.kind { + crate::CallableKind::FunctionItem => format!("", callable.prototype_id), + crate::CallableKind::Closure => format!("", callable.prototype_id), + crate::CallableKind::HostFunction => format!("", callable.prototype_id), + }, } } diff --git a/src/bytecode.rs b/src/bytecode.rs index 46ce7257..4f0c2dde 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -5,10 +5,71 @@ use std::sync::{Arc, OnceLock}; use crate::compiler::TypeSchema; +pub const BYTECODE_ABI_VERSION: u16 = 9; + pub type SharedString = Arc; pub type SharedBytes = Arc>; pub type SharedArray = Arc>; pub type SharedMap = Arc; +pub type SharedCallable = Arc; +pub type ProgramInstanceId = u64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CallableKind { + FunctionItem, + Closure, + HostFunction, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CallableTarget { + ScriptFunction(u32), + HostImport(u16), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ScriptFunction { + pub entry_ip: u32, + pub end_ip: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CallablePrototype { + pub kind: CallableKind, + pub target: CallableTarget, + pub arity: u8, + pub frame_local_count: usize, + pub parameter_slots: Vec, + pub capture_slots: Vec, + pub self_slot: Option, + pub schema: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FunctionRegion { + pub start_ip: u32, + pub end_ip: u32, + pub prototype_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RootCallableBinding { + pub local_slot: u16, + pub prototype_id: u32, +} + +#[derive(Debug)] +pub struct CallableEnvironment { + pub(crate) cells: std::sync::Mutex>, +} + +#[derive(Clone, Debug)] +pub struct CallableValue { + pub program_instance: ProgramInstanceId, + pub prototype_id: u32, + pub kind: CallableKind, + pub env: Option>, +} type VmMapStorage = HashMap>; @@ -233,6 +294,13 @@ fn hash_map_key(value: &Value, state: &mut impl Hasher) { 6u8.hash(state); Arc::as_ptr(entries).hash(state); } + Value::Callable(callable) => { + 7u8.hash(state); + callable.program_instance.hash(state); + callable.prototype_id.hash(state); + callable.kind.hash(state); + callable.env.as_ref().map(Arc::as_ptr).hash(state); + } } } @@ -248,6 +316,7 @@ fn map_key_eq(lhs: &Value, rhs: &Value) -> bool { (Value::Bytes(lhs), Value::Bytes(rhs)) => lhs == rhs, (Value::Array(lhs), Value::Array(rhs)) => Arc::ptr_eq(lhs, rhs), (Value::Map(lhs), Value::Map(rhs)) => Arc::ptr_eq(lhs, rhs), + (Value::Callable(lhs), Value::Callable(rhs)) => callable_value_eq(lhs, rhs), _ => false, } } @@ -307,6 +376,13 @@ pub(crate) fn hash_value(value: &Value, state: &mut impl Hasher) { entry_hash.hash(state); } } + Value::Callable(callable) => { + 7u8.hash(state); + callable.program_instance.hash(state); + callable.prototype_id.hash(state); + callable.kind.hash(state); + callable.env.as_ref().map(Arc::as_ptr).hash(state); + } } } @@ -328,6 +404,7 @@ pub enum Value { Bytes(SharedBytes), Array(SharedArray), Map(SharedMap), + Callable(SharedCallable), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -342,6 +419,7 @@ pub enum ValueType { Bytes = 6, Array = 7, Map = 8, + Callable = 9, } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -418,11 +496,26 @@ impl PartialEq for Value { (Self::Bytes(lhs), Self::Bytes(rhs)) => lhs == rhs, (Self::Array(lhs), Self::Array(rhs)) => lhs == rhs, (Self::Map(lhs), Self::Map(rhs)) => lhs == rhs, + (Self::Callable(lhs), Self::Callable(rhs)) => callable_value_eq(lhs, rhs), _ => false, } } } +fn callable_value_eq(lhs: &CallableValue, rhs: &CallableValue) -> bool { + if lhs.program_instance != rhs.program_instance + || lhs.prototype_id != rhs.prototype_id + || lhs.kind != rhs.kind + { + return false; + } + match (&lhs.env, &rhs.env) { + (None, None) => true, + (Some(lhs), Some(rhs)) => Arc::ptr_eq(lhs, rhs), + _ => false, + } +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct HostImport { pub name: String, @@ -487,6 +580,10 @@ pub struct Program { pub imports: Vec, pub debug: Option, pub type_map: Option, + pub script_functions: Vec, + pub callable_prototypes: Vec, + pub function_regions: Vec, + pub root_callable_bindings: Vec, #[allow(dead_code)] decoded_instruction_data_cache: Arc>>, operand_type_hints_cache: Arc>>>, @@ -502,6 +599,10 @@ impl Program { imports: Vec::new(), debug: None, type_map: None, + script_functions: Vec::new(), + callable_prototypes: Vec::new(), + function_regions: Vec::new(), + root_callable_bindings: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -520,6 +621,10 @@ impl Program { imports: Vec::new(), debug, type_map: None, + script_functions: Vec::new(), + callable_prototypes: Vec::new(), + function_regions: Vec::new(), + root_callable_bindings: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -539,6 +644,10 @@ impl Program { imports, debug, type_map: None, + script_functions: Vec::new(), + callable_prototypes: Vec::new(), + function_regions: Vec::new(), + root_callable_bindings: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -555,6 +664,20 @@ impl Program { self } + pub fn with_callable_metadata( + mut self, + script_functions: Vec, + callable_prototypes: Vec, + function_regions: Vec, + root_callable_bindings: Vec, + ) -> Self { + self.script_functions = script_functions; + self.callable_prototypes = callable_prototypes; + self.function_regions = function_regions; + self.root_callable_bindings = root_callable_bindings; + self + } + #[allow(dead_code)] pub(crate) fn shared_decoded_instruction_data(&self) -> Arc { Arc::clone( @@ -648,6 +771,8 @@ pub enum OpCode { Or = 0x16, Not = 0x17, Lshr = 0x18, + CallValue = 0x19, + MakeCallable = 0x1a, } impl TryFrom for OpCode { @@ -680,6 +805,8 @@ impl TryFrom for OpCode { x if x == Self::Or as u8 => Ok(Self::Or), 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::MakeCallable as u8 => Ok(Self::MakeCallable), _ => Err(()), } } @@ -708,7 +835,8 @@ impl OpCode { | Self::Not | Self::Lshr => 0, Self::Ldc | Self::Br | Self::Brfalse => 4, - Self::Ldloc | Self::Stloc => 1, + Self::MakeCallable => 4, + Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, } } @@ -740,6 +868,8 @@ impl OpCode { OpCode::Or => "or", OpCode::Not => "not", OpCode::Lshr => "lshr", + Self::CallValue => "callvalue", + Self::MakeCallable => "makecallable", } } @@ -770,6 +900,7 @@ impl OpCode { "or" => Some(OpCode::Or), "not" => Some(OpCode::Not), "lshr" => Some(OpCode::Lshr), + "callvalue" => Some(OpCode::CallValue), _ => None, } } diff --git a/src/cli.rs b/src/cli.rs index a5740e05..0eba9b3a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1285,6 +1285,10 @@ fn repl_schema_from_value(value: &Value) -> Option { Value::Bytes(_) => Some(TypeSchema::Bytes), Value::Array(_) => Some(TypeSchema::Array(Box::new(TypeSchema::Unknown))), Value::Map(_) => Some(TypeSchema::Map(Box::new(TypeSchema::Unknown))), + Value::Callable(_) => Some(TypeSchema::Callable { + params: Vec::new(), + result: Box::new(TypeSchema::Unknown), + }), } } @@ -1301,6 +1305,10 @@ fn repl_schema_from_value_type(value_type: vm::ValueType) -> Option Some(TypeSchema::Bytes), vm::ValueType::Array => Some(TypeSchema::Array(Box::new(TypeSchema::Unknown))), vm::ValueType::Map => Some(TypeSchema::Map(Box::new(TypeSchema::Unknown))), + vm::ValueType::Callable => Some(TypeSchema::Callable { + params: Vec::new(), + result: Box::new(TypeSchema::Unknown), + }), } } @@ -1477,6 +1485,7 @@ fn format_value(value: &Value) -> String { .join(", "); format!("{{{parts}}}") } + Value::Callable(callable) => format!("", callable.prototype_id), } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 2d2dda4e..70c63294 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -1,8 +1,11 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::HashMap; use crate::assembler::Assembler; use crate::builtins::BuiltinFunction; -use crate::{Program, TypeMap, Value, ValueType}; +use crate::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, Program, RootCallableBinding, + ScriptFunction, TypeMap, Value, ValueType, +}; use super::ir::{ ClosureExpr, Expr, FunctionDecl, FunctionImpl, LocalSlot, MatchPattern, MatchTypePattern, Stmt, @@ -20,12 +23,24 @@ pub struct Compiler { host_import_return_types: HashMap, host_import_signatures: HashMap, call_index_remap: HashMap, - inline_call_stack: Vec, + callable_bindings: HashMap, enable_local_move_semantics: bool, typing_mode: TypingMode, type_state: typing::LocalTypeState, type_map: TypeMap, + root_local_count: usize, + frame_local_count: usize, + function_slots: HashMap, + specialized_function_slots: Vec<(u16, Vec, LocalSlot)>, + function_prototype_ids: HashMap, + script_functions: Vec, + callable_prototypes: Vec, + function_regions: Vec, + root_callable_bindings: Vec, + pending_closures: Vec<(u32, ClosureExpr)>, + callable_prototype_bindings: HashMap, + closure_param_hints: HashMap)>>, } struct LoopContext { @@ -39,14 +54,6 @@ enum CallableBinding { Function(u16), } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum CaptureBindingMode { - Copy, - Borrow, - BorrowMut, - Move, -} - impl Default for Compiler { fn default() -> Self { Self::new() @@ -65,12 +72,24 @@ impl Compiler { host_import_return_types: HashMap::new(), host_import_signatures: HashMap::new(), call_index_remap: HashMap::new(), - inline_call_stack: Vec::new(), + callable_bindings: HashMap::new(), enable_local_move_semantics: false, typing_mode: TypingMode::DynamicHints, type_state: typing::LocalTypeState::default(), type_map: TypeMap::default(), + root_local_count: 0, + frame_local_count: 0, + function_slots: HashMap::new(), + specialized_function_slots: Vec::new(), + function_prototype_ids: HashMap::new(), + script_functions: Vec::new(), + callable_prototypes: Vec::new(), + function_regions: Vec::new(), + root_callable_bindings: Vec::new(), + pending_closures: Vec::new(), + callable_prototype_bindings: HashMap::new(), + closure_param_hints: HashMap::new(), } } @@ -99,6 +118,10 @@ impl Compiler { Ok(()) } + pub fn set_root_local_count(&mut self, root_local_count: usize) { + self.root_local_count = root_local_count; + } + pub fn set_function_impls(&mut self, function_impls: HashMap) { self.function_impls = function_impls; } @@ -145,17 +168,204 @@ impl Compiler { } pub fn compile_program(mut self, stmts: &[Stmt]) -> Result { + let named_functions = self.prepare_named_callables()?; self.compile_stmts(stmts)?; self.assembler.ret(); + let root_end = self.assembler.position(); + if root_end > 0 { + self.function_regions.push(FunctionRegion { + start_ip: 0, + end_ip: root_end, + prototype_id: None, + }); + } + + for function_index in named_functions { + self.compile_named_function_body(function_index)?; + } + let mut closure_index = 0usize; + while closure_index < self.pending_closures.len() { + let (prototype_id, closure) = self.pending_closures[closure_index].clone(); + self.compile_closure_body(prototype_id, &closure)?; + closure_index += 1; + } + for prototype in &mut self.callable_prototypes { + prototype.frame_local_count = self.frame_local_count; + } + let mut program = self .assembler .finish_program() .map_err(CompileError::Assembler)?; self.type_map.strict_types = self.typing_mode.is_strict(); program.type_map = Some(self.type_map); + program.local_count = self.frame_local_count; + program.script_functions = self.script_functions; + program.callable_prototypes = self.callable_prototypes; + program.function_regions = self.function_regions; + program.root_callable_bindings = self.root_callable_bindings; Ok(program) } + fn seed_frame_type_state_from_type_map(&mut self) { + for index in 0..self.frame_local_count.min(usize::from(u16::MAX) + 1) { + let Ok(slot) = LocalSlot::try_from(index) else { + break; + }; + let schema = self.type_map.local_schemas.get(index).cloned().flatten(); + let ty = schema + .as_ref() + .map(typing::bound_type_from_schema) + .unwrap_or_else(|| { + self.type_map + .local_types + .get(index) + .copied() + .map(typing::BoundType::from) + .unwrap_or(typing::BoundType::Unknown) + }); + self.type_state + .set_with_schema_origin(slot, ty, schema, false); + } + } + + 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)); + } + + 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 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 + .function_impls + .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); + self.function_prototype_ids + .insert(function_index, prototype_id); + self.callable_prototypes.push(CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(script_function_id), + arity: function_impl.param_slots.len() as u8, + frame_local_count: self.frame_local_count, + parameter_slots: function_impl.param_slots.clone(), + capture_slots: function_impl + .capture_copies + .iter() + .map(|(_, target)| *target) + .collect(), + self_slot: Some(hidden_slot), + schema: decl.map(|decl| TypeSchema::Callable { + params: decl + .arg_schemas + .iter() + .map(|schema| schema.clone().unwrap_or(TypeSchema::Unknown)) + .collect(), + result: Box::new(decl.return_schema.clone().unwrap_or(TypeSchema::Unknown)), + }), + }); + if function_impl.capture_copies.is_empty() { + self.root_callable_bindings.push(RootCallableBinding { + local_slot: hidden_slot, + prototype_id, + }); + } + } + Ok(indices) + } + + fn compile_named_function_body(&mut self, function_index: u16) -> Result<(), CompileError> { + let function_impl = self + .function_impls + .get(&function_index) + .cloned() + .expect("prepared function implementation must exist"); + let prototype_id = self.function_prototype_ids[&function_index]; + let entry_ip = self.assembler.position(); + let callable_snapshot = self.callable_bindings.clone(); + let type_snapshot = self.type_state.clone(); + let loop_snapshot = std::mem::take(&mut self.loop_stack); + self.seed_frame_type_state_from_type_map(); + if let Some(decl) = self.function_decls.get(&function_index) { + for (slot, schema) in function_impl.param_slots.iter().zip(&decl.arg_schemas) { + match schema { + Some(schema) => self.type_state.set_with_schema_origin( + *slot, + typing::bound_type_from_schema(schema), + Some(schema.clone()), + true, + ), + None => self.type_state.set_with_schema_origin( + *slot, + typing::BoundType::Unknown, + None, + false, + ), + } + } + } + self.compile_stmts(&function_impl.body_stmts)?; + self.compile_expr(&function_impl.body_expr)?; + self.assembler.ret(); + self.loop_stack = loop_snapshot; + self.callable_bindings = callable_snapshot; + self.type_state = type_snapshot; + let end_ip = self.assembler.position(); + self.script_functions + .push(ScriptFunction { entry_ip, end_ip }); + self.function_regions.push(FunctionRegion { + start_ip: entry_ip, + end_ip, + prototype_id: Some(prototype_id), + }); + Ok(()) + } + + fn compile_closure_body( + &mut self, + prototype_id: u32, + closure: &ClosureExpr, + ) -> Result<(), CompileError> { + let entry_ip = self.assembler.position(); + let callable_snapshot = self.callable_bindings.clone(); + let type_snapshot = self.type_state.clone(); + let loop_snapshot = std::mem::take(&mut self.loop_stack); + self.seed_frame_type_state_from_type_map(); + if let Some(hints) = self.closure_param_hints.get(&prototype_id).cloned() { + for (slot, (ty, schema)) in closure.param_slots.iter().zip(hints) { + self.type_state + .set_with_schema_origin(*slot, ty, schema, false); + } + } + self.compile_expr(&closure.body)?; + self.assembler.ret(); + self.loop_stack = loop_snapshot; + self.callable_bindings = callable_snapshot; + self.type_state = type_snapshot; + let end_ip = self.assembler.position(); + let function_id = self.script_functions.len() as u32; + self.script_functions + .push(ScriptFunction { entry_ip, end_ip }); + self.callable_prototypes[prototype_id as usize].target = + CallableTarget::ScriptFunction(function_id); + self.function_regions.push(FunctionRegion { + start_ip: entry_ip, + end_ip, + prototype_id: Some(prototype_id), + }); + Ok(()) + } + fn compile_stmts(&mut self, stmts: &[Stmt]) -> Result<(), CompileError> { for stmt in stmts { self.compile_stmt(stmt)?; @@ -183,9 +393,8 @@ impl Compiler { self.assembler.mark_line(*line); self.assign_expr_to_slot(*index, None, expr)?; } - Stmt::ClosureLet { line, closure } => { + Stmt::ClosureLet { line, .. } => { self.assembler.mark_line(*line); - self.bind_closure_captures(closure)?; } Stmt::FuncDecl { index, @@ -195,7 +404,7 @@ impl Compiler { } => { self.assembler.mark_line(*line); if *has_impl { - self.bind_function_decl_captures(*index)?; + self.emit_named_callable_binding(*index)?; } } Stmt::Expr { expr, line } => { @@ -423,25 +632,27 @@ impl Compiler { } => { self.compile_option_unwrap_or_expr(value, *value_slot, fallback)?; } - Expr::FunctionRef(_) => { - return Err(CompileError::CallableUsedAsValue); + Expr::FunctionRef(index, type_args) => { + let slot = self.ensure_function_value_slot(*index, type_args)?; + self.emit_copy_ldloc(slot)?; } Expr::Call(index, _, args) => { self.compile_function_call(*index, args)?; } - Expr::Closure(_) => { - return Err(CompileError::CallableUsedAsValue); + Expr::Closure(closure) => { + let _ = self.emit_closure_callable(closure)?; } Expr::ClosureCall(closure, args) => { - self.compile_inline_closure_call(closure, args)?; + let prototype_id = self.emit_closure_callable(closure)?; + self.record_closure_param_hints(prototype_id, args); + self.compile_callvalue_args(args)?; } Expr::LocalCall(index, _, args) => { - let callable = self - .callable_bindings - .get(index) - .cloned() - .ok_or(CompileError::NonCallableLocal(*index))?; - self.compile_callable_call(callable, args)?; + if let Some(prototype_id) = self.callable_prototype_bindings.get(index).copied() { + self.record_closure_param_hints(prototype_id, args); + } + self.emit_copy_ldloc(*index)?; + self.compile_callvalue_args(args)?; } Expr::Add(lhs, rhs) => { let lhs_ty = self.value_type_of_expr(lhs); @@ -558,15 +769,9 @@ impl Compiler { self.assembler.cgt(); } Expr::Var(index) => { - if self.callable_bindings.contains_key(index) { - return Err(CompileError::CallableUsedAsValue); - } self.emit_copy_ldloc(*index)?; } Expr::MoveVar(index) => { - if self.callable_bindings.contains_key(index) { - return Err(CompileError::CallableUsedAsValue); - } self.emit_move_ldloc(*index)?; self.type_state.set(*index, typing::BoundType::Null); } @@ -821,360 +1026,36 @@ impl Compiler { self.compile_expr(&lowered) } - fn bind_closure_captures(&mut self, closure: &ClosureExpr) -> Result<(), CompileError> { - let mut seen = HashSet::new(); - for (source_index, captured_slot) in &closure.capture_copies { - if !seen.insert((*source_index, *captured_slot)) { - continue; - } - let capture_mode = self.closure_capture_mode_for_slot(closure, *captured_slot); - self.bind_capture_copy(*source_index, *captured_slot, capture_mode)?; - } - Ok(()) - } - - fn bind_function_decl_captures(&mut self, index: u16) -> Result<(), CompileError> { + fn emit_named_callable_binding(&mut self, index: u16) -> Result<(), CompileError> { let Some(function_impl) = self.function_impls.get(&index).cloned() else { return Ok(()); }; - let mut seen = HashSet::new(); - for (source_index, captured_slot) in &function_impl.capture_copies { - if !seen.insert((*source_index, *captured_slot)) { - continue; - } - let capture_mode = self.function_capture_mode_for_slot(&function_impl, *captured_slot); - self.bind_capture_copy(*source_index, *captured_slot, capture_mode)?; + if function_impl.capture_copies.is_empty() { + return Ok(()); } - Ok(()) - } - - fn bind_capture_copy( - &mut self, - source_index: LocalSlot, - captured_slot: LocalSlot, - capture_mode: CaptureBindingMode, - ) -> Result<(), CompileError> { - let captured_type = self.type_state.get(source_index); - if self.enable_local_move_semantics && capture_mode == CaptureBindingMode::Move { - self.emit_move_ldloc(source_index)?; - self.type_state.set(source_index, typing::BoundType::Null); - } else { - self.emit_copy_ldloc(source_index)?; + for (source_index, _) in &function_impl.capture_copies { + self.emit_copy_ldloc(*source_index)?; } - self.emit_stloc(captured_slot)?; - self.type_state.set(captured_slot, captured_type); + let prototype_id = *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + let slot = *self + .function_slots + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + self.assembler.make_callable(prototype_id); + self.emit_stloc(slot)?; Ok(()) } - fn function_capture_mode_for_slot( - &self, - 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::Move, - &mut mode, - &mut seen, - ); - self.capture_mode_for_expr( - &function_impl.body_expr, - captured_slot, - CaptureBindingMode::Move, - &mut mode, - &mut seen, - ); - if seen { mode } else { CaptureBindingMode::Move } - } - - fn closure_capture_mode_for_slot( - &self, - 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::Move, - &mut mode, - &mut seen, - ); - if seen { mode } else { CaptureBindingMode::Move } - } - - fn capture_mode_for_stmts( - &self, - stmts: &[Stmt], - captured_slot: LocalSlot, - context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, - ) { - for stmt in stmts { - self.capture_mode_for_stmt(stmt, captured_slot, context, mode, seen); - } - } - - fn capture_mode_for_stmt( - &self, - stmt: &Stmt, - captured_slot: LocalSlot, - context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, - ) { - match stmt { - Stmt::Noop { .. } - | Stmt::FuncDecl { .. } - | Stmt::Break { .. } - | Stmt::Continue { .. } => {} - Stmt::Drop { index, .. } => { - if *index == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - } - Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { - if *index == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); - } - Stmt::ClosureLet { closure, .. } => { - for (nested_source_slot, nested_captured_slot) in &closure.capture_copies { - if *nested_source_slot == captured_slot { - self.capture_mode_for_expr( - &closure.body, - *nested_captured_slot, - CaptureBindingMode::Move, - mode, - seen, - ); - } - } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); - } - Stmt::Expr { expr, .. } => { - self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); - } - Stmt::IfElse { - condition, - then_branch, - 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); - } - Stmt::For { - init, - condition, - post, - 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); - } - 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); - } - } - } - - fn capture_mode_for_expr( - &self, - expr: &Expr, - captured_slot: LocalSlot, - context: CaptureBindingMode, - mode: &mut CaptureBindingMode, - seen: &mut bool, - ) { - match expr { - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(_) => {} - Expr::Var(index) => { - if *index == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - } - Expr::MoveVar(index) => { - if *index == captured_slot { - *seen = true; - *mode = CaptureBindingMode::Move; - } - } - Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { - if *root == captured_slot { - *seen = true; - *mode = CaptureBindingMode::Move; - } - } - Expr::OptionalGet { - container, - key, - container_slot, - key_slot, - } => { - if *container_slot == captured_slot || *key_slot == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - self.capture_mode_for_expr(container, captured_slot, context, mode, seen); - self.capture_mode_for_expr(key, captured_slot, context, mode, seen); - } - Expr::OptionUnwrapOr { - value, - value_slot, - fallback, - } => { - if *value_slot == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - self.capture_mode_for_expr(value, captured_slot, context, mode, seen); - self.capture_mode_for_expr(fallback, captured_slot, context, mode, seen); - } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { - for arg in args { - self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); - } - } - Expr::Closure(closure) => { - for (nested_source_slot, nested_captured_slot) in &closure.capture_copies { - if *nested_source_slot == captured_slot { - self.capture_mode_for_expr( - &closure.body, - *nested_captured_slot, - CaptureBindingMode::Move, - mode, - seen, - ); - } - } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); - } - Expr::ClosureCall(closure, args) => { - for arg in args { - self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); - } - for (nested_source_slot, nested_captured_slot) in &closure.capture_copies { - if *nested_source_slot == captured_slot { - self.capture_mode_for_expr( - &closure.body, - *nested_captured_slot, - CaptureBindingMode::Move, - mode, - seen, - ); - } - } - self.capture_mode_for_expr(&closure.body, captured_slot, context, mode, seen); - } - 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.capture_mode_for_expr(lhs, captured_slot, context, mode, seen); - self.capture_mode_for_expr(rhs, captured_slot, context, mode, seen); - } - Expr::Neg(inner) | Expr::Not(inner) => { - self.capture_mode_for_expr(inner, captured_slot, context, mode, seen); - } - Expr::ToOwned(inner) => { - self.capture_mode_for_expr( - inner, - captured_slot, - CaptureBindingMode::Copy, - mode, - seen, - ); - } - Expr::Borrow(inner) => { - self.capture_mode_for_expr( - inner, - captured_slot, - CaptureBindingMode::Borrow, - mode, - seen, - ); - } - Expr::BorrowMut(inner) => { - self.capture_mode_for_expr( - inner, - captured_slot, - CaptureBindingMode::BorrowMut, - mode, - seen, - ); - } - Expr::IfElse { - condition, - 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); - } - Expr::Match { - value_slot, - result_slot, - value, - arms, - default, - } => { - if *value_slot == captured_slot || *result_slot == captured_slot { - *seen = true; - *mode = (*mode).max(context); - } - self.capture_mode_for_expr(value, captured_slot, context, mode, seen); - for (_, arm_expr) in arms { - self.capture_mode_for_expr(arm_expr, captured_slot, context, mode, seen); - } - self.capture_mode_for_expr(default, captured_slot, context, mode, seen); - } - 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); - } - } - } - fn callable_binding_from_expr( &mut self, expr: &Expr, ) -> Result, CompileError> { match expr { - Expr::Closure(closure) => { - self.bind_closure_captures(closure)?; - Ok(Some(CallableBinding::Closure(closure.clone()))) - } - Expr::FunctionRef(index) => Ok(Some(CallableBinding::Function(*index))), + Expr::Closure(closure) => Ok(Some(CallableBinding::Closure(closure.clone()))), + Expr::FunctionRef(index, _) => Ok(Some(CallableBinding::Function(*index))), Expr::Var(index) => Ok(self.callable_bindings.get(index).cloned()), _ => Ok(None), } @@ -1192,6 +1073,19 @@ impl Compiler { CallableBinding::Closure(closure) => self.type_state.bind_closure(slot, &closure), CallableBinding::Function(index) => self.type_state.bind_function(slot, index), } + if let Expr::Closure(closure) = expr { + let prototype_id = self.emit_closure_callable_with_self(closure, Some(slot))?; + self.callable_prototype_bindings.insert(slot, prototype_id); + } else { + if let Expr::Var(source) | Expr::MoveVar(source) = expr + && let Some(prototype_id) = + self.callable_prototype_bindings.get(source).copied() + { + self.callable_prototype_bindings.insert(slot, prototype_id); + } + self.compile_expr(expr)?; + } + self.emit_stloc(slot)?; return Ok(()); } let declared_binding = declared_schema.map(TypeSchema::split_optional).or_else(|| { @@ -1348,70 +1242,233 @@ impl Compiler { Ok(()) } - fn compile_function_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { - if let Some(function_impl) = self.function_impls.get(&index).cloned() { - return self.compile_inline_function_call(index, &function_impl, args); + fn ensure_function_value_slot( + &mut self, + index: u16, + type_args: &[TypeSchema], + ) -> Result { + if !type_args.is_empty() + && let Some((_, _, slot)) = self + .specialized_function_slots + .iter() + .find(|(candidate, args, _)| *candidate == index && args == type_args) + { + return Ok(*slot); + } + if type_args.is_empty() + && let Some(slot) = self.function_slots.get(&index) + { + return Ok(*slot); + } + if !type_args.is_empty() && self.function_slots.contains_key(&index) { + return self.ensure_specialized_function_slot(index, type_args); + } + + 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) { + ( + self.call_index_remap.get(&index).copied().unwrap_or(index), + decl.args.len() as u8, + ) + } else { + return Err(CompileError::CallableUsedAsValue); + }; + let slot = self.allocate_hidden_callable_slot()?; + let prototype_id = self.callable_prototypes.len() as u32; + self.callable_prototypes.push(CallablePrototype { + kind: CallableKind::HostFunction, + target: CallableTarget::HostImport(target_index), + arity, + frame_local_count: self.frame_local_count, + parameter_slots: Vec::new(), + capture_slots: Vec::new(), + self_slot: None, + schema: self.instantiated_callable_schema(index, type_args), + }); + self.root_callable_bindings.push(RootCallableBinding { + local_slot: slot, + prototype_id, + }); + self.function_slots.insert(index, slot); + self.function_prototype_ids.insert(index, prototype_id); + if type_args.is_empty() { + Ok(slot) + } else { + self.ensure_specialized_function_slot(index, type_args) } - self.compile_direct_call(index, args) } - fn compile_inline_function_call( + fn ensure_specialized_function_slot( &mut self, index: u16, - function_impl: &FunctionImpl, - args: &[Expr], - ) -> Result<(), CompileError> { - if function_impl.param_slots.len() != args.len() { - return Err(CompileError::CallableArityMismatch { - expected: function_impl.param_slots.len(), - got: args.len(), - }); + type_args: &[TypeSchema], + ) -> Result { + let base_prototype_id = *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + if self + .function_impls + .get(&index) + .is_some_and(|function| !function.capture_copies.is_empty()) + { + return self + .function_slots + .get(&index) + .copied() + .ok_or(CompileError::CallableUsedAsValue); } - let frame_slots = collect_function_frame_slots(function_impl); - let callable_snapshot = self.callable_bindings.clone(); - for (arg, slot) in args.iter().zip(function_impl.param_slots.iter()) { - self.assign_expr_to_slot(*slot, None, arg)?; + let slot = self.allocate_hidden_callable_slot()?; + let mut prototype = self.callable_prototypes[base_prototype_id as usize].clone(); + prototype.schema = self.instantiated_callable_schema(index, type_args); + prototype.frame_local_count = self.frame_local_count; + let prototype_id = self.callable_prototypes.len() as u32; + self.callable_prototypes.push(prototype); + self.root_callable_bindings.push(RootCallableBinding { + local_slot: slot, + prototype_id, + }); + self.specialized_function_slots + .push((index, type_args.to_vec(), slot)); + Ok(slot) + } + + fn allocate_hidden_callable_slot(&mut self) -> Result { + let slot = LocalSlot::try_from(self.frame_local_count) + .map_err(|_| CompileError::LocalSlotOverflow(LocalSlot::MAX))?; + let _ = local_slot_operand(slot)?; + self.frame_local_count = self.frame_local_count.saturating_add(1); + Ok(slot) + } + + fn instantiated_callable_schema( + &self, + index: u16, + type_args: &[TypeSchema], + ) -> Option { + let decl = self.function_decls.get(&index)?; + if decl.type_params.len() != type_args.len() { + return None; + } + let bindings = decl + .type_params + .iter() + .cloned() + .zip(type_args.iter().cloned()) + .collect::>(); + Some(TypeSchema::Callable { + params: decl + .arg_schemas + .iter() + .map(|schema| { + schema + .as_ref() + .map(|schema| substitute_type_schema(schema, &bindings)) + .unwrap_or(TypeSchema::Unknown) + }) + .collect(), + result: Box::new( + decl.return_schema + .as_ref() + .map(|schema| substitute_type_schema(schema, &bindings)) + .unwrap_or(TypeSchema::Unknown), + ), + }) + } + + fn record_closure_param_hints(&mut self, prototype_id: u32, args: &[Expr]) { + let hints = args + .iter() + .map(|arg| { + let schema = typing::infer_expr_schema_with_function_impls_and_imports( + arg, + &self.type_state, + &self.function_impls, + &self.function_decls, + &self.struct_schemas, + &self.host_import_return_types, + &self.host_import_signatures, + ); + let ty = schema + .as_ref() + .map(typing::bound_type_from_schema) + .unwrap_or_else(|| self.infer_bound_type(arg)); + (ty, schema) + }) + .collect::>(); + + self.closure_param_hints + .entry(prototype_id) + .and_modify(|existing| { + for (index, hint) in hints.iter().enumerate() { + if let Some(existing) = existing.get_mut(index) + && existing.0 == typing::BoundType::Unknown + { + *existing = hint.clone(); + } + } + }) + .or_insert(hints); + } + + fn compile_function_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { + if self.function_impls.contains_key(&index) { + let slot = *self + .function_slots + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + self.emit_copy_ldloc(slot)?; + return self.compile_callvalue_args(args); } - if self.inline_call_stack.contains(&index) { - self.callable_bindings = callable_snapshot; - return Err(CompileError::InlineFunctionRecursion(format!( - "recursive RustScript function call detected for function index {}", - index - ))); + self.compile_direct_call(index, args) + } + + fn compile_callvalue_args(&mut self, args: &[Expr]) -> Result<(), CompileError> { + for arg in args { + self.compile_scalar_expr(arg)?; } - self.inline_call_stack.push(index); - let result = (|| -> Result<(), CompileError> { - self.compile_stmts(&function_impl.body_stmts)?; - self.compile_expr(&function_impl.body_expr) - })(); - self.inline_call_stack.pop(); - self.callable_bindings = callable_snapshot; - result?; - self.emit_inline_frame_clears(&frame_slots)?; + let argc = u8::try_from(args.len()).map_err(|_| CompileError::CallArityOverflow)?; + self.assembler.call_value(argc); Ok(()) } - fn compile_inline_closure_call( + fn emit_closure_callable(&mut self, closure: &ClosureExpr) -> Result { + self.emit_closure_callable_with_self(closure, None) + } + + fn emit_closure_callable_with_self( &mut self, closure: &ClosureExpr, - args: &[Expr], - ) -> Result<(), CompileError> { - if closure.param_slots.len() != args.len() { - return Err(CompileError::CallableArityMismatch { - expected: closure.param_slots.len(), - got: args.len(), - }); - } - let frame_slots = collect_closure_frame_slots(closure); - let callable_snapshot = self.callable_bindings.clone(); - for (arg, slot) in args.iter().zip(closure.param_slots.iter()) { - self.assign_expr_to_slot(*slot, None, arg)?; + binding_slot: Option, + ) -> Result { + for (source_slot, _) in &closure.capture_copies { + self.emit_copy_ldloc(*source_slot)?; } - let result = self.compile_expr(&closure.body); - self.callable_bindings = callable_snapshot; - result?; - self.emit_inline_frame_clears(&frame_slots)?; - Ok(()) + let prototype_id = self.callable_prototypes.len() as u32; + self.callable_prototypes.push(CallablePrototype { + kind: CallableKind::Closure, + target: CallableTarget::ScriptFunction(u32::MAX), + arity: u8::try_from(closure.param_slots.len()) + .map_err(|_| CompileError::CallArityOverflow)?, + frame_local_count: self.frame_local_count, + parameter_slots: closure.param_slots.clone(), + capture_slots: closure + .capture_copies + .iter() + .map(|(_, target)| *target) + .collect(), + self_slot: binding_slot.and_then(|binding_slot| { + closure + .capture_copies + .iter() + .find_map(|(source, target)| (*source == binding_slot).then_some(*target)) + }), + schema: None, + }); + self.pending_closures.push((prototype_id, closure.clone())); + self.assembler.make_callable(prototype_id); + Ok(prototype_id) } fn compile_direct_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { @@ -1434,17 +1491,6 @@ impl Compiler { Ok(()) } - fn compile_callable_call( - &mut self, - callable: CallableBinding, - args: &[Expr], - ) -> Result<(), CompileError> { - match callable { - CallableBinding::Closure(closure) => self.compile_inline_closure_call(&closure, args), - CallableBinding::Function(index) => self.compile_function_call(index, args), - } - } - fn compile_match_pattern_condition( &mut self, value_slot: LocalSlot, @@ -1686,15 +1732,6 @@ impl Compiler { Ok(()) } - fn emit_inline_frame_clears(&mut self, slots: &[LocalSlot]) -> Result<(), CompileError> { - for slot in slots { - self.assembler.push_const(Value::Null); - self.emit_stloc(*slot)?; - self.type_state.set(*slot, typing::BoundType::Null); - } - Ok(()) - } - fn compile_string_concat_operand(&mut self, expr: &Expr) -> Result<(), CompileError> { if let Some(value) = eval_const_int_expr(expr) { self.assembler.push_const(Value::string(value.to_string())); @@ -1741,219 +1778,62 @@ impl Compiler { } } -fn local_slot_operand(index: LocalSlot) -> Result { - u8::try_from(index).map_err(|_| CompileError::LocalSlotOverflow(index)) -} - -fn collect_function_frame_slots(function_impl: &FunctionImpl) -> Vec { - let mut slots = BTreeSet::new(); - for slot in &function_impl.param_slots { - slots.insert(*slot); - } - for stmt in &function_impl.body_stmts { - collect_stmt_slot_footprint(stmt, &mut slots); - } - collect_expr_slot_footprint(&function_impl.body_expr, &mut slots); - for (_, captured_slot) in &function_impl.capture_copies { - slots.remove(captured_slot); - } - let mut out = slots.into_iter().collect::>(); - out.sort_unstable_by(|lhs, rhs| rhs.cmp(lhs)); - out -} - -fn collect_closure_frame_slots(closure: &ClosureExpr) -> Vec { - let mut slots = BTreeSet::new(); - for slot in &closure.param_slots { - slots.insert(*slot); - } - collect_expr_slot_footprint(&closure.body, &mut slots); - for (_, captured_slot) in &closure.capture_copies { - slots.remove(captured_slot); - } - let mut out = slots.into_iter().collect::>(); - out.sort_unstable_by(|lhs, rhs| rhs.cmp(lhs)); - out -} - -fn collect_stmt_slot_footprint(stmt: &Stmt, slots: &mut BTreeSet) { - match stmt { - Stmt::Noop { .. } | Stmt::FuncDecl { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => {} - Stmt::Drop { index, .. } => { - slots.insert(*index); - } - Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { - slots.insert(*index); - collect_expr_slot_footprint(expr, slots); - } - Stmt::ClosureLet { closure, .. } => { - for slot in &closure.param_slots { - slots.insert(*slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - slots.insert(*source_slot); - slots.insert(*captured_slot); - } - collect_expr_slot_footprint(&closure.body, slots); - } - Stmt::Expr { expr, .. } => collect_expr_slot_footprint(expr, slots), - Stmt::IfElse { - condition, - then_branch, - else_branch, - .. - } => { - collect_expr_slot_footprint(condition, slots); - for stmt in then_branch { - collect_stmt_slot_footprint(stmt, slots); - } - for stmt in else_branch { - collect_stmt_slot_footprint(stmt, slots); - } +fn substitute_type_schema( + schema: &TypeSchema, + bindings: &HashMap, +) -> TypeSchema { + match schema { + TypeSchema::GenericParam(name) => bindings + .get(name) + .cloned() + .unwrap_or_else(|| schema.clone()), + TypeSchema::Optional(inner) => { + TypeSchema::Optional(Box::new(substitute_type_schema(inner, bindings))) } - Stmt::For { - init, - condition, - post, - body, - .. - } => { - collect_stmt_slot_footprint(init, slots); - collect_expr_slot_footprint(condition, slots); - collect_stmt_slot_footprint(post, slots); - for stmt in body { - collect_stmt_slot_footprint(stmt, slots); - } + TypeSchema::Named(name, args) => TypeSchema::Named( + name.clone(), + args.iter() + .map(|schema| substitute_type_schema(schema, bindings)) + .collect(), + ), + TypeSchema::Array(inner) => { + TypeSchema::Array(Box::new(substitute_type_schema(inner, bindings))) } - Stmt::While { - condition, body, .. - } => { - collect_expr_slot_footprint(condition, slots); - for stmt in body { - collect_stmt_slot_footprint(stmt, slots); - } + TypeSchema::ArrayTuple(items) => TypeSchema::ArrayTuple( + items + .iter() + .map(|schema| substitute_type_schema(schema, bindings)) + .collect(), + ), + TypeSchema::ArrayTupleRest { prefix, rest } => TypeSchema::ArrayTupleRest { + prefix: prefix + .iter() + .map(|schema| substitute_type_schema(schema, bindings)) + .collect(), + rest: Box::new(substitute_type_schema(rest, bindings)), + }, + TypeSchema::Map(inner) => { + TypeSchema::Map(Box::new(substitute_type_schema(inner, bindings))) } + TypeSchema::Object(fields) => TypeSchema::Object( + fields + .iter() + .map(|(name, schema)| (name.clone(), substitute_type_schema(schema, bindings))) + .collect(), + ), + TypeSchema::Callable { params, result } => TypeSchema::Callable { + params: params + .iter() + .map(|schema| substitute_type_schema(schema, bindings)) + .collect(), + result: Box::new(substitute_type_schema(result, bindings)), + }, + _ => schema.clone(), } } -fn collect_expr_slot_footprint(expr: &Expr, slots: &mut BTreeSet) { - match expr { - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(_) => {} - Expr::Var(index) | Expr::MoveVar(index) => { - slots.insert(*index); - } - Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { - slots.insert(*root); - } - Expr::OptionalGet { - container, - key, - container_slot, - key_slot, - } => { - slots.insert(*container_slot); - slots.insert(*key_slot); - collect_expr_slot_footprint(container, slots); - collect_expr_slot_footprint(key, slots); - } - Expr::OptionUnwrapOr { - value, - value_slot, - fallback, - } => { - slots.insert(*value_slot); - collect_expr_slot_footprint(value, slots); - collect_expr_slot_footprint(fallback, slots); - } - Expr::Call(_, _, args) => { - for arg in args { - collect_expr_slot_footprint(arg, slots); - } - } - Expr::LocalCall(index, _, args) => { - slots.insert(*index); - for arg in args { - collect_expr_slot_footprint(arg, slots); - } - } - Expr::Closure(closure) => { - for slot in &closure.param_slots { - slots.insert(*slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - slots.insert(*source_slot); - slots.insert(*captured_slot); - } - collect_expr_slot_footprint(&closure.body, slots); - } - Expr::ClosureCall(closure, args) => { - for slot in &closure.param_slots { - slots.insert(*slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - slots.insert(*source_slot); - slots.insert(*captured_slot); - } - for arg in args { - collect_expr_slot_footprint(arg, slots); - } - collect_expr_slot_footprint(&closure.body, slots); - } - 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) => { - collect_expr_slot_footprint(lhs, slots); - collect_expr_slot_footprint(rhs, slots); - } - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => collect_expr_slot_footprint(inner, slots), - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - collect_expr_slot_footprint(condition, slots); - collect_expr_slot_footprint(then_expr, slots); - collect_expr_slot_footprint(else_expr, slots); - } - Expr::Match { - value_slot, - result_slot, - value, - arms, - default, - } => { - slots.insert(*value_slot); - slots.insert(*result_slot); - collect_expr_slot_footprint(value, slots); - for (_, arm_expr) in arms { - collect_expr_slot_footprint(arm_expr, slots); - } - collect_expr_slot_footprint(default, slots); - } - Expr::Block { stmts, expr } => { - for stmt in stmts { - collect_stmt_slot_footprint(stmt, slots); - } - collect_expr_slot_footprint(expr, slots); - } - } +fn local_slot_operand(index: LocalSlot) -> Result { + u8::try_from(index).map_err(|_| CompileError::LocalSlotOverflow(index)) } fn shift_amount_for_power_of_two(value: i64) -> Option { diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index deddd570..e17494a5 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -55,10 +55,10 @@ impl TypeSchema { pub(crate) fn coarse_value_type(&self) -> ValueType { match self { - TypeSchema::Unknown - | TypeSchema::GenericParam(_) - | TypeSchema::Number - | TypeSchema::Callable { .. } => ValueType::Unknown, + TypeSchema::Unknown | TypeSchema::GenericParam(_) | TypeSchema::Number => { + ValueType::Unknown + } + TypeSchema::Callable { .. } => ValueType::Callable, TypeSchema::Null => ValueType::Null, TypeSchema::Int => ValueType::Int, TypeSchema::Float => ValueType::Float, @@ -185,7 +185,7 @@ pub enum Expr { Bool(bool), String(String), Bytes(Vec), - FunctionRef(u16), + FunctionRef(u16, Vec), OptionalGet { container: Box, key: Box, diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index a6918754..3c8dc76a 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -332,7 +332,16 @@ impl AvailabilityAnalyzer { expr, line, } => { - let mut out = self.analyze_expr(expr, &state, *line)?; + let mut initializer_state = state.clone(); + if let Expr::Closure(closure) = expr + && closure + .capture_copies + .iter() + .any(|(source, _)| source == index) + { + self.mark_available(&mut initializer_state, *index, *line)?; + } + let mut out = self.analyze_expr(expr, &initializer_state, *line)?; let mut rewritten_expr = expr.clone(); if out.reachable { self.mark_available(&mut out, *index, *line)?; @@ -647,7 +656,7 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => Ok(state.clone()), + | Expr::FunctionRef(..) => Ok(state.clone()), Expr::Var(index) => { self.require_available(*index, state, line)?; self.require_local_not_moved(*index, state, line)?; diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index cfa81bdd..410ec9b0 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -283,7 +283,7 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) => { if *index == captured_slot { *seen = true; diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index 4716d857..f410291b 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -171,7 +171,7 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => false, + | Expr::FunctionRef(..) => false, Expr::Var(index) | Expr::MoveVar(index) => *index == slot, Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => *root == slot, Expr::OptionalGet { @@ -375,7 +375,7 @@ pub(super) fn collect_consumed_positions_from_expr( | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) + | Expr::FunctionRef(..) | Expr::Var(_) => {} Expr::MoveVar(slot) => { if let Some(position) = function_impl diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index b96e5641..6222e739 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -437,7 +437,7 @@ impl LivenessRewriter { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) | Expr::MoveVar(index) => self.mark_live(live, *index), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { self.mark_live(live, *root) @@ -736,7 +736,7 @@ impl LivenessRewriter { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { self.mark_live(footprint, *index); } @@ -897,7 +897,7 @@ fn expr_contains_local_call(expr: &Expr) -> bool { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) + | Expr::FunctionRef(..) | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1130,7 +1130,7 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) | Expr::MoveVar(index) => { self.add_slot_live_edges(*index, &live_during); } @@ -1354,7 +1354,7 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { self.mark_set_slot(set, *index) } @@ -1721,7 +1721,7 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) => {} - Expr::FunctionRef(_) => {} + Expr::FunctionRef(..) => {} Expr::Call(_, _, args) => { for arg in args { remap_expr_slots(arg, mapping)?; diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index c08d0049..677840b6 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -298,6 +298,7 @@ fn value_type_name(ty: crate::ValueType) -> &'static str { crate::ValueType::Bytes => "bytes", crate::ValueType::Array => "array", crate::ValueType::Map => "map", + crate::ValueType::Callable => "callable", } } @@ -412,7 +413,7 @@ fn remap_expr_indices( | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) => {} - Expr::FunctionRef(index) => { + Expr::FunctionRef(index, _) => { if let Some(remapped_index) = function_map.get(index).copied() { *index = remapped_index; } else if BuiltinFunction::from_call_index(*index).is_none() { diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index f687eb26..fc294e36 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -511,21 +511,34 @@ impl Parser { Expr::Call(decl.index, type_args, args) } } else { - if !type_args.is_empty() { - return Err(ParseError { - span: Some(self.current_span()), - code: None, - line: self.current_line(), - message: format!( - "explicit type arguments require a direct function call; generic function values are not supported for '{name}'" - ), - }); - } if self.has_local_binding(&name) { + if !type_args.is_empty() { + return Err(ParseError { + span: Some(self.current_span()), + code: None, + line: self.current_line(), + message: format!( + "local callable '{name}' does not accept explicit type arguments" + ), + }); + } let index = self.get_local(&name)?; Expr::Var(index) - } else if let Some(decl) = self.functions.get(&name) { - Expr::FunctionRef(decl.index) + } else if let Some(decl) = self.functions.get(&name).cloned() { + self.validate_named_call_type_args(&decl, &type_args)?; + Expr::FunctionRef(decl.index, type_args) + } else if let Some(index) = crate::builtin_call_index(&name) { + if !type_args.is_empty() { + return Err(ParseError { + span: Some(self.current_span()), + code: None, + line: self.current_line(), + message: format!( + "function '{name}' does not accept explicit type arguments" + ), + }); + } + Expr::FunctionRef(index, Vec::new()) } else { return Err(ParseError { span: None, diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index 37457da7..f989111e 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -121,6 +121,7 @@ pub(super) struct Parser { functions: HashMap, function_list: Vec, function_impls: HashMap, + parsed_function_decls: HashSet, next_function: u16, closure_scopes: Vec>, closure_capture_contexts: Vec, @@ -175,6 +176,7 @@ impl Parser { functions: HashMap::new(), function_list: Vec::new(), function_impls: HashMap::new(), + parsed_function_decls: HashSet::new(), next_function: 0, closure_scopes: Vec::new(), closure_capture_contexts: Vec::new(), @@ -221,6 +223,7 @@ impl Parser { } pub(super) fn parse_program(&mut self) -> Result, ParseError> { + self.predeclare_functions()?; let mut stmts = Vec::new(); while !self.check(&TokenKind::Eof) { stmts.push(self.parse_stmt()?); @@ -229,6 +232,121 @@ impl Parser { Ok(stmts) } + fn predeclare_functions(&mut self) -> Result<(), ParseError> { + let mut index = 0usize; + while index < self.tokens.len() { + match &self.tokens[index].kind { + TokenKind::Fn => { + let line = self.tokens[index].line; + let Some(Token { + kind: TokenKind::Ident(name), + .. + }) = self.tokens.get(index + 1) + else { + index += 1; + continue; + }; + let name = name.clone(); + let exported = + index > 0 && matches!(self.tokens[index - 1].kind, TokenKind::Pub); + let mut cursor = index + 2; + let mut type_params = Vec::new(); + if self + .tokens + .get(cursor) + .is_some_and(|token| matches!(token.kind, TokenKind::Less)) + { + cursor += 1; + while let Some(token) = self.tokens.get(cursor) { + match &token.kind { + TokenKind::Ident(param) => type_params.push(param.clone()), + TokenKind::Greater => { + cursor += 1; + break; + } + _ => {} + } + cursor += 1; + } + } + if !self + .tokens + .get(cursor) + .is_some_and(|token| matches!(token.kind, TokenKind::LParen)) + { + index += 1; + continue; + } + cursor += 1; + let mut arity = 0usize; + let mut angle_depth = 0usize; + let mut paren_depth = 1usize; + let mut has_param = false; + while let Some(token) = self.tokens.get(cursor) { + match token.kind { + TokenKind::LParen => paren_depth += 1, + TokenKind::RParen if paren_depth == 1 && angle_depth == 0 => { + if has_param { + arity += 1; + } + break; + } + TokenKind::RParen => paren_depth = paren_depth.saturating_sub(1), + TokenKind::Less => angle_depth += 1, + TokenKind::Greater => angle_depth = angle_depth.saturating_sub(1), + TokenKind::Comma if paren_depth == 1 && angle_depth == 0 => { + if has_param { + arity += 1; + has_param = false; + } + } + _ if paren_depth == 1 && angle_depth == 0 => has_param = true, + _ => {} + } + cursor += 1; + } + if self.functions.contains_key(&name) { + return Err(ParseError { + span: None, + code: None, + line, + message: format!("duplicate function '{name}'"), + }); + } + let arity = u8::try_from(arity).map_err(|_| ParseError { + span: None, + code: None, + line, + message: "function arity too large".to_string(), + })?; + let function_index = self.next_function; + self.next_function = self.next_function.checked_add(1).ok_or(ParseError { + span: None, + code: None, + line, + message: "function index overflow".to_string(), + })?; + let decl = FunctionDecl { + name: name.clone(), + arity, + index: function_index, + args: vec![String::new(); usize::from(arity)], + arg_schemas: vec![None; usize::from(arity)], + return_schema: None, + type_params, + exported, + return_type: ValueType::Unknown, + }; + self.functions.insert(name, decl.clone()); + self.function_list.push(decl); + index = cursor; + } + _ => index += 1, + } + } + Ok(()) + } + pub(super) fn local_count(&self) -> usize { self.next_local as usize } diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 1497cdf1..9191b21c 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -453,7 +453,13 @@ impl Parser { line: self.current_line(), message: "function arity too large".to_string(), })?; - if self.functions.contains_key(&name) { + let predeclared = self.functions.get(&name).cloned().ok_or(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!("function '{name}' was not predeclared"), + })?; + if self.parsed_function_decls.contains(&predeclared.index) { return Err(ParseError { span: None, code: None, @@ -461,6 +467,14 @@ impl Parser { message: format!("duplicate function '{name}'"), }); } + if predeclared.arity != arity || predeclared.type_params.len() != type_params.len() { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!("function header changed while parsing '{name}'"), + }); + } if self.locals.contains_key(&name) { return Err(ParseError { span: None, @@ -469,13 +483,7 @@ impl Parser { message: format!("name '{name}' already used by a local binding"), }); } - let index = self.next_function; - self.next_function = self.next_function.checked_add(1).ok_or(ParseError { - span: None, - code: None, - line: self.current_line(), - message: "function index overflow".to_string(), - })?; + let index = predeclared.index; let decl = FunctionDecl { name: name.clone(), arity, @@ -488,7 +496,19 @@ impl Parser { return_type, }; self.functions.insert(name.clone(), decl.clone()); - self.function_list.push(decl.clone()); + let current_line = self.current_line(); + let list_entry = self + .function_list + .iter_mut() + .find(|candidate| candidate.index == index) + .ok_or(ParseError { + span: None, + code: None, + line: current_line, + message: format!("function list entry missing for '{name}'"), + })?; + *list_entry = decl.clone(); + self.parsed_function_decls.insert(index); self.push_active_type_params(&type_params); let has_impl = if self.match_kind(&TokenKind::Equal) { @@ -886,12 +906,37 @@ impl Parser { None }; self.expect(&TokenKind::Equal, "expected '=' after identifier")?; + let predeclared_closure_binding = if self.check(&TokenKind::Pipe) { + Some(if !self.closure_scopes.is_empty() { + if let Some(index) = self + .closure_scopes + .last() + .and_then(|scope| scope.get(&name)) + .copied() + { + (index, false) + } else { + let index = self.allocate_hidden_local()?; + if let Some(scope) = self.closure_scopes.last_mut() { + scope.insert(name.clone(), index); + } + self.named_local_bindings.push((name.clone(), index)); + (index, true) + } + } else { + self.get_or_assign_local(&name)? + }) + } else { + None + }; let expr = self.parse_expr()?; if expect_terminator { self.consume_stmt_terminator("expected ';' after let")?; } - let (index, created) = if !self.closure_scopes.is_empty() { + let (index, created) = if let Some(predeclared) = predeclared_closure_binding { + predeclared + } else if !self.closure_scopes.is_empty() { if let Some(index) = self .closure_scopes .last() diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index 198f807a..7f98363d 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -173,7 +173,7 @@ fn record_expr_local_debug_ranges( | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) => {} + | Expr::FunctionRef(..) => {} Expr::Var(index) | Expr::MoveVar(index) => { note_local_use(ranges, *index, line); } @@ -436,6 +436,7 @@ fn compile_parsed_output_with_entry_locals( compiler.set_type_inference(type_info); compiler.set_typing_mode(typing_mode); compiler.set_source(source); + compiler.set_root_local_count(locals); compiler.set_function_decls(function_decls); compiler.set_function_impls(function_impls); compiler.set_struct_schemas(struct_schemas); @@ -455,7 +456,7 @@ fn compile_parsed_output_with_entry_locals( let mut program = compiler .compile_program(&stmts) .map_err(SourceError::Compile)?; - program.local_count = locals; + program.local_count = program.local_count.max(locals); program.imports = runtime_import_functions .iter() .map(|func| HostImport { @@ -464,9 +465,10 @@ fn compile_parsed_output_with_entry_locals( return_type: func.return_type, }) .collect(); + let runtime_locals = program.local_count; Ok(CompiledProgram { program, - locals, + locals: runtime_locals, functions: visible_runtime_import_functions, }) } @@ -1021,6 +1023,7 @@ fn value_type_name(value: crate::ValueType) -> &'static str { crate::ValueType::Bytes => "bytes", crate::ValueType::Array => "array", crate::ValueType::Map => "map", + crate::ValueType::Callable => "callable", } } diff --git a/src/compiler/source_loader/line_map.rs b/src/compiler/source_loader/line_map.rs index f1b72bd3..34853d48 100644 --- a/src/compiler/source_loader/line_map.rs +++ b/src/compiler/source_loader/line_map.rs @@ -152,7 +152,7 @@ fn remap_expr_line_numbers(expr: &mut Expr, offset: u32) { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) + | Expr::FunctionRef(..) | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index 4797080a..ac048e9a 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -519,7 +519,7 @@ fn collect_expr_types( | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } - | Expr::FunctionRef(_) => { + | Expr::FunctionRef(..) => { let _ = context.infer_expr_type(expr, state); } Expr::OptionalGet { container, key, .. } => { diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index a2202a4f..89fc6501 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -768,6 +768,41 @@ impl<'a> TypeContext<'a> { self.infer_expr_schema(inner, state) } Expr::Var(slot) | Expr::MoveVar(slot) => state.schema(*slot).cloned(), + Expr::FunctionRef(index, type_args) => { + let decl = self.function_decls.get(index).cloned()?; + if decl.type_params.len() != type_args.len() && !type_args.is_empty() { + return None; + } + if !type_args.is_empty() { + self.push_generic_bindings(&decl.type_params, type_args); + } + let schema = TypeSchema::Callable { + params: decl + .arg_schemas + .iter() + .map(|schema| { + schema + .as_ref() + .map(|schema| self.resolve_schema(schema)) + .unwrap_or(TypeSchema::Unknown) + }) + .collect(), + result: Box::new( + decl.return_schema + .as_ref() + .map(|schema| self.resolve_schema(schema)) + .unwrap_or(TypeSchema::Unknown), + ), + }; + if !type_args.is_empty() { + self.pop_generic_bindings(); + } + Some(schema) + } + Expr::Closure(closure) => Some(TypeSchema::Callable { + params: vec![TypeSchema::Unknown; closure.param_slots.len()], + result: Box::new(TypeSchema::Unknown), + }), Expr::Call(index, type_args, args) => { if let Some(builtin) = BuiltinFunction::from_call_index(*index) { self.infer_builtin_call_schema(builtin, type_args, args, state) @@ -887,9 +922,8 @@ impl<'a> TypeContext<'a> { state.get(*root) } } - Expr::FunctionRef(_) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { - self.infer_call_like_expr_type(expr, state) - } + Expr::FunctionRef(..) | Expr::Closure(_) => BoundType::Callable, + Expr::Call(..) | Expr::LocalCall(..) => self.infer_call_like_expr_type(expr, state), Expr::ClosureCall(_, _) => self.infer_call_like_expr_type(expr, state), Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) @@ -1027,7 +1061,7 @@ impl<'a> TypeContext<'a> { .unwrap_or(BoundType::Unknown), }, Expr::ClosureCall(closure, args) => self.infer_closure_return(closure, args, state), - Expr::Closure(_) | Expr::FunctionRef(_) => BoundType::Unknown, + Expr::Closure(_) | Expr::FunctionRef(..) => BoundType::Callable, _ => BoundType::Unknown, } } @@ -2048,7 +2082,7 @@ impl<'a> TypeContext<'a> { ) -> Option { match expr { Expr::Closure(closure) => Some(InferredCallable::Closure(closure.clone())), - Expr::FunctionRef(index) => Some(InferredCallable::Function(*index)), + Expr::FunctionRef(index, _) => Some(InferredCallable::Function(*index)), Expr::Var(slot) | Expr::MoveVar(slot) => state.callable(*slot).cloned(), _ => None, } @@ -2205,6 +2239,10 @@ fn schema_from_bound_type(ty: BoundType) -> Option { BoundType::Map | BoundType::MapOf(_) => { Some(TypeSchema::Map(Box::new(TypeSchema::Unknown))) } + BoundType::Callable => Some(TypeSchema::Callable { + params: Vec::new(), + result: Box::new(TypeSchema::Unknown), + }), } } @@ -2230,7 +2268,7 @@ pub(crate) fn bound_type_from_schema(schema: &TypeSchema) -> BoundType { TypeSchema::Bytes => BoundType::Bytes, TypeSchema::Optional(inner) => bound_type_from_schema(inner), TypeSchema::GenericParam(_) => BoundType::Unknown, - TypeSchema::Callable { .. } => BoundType::Unknown, + TypeSchema::Callable { .. } => BoundType::Callable, TypeSchema::Named(_, _) => BoundType::Map, TypeSchema::Array(_) | TypeSchema::ArrayTuple(_) | TypeSchema::ArrayTupleRest { .. } => { BoundType::Array diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index b2fdd2e5..c4484c09 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -171,6 +171,9 @@ pub(super) fn validate_function_impl( .get(&function_index) .and_then(|decl| decl.return_schema.as_ref()); let function_name = context.function_name(function_index).to_string(); + let returns_callable = context + .callable_binding_from_expr(&function_impl.body_expr, &state) + .is_some(); if let Some(schema) = declared_return_schema { validate_declared_return_schema( &function_name, @@ -182,12 +185,8 @@ pub(super) fn validate_function_impl( Some(function_impl.body_expr_line), source_name, )?; - } else if context - .callable_binding_from_expr(&function_impl.body_expr, &state) - .is_some() - { - return Err(CompileError::CallableUsedAsValue); } else if context.is_strict() + && !returns_callable && observed_body_ty == BoundType::Unknown && actual_schema.is_none() { @@ -1355,7 +1354,7 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) + | Expr::FunctionRef(..) | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1430,7 +1429,7 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(_) + | Expr::FunctionRef(..) | Expr::MoveField { .. } | Expr::MoveIndex { .. } => false, Expr::OptionalGet { container, key, .. } => { @@ -1589,7 +1588,7 @@ pub(super) fn legalize_expr( let _ = legalize_expr(fallback, state, context); context.infer_expr_type(expr, state) } - Expr::FunctionRef(_) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { legalize_expr_children(expr, state, context); context.infer_call_like_expr_type(expr, state) } @@ -1852,5 +1851,6 @@ pub(super) fn bound_type_label(ty: BoundType) -> &'static str { BoundType::Bytes => "bytes", BoundType::Array | BoundType::ArrayOf(_) => "array", BoundType::Map | BoundType::MapOf(_) => "map", + BoundType::Callable => "callable", } } diff --git a/src/compiler/typing/state.rs b/src/compiler/typing/state.rs index c10fdd98..5020bb27 100644 --- a/src/compiler/typing/state.rs +++ b/src/compiler/typing/state.rs @@ -29,6 +29,7 @@ pub(crate) enum BoundType { ArrayOf(Option), Map, MapOf(Option), + Callable, } impl BoundType { @@ -44,6 +45,7 @@ impl BoundType { BoundType::Bytes => Some("bytes"), BoundType::Array | BoundType::ArrayOf(_) => Some("array"), BoundType::Map | BoundType::MapOf(_) => Some("map"), + BoundType::Callable => Some("callable"), } } @@ -84,6 +86,7 @@ impl From for ValueType { BoundType::Bytes => ValueType::Bytes, BoundType::Array | BoundType::ArrayOf(_) => ValueType::Array, BoundType::Map | BoundType::MapOf(_) => ValueType::Map, + BoundType::Callable => ValueType::Callable, } } } @@ -100,6 +103,7 @@ impl From for BoundType { ValueType::Bytes => BoundType::Bytes, ValueType::Array => BoundType::Array, ValueType::Map => BoundType::Map, + ValueType::Callable => BoundType::Callable, } } } @@ -277,7 +281,7 @@ impl LocalTypeState { } pub(super) fn bind_callable(&mut self, slot: LocalSlot, callable: InferredCallable) { - self.by_slot.remove(&slot); + self.by_slot.insert(slot, BoundType::Callable); self.schemas.remove(&slot); self.declared_schema_slots.remove(&slot); self.optional_slots.remove(&slot); @@ -292,7 +296,7 @@ impl LocalTypeState { from_declared_schema: bool, optional: bool, ) { - self.by_slot.remove(&slot); + self.by_slot.insert(slot, BoundType::Callable); if let Some(schema) = schema { self.schemas.insert(slot, schema); } else { diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 1ace17fd..31d84d89 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -556,7 +556,7 @@ pub(super) fn validate_expr( )?, Expr::Var(slot) | Expr::MoveVar(slot) => state.get(*slot), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => state.get(*root), - Expr::FunctionRef(_) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { validate_expr_children( expr, state, @@ -566,7 +566,6 @@ pub(super) fn validate_expr( strict_function_add_types, )?; observe_direct_function_call_types(expr, state, line_context, source_name, context)?; - validate_callable_value_usage(expr, state, context)?; validate_schema_access(expr, state, line_context, source_name, context)?; context.validate_call_argument_types(expr, state, line_context, source_name)?; context.infer_call_like_expr_type(expr, state) @@ -827,31 +826,6 @@ pub(super) fn validate_expr( }) } -fn validate_callable_value_usage( - expr: &Expr, - state: &LocalTypeState, - context: &mut TypeContext<'_>, -) -> Result<(), CompileError> { - let Expr::Call(index, _, args) = expr else { - return Ok(()); - }; - let Some(builtin) = BuiltinFunction::from_call_index(*index) else { - return Ok(()); - }; - let value_arg = match builtin { - BuiltinFunction::ArrayPush if args.len() == 2 => args.get(1), - BuiltinFunction::Set if args.len() == 3 => args.get(2), - _ => None, - }; - if value_arg - .and_then(|value| context.callable_binding_from_expr(value, state)) - .is_some() - { - return Err(CompileError::CallableUsedAsValue); - } - Ok(()) -} - fn validate_expr_children( expr: &Expr, state: &LocalTypeState, @@ -880,13 +854,21 @@ fn validate_expr_children( let TypeSchema::Callable { params, .. } = schema else { return None; }; - Some(params.iter().cloned().map(Some).collect::>()) + Some( + params + .iter() + .cloned() + .map(|schema| { + (!matches!(schema, TypeSchema::Unknown)).then_some(schema) + }) + .collect::>(), + ) }); let declared_result_schema = declared_callable.as_ref().and_then(|schema| { let TypeSchema::Callable { result, .. } = schema else { return None; }; - Some(result.as_ref()) + (!matches!(result.as_ref(), TypeSchema::Unknown)).then_some(result.as_ref()) }); validate_callable_body( CallableBody { diff --git a/src/debugger/recording.rs b/src/debugger/recording.rs index b6dfe5f8..1adf70a7 100644 --- a/src/debugger/recording.rs +++ b/src/debugger/recording.rs @@ -1,13 +1,15 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::Path; +use std::sync::{Arc, Mutex}; -use crate::vm::{Program, Value, Vm, VmStatus}; +use crate::vm::{Program, Value, Vm, VmExecutionFrameSnapshot, VmFrameContinuation, VmStatus}; #[derive(Clone, Debug, PartialEq)] pub struct VmRecordingFrame { pub ip: usize, pub call_depth: usize, + pub execution_frames: Vec, pub stack: Vec, pub locals: Vec, } @@ -51,6 +53,7 @@ impl VmRecordingFrame { Self { ip: vm.ip(), call_depth: vm.call_depth(), + execution_frames: vm.execution_frames(), stack: vm.stack().to_vec(), locals: vm.locals().to_vec(), } @@ -99,7 +102,7 @@ impl VmRecording { pub fn encode(&self) -> Result, VmRecordingError> { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 2; + const VERSION: u16 = 3; let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -122,18 +125,40 @@ impl VmRecording { } write_u32_len(self.frames.len(), &mut out)?; + let mut value_context = ValueEncodeContext::default(); for frame in &self.frames { write_u32_from_usize(frame.ip, &mut out)?; write_u32_from_usize(frame.call_depth, &mut out)?; + write_u32_len(frame.execution_frames.len(), &mut out)?; + for execution_frame in &frame.execution_frames { + match execution_frame.continuation { + VmFrameContinuation::Halt => out.push(0), + VmFrameContinuation::ResumeBytecode { return_ip } => { + out.push(1); + write_u32_from_usize(return_ip, &mut out)?; + } + VmFrameContinuation::ReturnToHost => out.push(2), + } + write_u32_from_usize(execution_frame.operand_stack_base, &mut out)?; + write_u32_from_usize(execution_frame.local_base, &mut out)?; + write_u32_from_usize(execution_frame.local_count, &mut out)?; + match execution_frame.prototype_id { + Some(prototype_id) => { + out.push(1); + out.extend_from_slice(&prototype_id.to_le_bytes()); + } + None => out.push(0), + } + } write_u32_len(frame.stack.len(), &mut out)?; for value in &frame.stack { - encode_value(value, &mut out)?; + encode_value(value, &mut out, &mut value_context)?; } write_u32_len(frame.locals.len(), &mut out)?; for value in &frame.locals { - encode_value(value, &mut out)?; + encode_value(value, &mut out, &mut value_context)?; } } @@ -142,8 +167,7 @@ impl VmRecording { pub fn decode(bytes: &[u8]) -> Result { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION_LEGACY: u16 = 1; - const VERSION: u16 = 2; + const VERSION: u16 = 3; let mut cursor = RecordingCursor::new(bytes); @@ -153,7 +177,7 @@ impl VmRecording { } let version = cursor.read_u16()?; - if version != VERSION && version != VERSION_LEGACY { + if version != VERSION { return Err(VmRecordingError::Message(format!( "unsupported recording version {version}" ))); @@ -180,25 +204,62 @@ impl VmRecording { let frame_count = cursor.read_u32()? as usize; let mut frames = Vec::with_capacity(frame_count); + let mut value_context = ValueDecodeContext::default(); for _ in 0..frame_count { let ip = cursor.read_u32()? as usize; let call_depth = cursor.read_u32()? as usize; + let execution_frame_count = cursor.read_u32()? as usize; + let mut execution_frames = Vec::with_capacity(execution_frame_count); + for _ in 0..execution_frame_count { + let continuation = match cursor.read_u8()? { + 0 => VmFrameContinuation::Halt, + 1 => VmFrameContinuation::ResumeBytecode { + return_ip: cursor.read_u32()? as usize, + }, + 2 => VmFrameContinuation::ReturnToHost, + _ => { + return Err(VmRecordingError::InvalidFormat( + "invalid frame continuation tag", + )); + } + }; + let operand_stack_base = cursor.read_u32()? as usize; + let local_base = cursor.read_u32()? as usize; + let local_count = cursor.read_u32()? as usize; + let prototype_id = match cursor.read_u8()? { + 0 => None, + 1 => Some(cursor.read_u32()?), + _ => { + return Err(VmRecordingError::InvalidFormat( + "invalid frame prototype tag", + )); + } + }; + execution_frames.push(VmExecutionFrameSnapshot { + continuation, + operand_stack_base, + local_base, + local_count, + prototype_id, + }); + } let stack_len = cursor.read_u32()? as usize; let mut stack = Vec::with_capacity(stack_len); for _ in 0..stack_len { - stack.push(decode_value(&mut cursor)?); + stack.push(decode_value(&mut cursor, &mut value_context)?); } let locals_len = cursor.read_u32()? as usize; let mut locals = Vec::with_capacity(locals_len); for _ in 0..locals_len { - locals.push(decode_value(&mut cursor)?); + locals.push(decode_value(&mut cursor, &mut value_context)?); } frames.push(VmRecordingFrame { ip, call_depth, + execution_frames, stack, locals, }); @@ -245,7 +306,22 @@ fn write_u32_from_usize(value: usize, out: &mut Vec) -> Result<(), VmRecordi Ok(()) } -fn encode_value(value: &Value, out: &mut Vec) -> Result<(), VmRecordingError> { +#[derive(Default)] +struct ValueEncodeContext { + environment_ids: HashMap, + next_environment_id: u32, +} + +#[derive(Default)] +struct ValueDecodeContext { + environments: HashMap>, +} + +fn encode_value( + value: &Value, + out: &mut Vec, + context: &mut ValueEncodeContext, +) -> Result<(), VmRecordingError> { match value { Value::Null => out.push(0), Value::Int(value) => { @@ -272,22 +348,62 @@ fn encode_value(value: &Value, out: &mut Vec) -> Result<(), VmRecordingError out.push(7); write_u32_len(values.len(), out)?; for value in values.iter() { - encode_value(value, out)?; + encode_value(value, out, context)?; } } Value::Map(entries) => { out.push(8); write_u32_len(entries.len(), out)?; for (key, value) in entries.iter() { - encode_value(key, out)?; - encode_value(value, out)?; + encode_value(key, out, context)?; + encode_value(value, out, context)?; + } + } + Value::Callable(callable) => { + out.push(9); + out.extend_from_slice(&callable.program_instance.to_le_bytes()); + out.extend_from_slice(&callable.prototype_id.to_le_bytes()); + out.push(match callable.kind { + crate::CallableKind::FunctionItem => 0, + crate::CallableKind::Closure => 1, + crate::CallableKind::HostFunction => 2, + }); + if let Some(env) = &callable.env { + let environment_key = Arc::as_ptr(env) as usize; + if let Some(environment_id) = context.environment_ids.get(&environment_key) { + out.push(2); + out.extend_from_slice(&environment_id.to_le_bytes()); + } else { + let environment_id = context.next_environment_id; + context.next_environment_id = + context.next_environment_id.checked_add(1).ok_or( + VmRecordingError::InvalidFormat("too many callable environments"), + )?; + context + .environment_ids + .insert(environment_key, environment_id); + out.push(1); + out.extend_from_slice(&environment_id.to_le_bytes()); + let cells = env.cells.lock().map_err(|_| { + VmRecordingError::InvalidFormat("poisoned callable environment") + })?; + write_u32_len(cells.len(), out)?; + for cell in cells.iter() { + encode_value(cell, out, context)?; + } + } + } else { + out.push(0); } } } Ok(()) } -fn decode_value(cursor: &mut RecordingCursor<'_>) -> Result { +fn decode_value( + cursor: &mut RecordingCursor<'_>, + context: &mut ValueDecodeContext, +) -> Result { match cursor.read_u8()? { 0 => Ok(Value::Null), 1 => Ok(Value::Int(cursor.read_i64()?)), @@ -309,7 +425,7 @@ fn decode_value(cursor: &mut RecordingCursor<'_>) -> Result) -> Result { + let program_instance = cursor.read_u64()?; + let prototype_id = cursor.read_u32()?; + let kind = match cursor.read_u8()? { + 0 => crate::CallableKind::FunctionItem, + 1 => crate::CallableKind::Closure, + 2 => crate::CallableKind::HostFunction, + _ => return Err(VmRecordingError::InvalidFormat("invalid callable kind")), + }; + let env = match cursor.read_u8()? { + 0 => None, + 1 => { + let environment_id = cursor.read_u32()?; + if context.environments.contains_key(&environment_id) { + return Err(VmRecordingError::InvalidFormat( + "duplicate callable environment id", + )); + } + let environment = Arc::new(crate::CallableEnvironment { + cells: Mutex::new(Vec::new()), + }); + context + .environments + .insert(environment_id, environment.clone()); + let len = cursor.read_u32()? as usize; + let mut cells = Vec::with_capacity(len); + for _ in 0..len { + cells.push(decode_value(cursor, context)?); + } + *environment.cells.lock().map_err(|_| { + VmRecordingError::InvalidFormat("poisoned callable environment") + })? = cells; + Some(environment) + } + 2 => { + let environment_id = cursor.read_u32()?; + Some(context.environments.get(&environment_id).cloned().ok_or( + VmRecordingError::InvalidFormat("unknown callable environment id"), + )?) + } + _ => return Err(VmRecordingError::InvalidFormat("invalid callable env flag")), + }; + Ok(Value::Callable(Arc::new(crate::CallableValue { + program_instance, + prototype_id, + kind, + env, + }))) + } _ => Err(VmRecordingError::InvalidFormat("invalid value tag")), } } @@ -386,3 +551,67 @@ impl<'a> RecordingCursor<'a> { Ok(f64::from_le_bytes(bytes)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recording_preserves_callable_environment_aliases() { + let environment = Arc::new(crate::CallableEnvironment { + cells: Mutex::new(vec![Value::Int(7)]), + }); + let callable = |prototype_id| { + Value::Callable(Arc::new(crate::CallableValue { + program_instance: 11, + prototype_id, + kind: crate::CallableKind::Closure, + env: Some(environment.clone()), + })) + }; + let recording = VmRecording { + program: Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]), + frames: vec![VmRecordingFrame { + ip: 0, + call_depth: 0, + execution_frames: Vec::new(), + stack: vec![callable(1), callable(2)], + locals: Vec::new(), + }], + terminal_status: None, + }; + + let decoded = VmRecording::decode(&recording.encode().expect("encode")).expect("decode"); + let Value::Callable(first) = &decoded.frames[0].stack[0] else { + panic!("first value should be callable"); + }; + let Value::Callable(second) = &decoded.frames[0].stack[1] else { + panic!("second value should be callable"); + }; + assert!(Arc::ptr_eq( + first.env.as_ref().expect("first env"), + second.env.as_ref().expect("second env") + )); + } + + #[test] + fn recording_v3_rejects_legacy_versions() { + let recording = VmRecording { + program: Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]), + frames: Vec::new(), + terminal_status: Some(VmStatus::Halted), + }; + let bytes = recording.encode().expect("recording should encode"); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 3); + + for legacy in [1u16, 2u16] { + let mut legacy_bytes = bytes.clone(); + legacy_bytes[4..6].copy_from_slice(&legacy.to_le_bytes()); + assert!(matches!( + VmRecording::decode(&legacy_bytes), + Err(VmRecordingError::Message(message)) + if message == format!("unsupported recording version {legacy}") + )); + } + } +} diff --git a/src/debugger/tests.rs b/src/debugger/tests.rs index d42900fe..c58856ec 100644 --- a/src/debugger/tests.rs +++ b/src/debugger/tests.rs @@ -239,12 +239,14 @@ fn recording_encode_decode_roundtrip() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![Value::array(vec![Value::Int(7), Value::Bool(true)])], locals: vec![Value::map(vec![(Value::string("k"), Value::Int(9))])], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![Value::Int(42)], locals: vec![Value::Int(42)], }, @@ -290,18 +292,21 @@ fn replay_break_sets_pause_point_for_continue() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 2, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -340,12 +345,14 @@ fn replay_continue_marks_end_of_recording() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -390,18 +397,21 @@ fn replay_break_line_sets_pause_point_for_continue() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 5, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 9, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -457,12 +467,14 @@ fn replay_break_line_on_non_executable_source_line_resolves_forward() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 10, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -508,30 +520,35 @@ fn replay_offset_breakpoint_persists_until_cleared() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 2, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 3, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -599,30 +616,35 @@ fn replay_line_breakpoint_persists_until_cleared() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 1, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 2, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 3, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 4, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -797,12 +819,14 @@ fn public_replay_api_updates_cursor_and_reports_line() { VmRecordingFrame { ip: 0, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 5, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, @@ -1111,30 +1135,35 @@ fn replay_next_and_out_follow_call_depth_transitions() { VmRecordingFrame { ip: 10, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 10, call_depth: 1, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 11, call_depth: 1, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 10, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, VmRecordingFrame { ip: 12, call_depth: 0, + execution_frames: Vec::new(), stack: vec![], locals: vec![], }, diff --git a/src/lib.rs b/src/lib.rs index eb4bbd9a..2fc95134 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,11 @@ pub use builtins::{ callable_signatures_for_builtin_namespace_member, default_host_callables, is_builtin_namespace, language_builtin_specs, resolve_builtin_namespace_call, }; -pub use bytecode::{HostImport, OpCode, Program, TypeMap, Value, ValueType}; +pub use bytecode::{ + CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, + FunctionRegion, HostImport, OpCode, Program, ProgramInstanceId, RootCallableBinding, + ScriptFunction, TypeMap, Value, ValueType, +}; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; @@ -86,7 +90,8 @@ pub use vm::diagnostics::render_vm_error; pub use vm::{ AotArtifactError, CallOutcome, CallReturn, EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, HostFunctionRegistry, - HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, + HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, ScriptArgs, + ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, }; #[cfg(feature = "runtime")] diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index b01bea3b..3a0623a2 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 = 2; -const ABI_VERSION: u16 = 1; +const VERSION: u16 = 3; +const ABI_VERSION: u16 = 2; const FLAGS: u16 = 0; #[derive(Debug)] @@ -458,6 +458,7 @@ fn write_value(value: &Value, out: &mut Vec) -> Result<(), AotArtifactError> out.extend_from_slice(&entry); } } + Value::Callable(_) => return Err(AotArtifactError::InvalidValueType(9)), } Ok(()) } @@ -551,6 +552,7 @@ fn read_value_type(raw: u8) -> Result { 6 => Ok(ValueType::Bytes), 7 => Ok(ValueType::Array), 8 => Ok(ValueType::Map), + 9 => Ok(ValueType::Callable), other => Err(AotArtifactError::InvalidValueType(other)), } } diff --git a/src/vm/aot/cfg.rs b/src/vm/aot/cfg.rs index 230a3406..4771a24c 100644 --- a/src/vm/aot/cfg.rs +++ b/src/vm/aot/cfg.rs @@ -34,13 +34,16 @@ pub(crate) enum AotBlockTerminal { Fallthrough { next_ip: usize, }, + InterpreterExit { + exit_ip: usize, + }, Stop, } impl AotBlockTerminal { pub(crate) fn successor_ips(&self) -> Vec { match self { - Self::Return | Self::Stop => Vec::new(), + Self::Return | Self::InterpreterExit { .. } | Self::Stop => Vec::new(), Self::Jump { target_ip } => vec![*target_ip], Self::ConditionalJump { target_ip, @@ -98,6 +101,9 @@ fn build_cfg_from_code(code: &[u8]) -> Result { target_ip: read_jump_target(code, ip)?, fallthrough_ip: next_ip, }), + OpCode::CallValue | OpCode::MakeCallable => { + Some(AotBlockTerminal::InterpreterExit { exit_ip: ip }) + } _ if next_ip == code.len() => Some(AotBlockTerminal::Stop), _ if Some(next_ip) == next_block_start => { Some(AotBlockTerminal::Fallthrough { next_ip }) @@ -141,6 +147,11 @@ fn collect_block_starts(code: &[u8]) -> Result, AotCfgError> { starts.insert(next_ip); } } + OpCode::CallValue | OpCode::MakeCallable => { + if next_ip < code.len() { + starts.insert(next_ip); + } + } _ => {} } ip = next_ip; diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index 80dd06b5..f4d8bb74 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -1395,6 +1395,25 @@ fn lower_aot_ssa_terminator( ); jump_with_status(b, exit_block, status); } + AotSsaTerminator::InterpreterBoundary { ip, stack, locals } => { + materialize_state_to_vm( + b, + vm_ptr, + exit_block, + pointer_type, + layout, + helper_refs, + helper_addrs, + stack, + locals, + values, + *ip, + )?; + let status = b + .ins() + .iconst(types::I32, crate::vm::native::STATUS_TRACE_EXIT as i64); + jump_with_status(b, exit_block, status); + } AotSsaTerminator::Return { ip, stack, locals } => { materialize_state_to_vm( b, diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index 1c08834e..d71838b6 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -315,6 +315,13 @@ fn lower_block( } apply_call_provenance(&mut provenance, argc, index); } + OpCode::CallValue | OpCode::MakeCallable => { + return Err(AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "script callable frame operation requires runtime lowering", + }); + } OpCode::Ret | OpCode::Br | OpCode::Brfalse => { return Err(AotLowerError::InvalidImmediate { ip, @@ -487,6 +494,9 @@ fn is_explicit_terminal_opcode( && read_u32(code, ip + 1) == Some(*target_ip as u32) && next_ip == *fallthrough_ip), AotBlockTerminal::Fallthrough { .. } => Ok(false), + AotBlockTerminal::InterpreterExit { exit_ip } => { + Ok(matches!(opcode, OpCode::CallValue | OpCode::MakeCallable) && ip == *exit_ip) + } AotBlockTerminal::Stop => Ok(false), } } diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index 4deac4ac..7f3d51f7 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -102,9 +102,10 @@ impl Vm { self.has_aot_program() ))) } - STATUS_TRACE_EXIT => Err(VmError::JitNative( - "whole-program aot returned trace-exit status unexpectedly".to_string(), - )), + STATUS_TRACE_EXIT => { + self.aot_interpreter_boundary_hit = true; + Ok(ExecOutcome::Continue) + } other => Err(VmError::JitNative(format!( "unexpected aot return status {other}" ))), diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 0365b54c..66bca70f 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -412,6 +412,11 @@ pub(crate) enum AotSsaTerminator { stack: Vec, locals: Vec, }, + InterpreterBoundary { + ip: usize, + stack: Vec, + locals: Vec, + }, Return { ip: usize, stack: Vec, @@ -730,6 +735,7 @@ fn verify_terminator( verify_jump_target(if_false, block_params, available_values) } AotSsaTerminator::CallBoundary { stack, locals, .. } + | AotSsaTerminator::InterpreterBoundary { stack, locals, .. } | AotSsaTerminator::Return { stack, locals, .. } => { for materialization in stack.iter().chain(locals.iter()) { verify_materialization(materialization, available_values)?; @@ -830,6 +836,10 @@ enum ProcessResult { frame: Frame, resume_frame: Frame, }, + InterpreterBoundary { + ip: usize, + frame: Frame, + }, Return { ip: usize, frame: Frame, @@ -1060,6 +1070,13 @@ impl<'a> Builder<'a> { stack: materialize_values(&frame.stack), locals: materialize_values(&frame.locals), }, + ProcessResult::InterpreterBoundary { ip, frame } => { + AotSsaTerminator::InterpreterBoundary { + ip, + stack: materialize_values(&frame.stack), + locals: materialize_values(&frame.locals), + } + } ProcessResult::Return { ip, frame } => AotSsaTerminator::Return { ip, stack: materialize_values(&frame.stack), @@ -1138,7 +1155,9 @@ impl<'a> Builder<'a> { } => { self.merge_shape(call.resume_ip, resume_frame.shape(), &mut queue)?; } - ProcessResult::Return { .. } | ProcessResult::Stop { .. } => {} + ProcessResult::InterpreterBoundary { .. } + | ProcessResult::Return { .. } + | ProcessResult::Stop { .. } => {} } } @@ -1377,6 +1396,12 @@ impl<'a> Builder<'a> { frame_after_pop: frame.clone(), }) } + AotBlockTerminal::InterpreterExit { exit_ip } => { + Ok(ProcessResult::InterpreterBoundary { + ip: *exit_ip, + frame: frame.clone(), + }) + } AotBlockTerminal::Return => Ok(ProcessResult::Return { ip: block .terminal_ip @@ -1434,6 +1459,7 @@ fn terminal_ip(block: &super::ir::AotIrBlock) -> Option { block.end_ip.checked_sub(5) } AotBlockTerminal::Fallthrough { .. } | AotBlockTerminal::Stop => None, + AotBlockTerminal::InterpreterExit { exit_ip } => Some(exit_ip), } } @@ -1533,7 +1559,8 @@ fn value_type_repr(ty: ValueType) -> AotSsaValueRepr { | ValueType::String | ValueType::Bytes | ValueType::Array - | ValueType::Map => AotSsaValueRepr::Tagged, + | ValueType::Map + | ValueType::Callable => AotSsaValueRepr::Tagged, } } @@ -1542,9 +1569,12 @@ fn value_repr_for_constant(value: &Value) -> AotSsaValueRepr { Value::Int(_) => AotSsaValueRepr::I64, Value::Float(_) => AotSsaValueRepr::F64, Value::Bool(_) => AotSsaValueRepr::Bool, - Value::Null | Value::String(_) | Value::Bytes(_) | Value::Array(_) | Value::Map(_) => { - AotSsaValueRepr::Tagged - } + Value::Null + | Value::String(_) + | Value::Bytes(_) + | Value::Array(_) + | Value::Map(_) + | Value::Callable(_) => AotSsaValueRepr::Tagged, } } @@ -1572,7 +1602,8 @@ fn apply_direct_instruction( | Value::String(_) | Value::Bytes(_) | Value::Array(_) - | Value::Map(_) => AotSsaInstKind::ConstSlot { + | Value::Map(_) + | Value::Callable(_) => AotSsaInstKind::ConstSlot { index: *const_index, }, }; diff --git a/src/vm/jit/native/lower.rs b/src/vm/jit/native/lower.rs index 5185cebb..931b85f2 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -1144,7 +1144,12 @@ fn lower_ssa_inst( b.ins().icmp_imm(IntCC::NotEqual, raw, 0) } SsaInstKind::Constant( - Value::Null | Value::String(_) | Value::Bytes(_) | Value::Array(_) | Value::Map(_), + Value::Null + | Value::String(_) + | Value::Bytes(_) + | Value::Array(_) + | Value::Map(_) + | Value::Callable(_), ) => { let addr = tagged_constant_addrs .get(&output.id) diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index 7bcdea6b..c55757b0 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -174,6 +174,7 @@ impl ValueInfo { Value::Bytes(_) => Self::tagged_typed(ValueType::Bytes), Value::Array(_) => Self::tagged_typed(ValueType::Array), Value::Map(_) => Self::tagged_typed(ValueType::Map), + Value::Callable(_) => Self::tagged_typed(ValueType::Callable), } } @@ -371,6 +372,9 @@ enum DecodedOp { argc: u8, yields: bool, }, + InterpreterBoundary { + ip: usize, + }, } impl DecodedOp { @@ -383,7 +387,8 @@ impl DecodedOp { | Self::Pop { .. } | Self::Dup { .. } | Self::Br { .. } - | Self::Call { .. } => false, + | Self::Call { .. } + | Self::InterpreterBoundary { .. } => false, Self::Stloc { .. } | Self::Neg { .. } | Self::Not { .. } @@ -671,6 +676,14 @@ impl<'a> TraceCursor<'a> { yields: true, } } + } else if opcode == OpCode::CallValue as u8 { + let _argc = read_u8(&self.program.code, &mut self.ip) + .ok_or(TraceRecordError::InvalidImmediate("callvalue argc"))?; + DecodedOp::InterpreterBoundary { ip: instr_ip } + } else if opcode == OpCode::MakeCallable as u8 { + let _prototype_id = read_u32(&self.program.code, &mut self.ip) + .ok_or(TraceRecordError::InvalidImmediate("makecallable prototype"))?; + DecodedOp::InterpreterBoundary { ip: instr_ip } } else { return Err(TraceRecordError::UnsupportedOpcode(opcode)); }; @@ -1133,6 +1146,20 @@ pub(crate) fn record_trace( } cursor.jump_to(target)?; } + DecodedOp::InterpreterBoundary { ip } => { + op_names.push("callable_boundary".to_string()); + let exit = builder.add_exit( + ip, + materialize_stack(&frame.stack), + materialize_locals(&frame.locals), + frame.dirty_locals.clone(), + ); + builder + .set_terminator(current_block, SsaTerminator::Exit { exit }) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + terminal = Some(JitTraceTerminal::BranchExit); + break; + } DecodedOp::Call { ip, index, @@ -1424,6 +1451,7 @@ fn infer_loop_header_plan( frame.push(ValueInfo::tagged_typed(return_type)); } DecodedOp::Call { .. } => return Ok(None), + DecodedOp::InterpreterBoundary { .. } => return Ok(None), DecodedOp::Brfalse { target, fallthrough_ip, @@ -3350,6 +3378,7 @@ fn emit_specialized_builtin_call( ValueType::Bytes => "bytes", ValueType::Array => "array", ValueType::Map => "map", + ValueType::Callable => "callable", ValueType::Unknown => { return Err(TraceRecordError::UnsupportedTrace( "type_of known specialization received unknown type".to_string(), @@ -4513,6 +4542,23 @@ mod tests { code[start..start + 4].copy_from_slice(&target.to_le_bytes()); } + #[test] + fn callable_opcodes_decode_as_explicit_interpreter_boundaries() { + let mut bytecode = BytecodeBuilder::new(); + bytecode.call_value(2); + bytecode.make_callable(7); + let program = Program::new(Vec::new(), bytecode.finish()); + let mut cursor = TraceCursor::new(&program, 0, 8); + assert!(matches!( + cursor.next().expect("callvalue decode"), + Some(DecodedOp::InterpreterBoundary { ip: 0 }) + )); + assert!(matches!( + cursor.next().expect("makecallable decode"), + Some(DecodedOp::InterpreterBoundary { ip: 2 }) + )); + } + #[test] fn unknown_len_container_uses_checked_value_len_specialization() { let program = Program::new(Vec::new(), Vec::new()); diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index e4d75383..a1eebd52 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -539,6 +539,12 @@ impl Vm { } native::STATUS_TRACE_EXIT => { self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + if self.jit.trace_clone(current_trace_id).is_some_and(|trace| { + trace.op_names.last().map(String::as_str) == Some("callable_boundary") + }) { + self.jit.block_trace(current_trace_id); + return Ok(ExecOutcome::Continue); + } // Fast path: if this trace looped back to its own root and cannot yield via host // calls, keep executing in native mode without bouncing through the interpreter. if !has_yielding_call diff --git a/src/vm/mod.rs b/src/vm/mod.rs index e9379f35..c3aa0ffd 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,6 +1,7 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; -use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; pub(crate) mod aot; pub mod diagnostics; @@ -23,9 +24,13 @@ pub use self::host::{ StaticHostStackFunction, }; use self::host::{HostCallExecOutcome, VmHostFunction, WaitingHostOp}; -pub use crate::bytecode::{HostImport, OpCode, Program, Value, ValueType}; +pub use crate::bytecode::{ + CallableTarget, CallableValue, HostImport, OpCode, Program, ProgramInstanceId, Value, ValueType, +}; use crate::bytecode::{StableHasher, hash_value}; -pub use store::Store; +pub use store::{ + IntoScriptValue, QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, Store, +}; #[derive(Clone, Copy, Debug)] pub(crate) enum NumericValue { @@ -72,6 +77,24 @@ pub enum VmError { expected: u8, got: u8, }, + InvalidFrameState(&'static str), + InvalidCallable, + StaleCallable { + expected: ProgramInstanceId, + found: ProgramInstanceId, + }, + InvalidCallablePrototype(u32), + InvalidBranchTarget { + target: usize, + }, + CallableArityMismatch { + prototype_id: u32, + expected: u8, + got: u8, + }, + CallStackOverflow { + limit: usize, + }, UnboundImport(String), InvalidOpcode(u8), BytecodeBounds, @@ -117,6 +140,34 @@ impl std::fmt::Display for VmError { f, "invalid call arity for import '{import}': expected {expected}, got {got}", ), + VmError::InvalidFrameState(message) => { + write!(f, "invalid execution frame state: {message}") + } + VmError::InvalidCallable => write!(f, "callvalue operand is not callable"), + VmError::StaleCallable { expected, found } => write!( + f, + "stale callable program instance: expected {expected}, found {found}" + ), + VmError::InvalidCallablePrototype(id) => { + write!(f, "invalid callable prototype {id}") + } + VmError::InvalidBranchTarget { target } => { + write!( + f, + "branch target {target} leaves the active function region" + ) + } + VmError::CallableArityMismatch { + prototype_id, + expected, + got, + } => write!( + f, + "invalid call arity for callable {prototype_id}: expected {expected}, got {got}" + ), + VmError::CallStackOverflow { limit } => { + write!(f, "script call stack limit {limit} exceeded") + } VmError::UnboundImport(name) => write!(f, "unbound host import '{name}'"), VmError::InvalidOpcode(opcode) => write!(f, "invalid opcode {opcode}"), VmError::BytecodeBounds => write!(f, "bytecode bounds"), @@ -149,6 +200,13 @@ impl std::error::Error for VmError {} pub type VmResult = Result; +static NEXT_PROGRAM_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); +const MAX_SCRIPT_CALL_DEPTH: usize = 1024; + +fn next_program_instance_id() -> ProgramInstanceId { + NEXT_PROGRAM_INSTANCE_ID.fetch_add(1, Ordering::Relaxed) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VmStatus { Halted, @@ -213,6 +271,60 @@ const INT_UNARY_OPERAND_TYPE_HINT: PackedOperandTypes = const FLOAT_UNARY_OPERAND_TYPE_HINT: PackedOperandTypes = pack_operand_types(ValueType::Float, ValueType::Unknown); +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VmFrameContinuation { + Halt, + ResumeBytecode { return_ip: usize }, + ReturnToHost, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmExecutionFrameSnapshot { + pub continuation: VmFrameContinuation, + pub operand_stack_base: usize, + pub local_base: usize, + pub local_count: usize, + pub prototype_id: Option, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FrameContinuation { + Halt, + ResumeBytecode { return_ip: usize }, + ReturnToHost, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct ExecutionFrame { + pub(crate) continuation: FrameContinuation, + pub(crate) operand_stack_base: usize, + pub(crate) local_base: usize, + pub(crate) local_count: usize, + pub(crate) prototype_id: Option, + pub(crate) active_callable: Option>, +} + +impl ExecutionFrame { + fn root(local_count: usize) -> Self { + Self { + continuation: FrameContinuation::Halt, + operand_stack_base: 0, + local_base: 0, + local_count, + prototype_id: None, + active_callable: None, + } + } +} + +#[derive(Clone, Debug)] +struct QueuedCallable { + callable: Value, + args: Vec, +} + pub struct Vm { program: Arc, #[allow(dead_code)] @@ -236,8 +348,15 @@ pub struct Vm { resolved_calls: Vec, resolved_calls_dirty: bool, call_depth: usize, + program_instance: ProgramInstanceId, + execution_frames: Vec, + host_return: Option, + queued_callables: VecDeque, + draining_queued_callables: bool, + shutdown: bool, aot_program: Option, aot_exec_count: u64, + aot_interpreter_boundary_hit: bool, jit: jit::TraceJitEngine, native_traces: Vec>, native_trace_exec_count: u64, @@ -305,6 +424,7 @@ const fn unpack_operand_type(raw: u8) -> ValueType { 6 => ValueType::Bytes, 7 => ValueType::Array, 8 => ValueType::Map, + 9 => ValueType::Callable, _ => ValueType::Unknown, } } @@ -338,12 +458,33 @@ pub(crate) fn checked_int_rem(lhs: i64, rhs: i64) -> VmResult { fn compute_program_cache_key(program: &Program) -> u64 { let mut hasher = StableHasher::default(); + crate::bytecode::BYTECODE_ABI_VERSION.hash(&mut hasher); program.code.hash(&mut hasher); program.local_count.hash(&mut hasher); for constant in &program.constants { hash_value(constant, &mut hasher); } program.imports.hash(&mut hasher); + program.script_functions.hash(&mut hasher); + program.function_regions.hash(&mut hasher); + program.root_callable_bindings.hash(&mut hasher); + program.callable_prototypes.len().hash(&mut hasher); + for prototype in &program.callable_prototypes { + prototype.kind.hash(&mut hasher); + prototype.target.hash(&mut hasher); + prototype.arity.hash(&mut hasher); + prototype.frame_local_count.hash(&mut hasher); + prototype.parameter_slots.hash(&mut hasher); + prototype.capture_slots.hash(&mut hasher); + prototype.self_slot.hash(&mut hasher); + match &prototype.schema { + Some(schema) => { + 1u8.hash(&mut hasher); + hash_type_schema(schema, &mut hasher); + } + None => 0u8.hash(&mut hasher), + } + } hash_type_map(program.type_map.as_ref(), &mut hasher); hasher.finish() } @@ -382,6 +523,31 @@ fn hash_local_schemas(schemas: &[Option], state: &m } } +fn value_matches_type_schema(value: &Value, schema: &crate::compiler::TypeSchema) -> bool { + use crate::compiler::TypeSchema; + + match schema { + TypeSchema::Unknown | TypeSchema::GenericParam(_) => true, + TypeSchema::Null => matches!(value, Value::Null), + TypeSchema::Int => matches!(value, Value::Int(_)), + TypeSchema::Float => matches!(value, Value::Float(_)), + TypeSchema::Number => matches!(value, Value::Int(_) | Value::Float(_)), + TypeSchema::Bool => matches!(value, Value::Bool(_)), + TypeSchema::String => matches!(value, Value::String(_)), + TypeSchema::Bytes => matches!(value, Value::Bytes(_)), + TypeSchema::Optional(inner) => { + matches!(value, Value::Null) || value_matches_type_schema(value, inner) + } + TypeSchema::Named(_, _) | TypeSchema::Map(_) | TypeSchema::Object(_) => { + matches!(value, Value::Map(_)) + } + TypeSchema::Array(_) | TypeSchema::ArrayTuple(_) | TypeSchema::ArrayTupleRest { .. } => { + matches!(value, Value::Array(_)) + } + TypeSchema::Callable { .. } => matches!(value, Value::Callable(_)), + } +} + fn hash_type_schema(schema: &crate::compiler::TypeSchema, state: &mut impl Hasher) { use crate::compiler::TypeSchema; @@ -474,7 +640,7 @@ impl Vm { let decoded_instruction_data = program.shared_decoded_instruction_data(); let epoch_handle = EpochHandle::default(); let epoch_counter_ptr = epoch_handle.as_ptr() as usize; - Self { + let mut vm = Self { program, program_constants_ptr: program_constants_ptr as usize, program_constants_len, @@ -493,8 +659,15 @@ impl Vm { resolved_calls: Vec::new(), resolved_calls_dirty: true, call_depth: 0, + program_instance: next_program_instance_id(), + execution_frames: vec![ExecutionFrame::root(local_count)], + host_return: None, + queued_callables: VecDeque::new(), + draining_queued_callables: false, + shutdown: false, aot_program: None, aot_exec_count: 0, + aot_interpreter_boundary_hit: false, jit: jit::TraceJitEngine::new(jit_config), native_traces: Vec::new(), native_trace_exec_count: 0, @@ -531,6 +704,30 @@ impl Vm { generic_builtin_call_count: 0, scalar_superinstruction_count: 0, local_type_hint_hit_count: 0, + }; + vm.initialize_root_callable_bindings(); + vm + } + + fn initialize_root_callable_bindings(&mut self) { + let bindings = self.program.root_callable_bindings.clone(); + for binding in bindings { + let Some(prototype) = self + .program + .callable_prototypes + .get(binding.prototype_id as usize) + else { + continue; + }; + let Some(slot) = self.locals.get_mut(binding.local_slot as usize) else { + continue; + }; + *slot = Value::Callable(Arc::new(CallableValue { + program_instance: self.program_instance, + prototype_id: binding.prototype_id, + kind: prototype.kind, + env: None, + })); } } @@ -664,8 +861,19 @@ impl Vm { self.clear_epoch_deadline(); self.clear_stack_with_drop_contract(); self.clear_locals_with_drop_contract(); + self.locals.resize(self.program.local_count, Value::Null); + self.program_instance = next_program_instance_id(); + self.initialize_root_callable_bindings(); crate::builtins::runtime::close_all_handles(self); self.call_depth = 0; + self.execution_frames.clear(); + self.execution_frames + .push(ExecutionFrame::root(self.program.local_count)); + self.host_return = None; + self.queued_callables.clear(); + self.draining_queued_callables = false; + self.shutdown = false; + self.aot_interpreter_boundary_hit = false; self.waiting_host_op = None; self.next_host_op_id = 1; self.io_state = crate::builtins::runtime::IoState::default(); @@ -757,9 +965,43 @@ impl Vm { } } + #[inline(always)] + fn active_local_base(&self) -> usize { + self.execution_frames + .last() + .map(|frame| frame.local_base) + .unwrap_or(0) + } + + fn script_frame_depth(&self) -> usize { + self.execution_frames + .iter() + .filter(|frame| frame.prototype_id.is_some()) + .count() + } + + #[inline(always)] + fn absolute_local_index(&self, index: u8) -> VmResult { + let absolute = self + .active_local_base() + .checked_add(index as usize) + .ok_or(VmError::InvalidLocal(index))?; + self.locals + .get(absolute) + .map(|_| absolute) + .ok_or(VmError::InvalidLocal(index)) + } + + #[inline(always)] + fn load_local_value(&self, index: u8) -> VmResult { + let absolute = self.absolute_local_index(index)?; + Ok(self.locals[absolute].clone()) + } + #[inline(always)] pub(super) fn local_numeric_value(&self, index: u8) -> Option { - match self.locals.get(index as usize)? { + let absolute = self.absolute_local_index(index).ok()?; + match self.locals.get(absolute)? { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -820,6 +1062,302 @@ impl Vm { self.stack.pop().ok_or(VmError::StackUnderflow) } + fn make_callable(&mut self, prototype_id: u32) -> VmResult<()> { + let prototype = self + .program + .callable_prototypes + .get(prototype_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + let capture_count = prototype.capture_slots.len(); + if self.stack.len() < capture_count { + return Err(VmError::StackUnderflow); + } + let captures = self.stack.split_off(self.stack.len() - capture_count); + let env = if prototype.kind == crate::CallableKind::Closure || !captures.is_empty() { + Some(Arc::new(crate::CallableEnvironment { + cells: Mutex::new(captures), + })) + } else { + None + }; + self.stack.push(Value::Callable(Arc::new(CallableValue { + program_instance: self.program_instance, + prototype_id, + kind: prototype.kind, + env, + }))); + Ok(()) + } + + fn execute_call_value(&mut self, argc: u8) -> VmResult { + let operand_count = argc as usize + 1; + if self.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let operand_stack_base = self.stack.len() - operand_count; + let mut operands = self.stack.split_off(operand_stack_base); + let callee = operands.remove(0); + let Value::Callable(callable) = callee else { + return Err(VmError::InvalidCallable); + }; + if callable.program_instance != self.program_instance { + return Err(VmError::StaleCallable { + expected: self.program_instance, + found: callable.program_instance, + }); + } + let prototype = self + .program + .callable_prototypes + .get(callable.prototype_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + if prototype.arity != argc { + return Err(VmError::CallableArityMismatch { + prototype_id: callable.prototype_id, + expected: prototype.arity, + got: argc, + }); + } + if let Some(crate::compiler::TypeSchema::Callable { params, .. }) = &prototype.schema + && (params.len() != operands.len() + || !params + .iter() + .zip(&operands) + .all(|(schema, value)| value_matches_type_schema(value, schema))) + { + return Err(VmError::TypeMismatch("callable argument schema")); + } + + match prototype.target { + CallableTarget::ScriptFunction(function_id) => { + if self.call_depth >= MAX_SCRIPT_CALL_DEPTH { + return Err(VmError::CallStackOverflow { + limit: MAX_SCRIPT_CALL_DEPTH, + }); + } + let function = self + .program + .script_functions + .get(function_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + if prototype.parameter_slots.len() != operands.len() { + return Err(VmError::CallableArityMismatch { + prototype_id: callable.prototype_id, + expected: prototype.parameter_slots.len() as u8, + got: argc, + }); + } + let inherited_callables = self + .execution_frames + .last() + .map(|frame| { + self.locals[frame.local_base..frame.local_base + frame.local_count] + .iter() + .enumerate() + .filter(|(_, value)| matches!(value, Value::Callable(_))) + .map(|(slot, value)| (slot, value.clone())) + .collect::>() + }) + .unwrap_or_default(); + let local_base = self.locals.len(); + let local_count = prototype.frame_local_count; + self.locals + .resize(local_base.saturating_add(local_count), Value::Null); + for binding in &self.program.root_callable_bindings { + let relative = binding.local_slot as usize; + if relative >= local_count { + return Err(VmError::InvalidFrameState( + "root callable binding is outside the script frame", + )); + } + let bound_prototype = self + .program + .callable_prototypes + .get(binding.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; + self.locals[local_base + relative] = Value::Callable(Arc::new(CallableValue { + program_instance: self.program_instance, + prototype_id: binding.prototype_id, + kind: bound_prototype.kind, + env: None, + })); + } + for (slot, value) in inherited_callables { + if slot < local_count { + self.locals[local_base + slot] = value; + } + } + for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { + let relative = *slot as usize; + if relative >= local_count { + return Err(VmError::InvalidFrameState( + "parameter slot is outside the script frame", + )); + } + self.locals[local_base + relative] = argument; + } + if let Some(environment) = &callable.env { + let cells = environment + .cells + .lock() + .map_err(|_| VmError::InvalidFrameState("poisoned callable environment"))?; + if cells.len() != prototype.capture_slots.len() { + return Err(VmError::InvalidFrameState( + "callable environment layout mismatch", + )); + } + for (slot, cell) in prototype.capture_slots.iter().zip(cells.iter()) { + let relative = *slot as usize; + if relative >= local_count { + return Err(VmError::InvalidFrameState( + "capture slot is outside the script frame", + )); + } + self.locals[local_base + relative] = cell.clone(); + } + } + if let Some(slot) = prototype.self_slot { + let relative = slot as usize; + if relative >= local_count { + return Err(VmError::InvalidFrameState( + "self slot is outside the script frame", + )); + } + self.locals[local_base + relative] = Value::Callable(callable.clone()); + } + let return_ip = self.ip; + self.execution_frames.push(ExecutionFrame { + continuation: FrameContinuation::ResumeBytecode { return_ip }, + operand_stack_base, + local_base, + local_count, + prototype_id: Some(callable.prototype_id), + active_callable: Some(callable), + }); + self.call_depth = self.script_frame_depth(); + self.ip = function.entry_ip as usize; + self.charge_interrupt_tick()?; + Ok(ExecOutcome::Continue) + } + CallableTarget::HostImport(import_index) => { + self.stack.extend(operands); + let call_ip = self.ip.saturating_sub(2); + match self.execute_host_call(import_index, argc, call_ip)? { + HostCallExecOutcome::Returned => Ok(ExecOutcome::Continue), + HostCallExecOutcome::Halted => Ok(ExecOutcome::Halted), + HostCallExecOutcome::Yielded => Ok(ExecOutcome::Yielded), + HostCallExecOutcome::Pending(op_id) => Ok(ExecOutcome::Waiting(op_id)), + } + } + } + } + + fn complete_active_frame(&mut self) -> VmResult { + let frame = self + .execution_frames + .pop() + .ok_or(VmError::InvalidFrameState("missing active frame"))?; + if self.stack.len() < frame.operand_stack_base { + return Err(VmError::InvalidFrameState( + "operand stack is below the active frame base", + )); + } + if matches!(frame.continuation, FrameContinuation::Halt) { + self.call_depth = self.script_frame_depth(); + return Ok(ExecOutcome::Halted); + } + + let result = if self.stack.len() > frame.operand_stack_base { + self.stack.pop().expect("stack length checked above") + } else { + Value::Null + }; + while self.stack.len() > frame.operand_stack_base { + let value = self.stack.pop().expect("stack length checked above"); + self.drop_value_with_contract(value); + } + self.call_depth = self.script_frame_depth(); + + let mut replaced_capture_values = Vec::new(); + if let (Some(prototype_id), Some(callable)) = + (frame.prototype_id, frame.active_callable.as_ref()) + && let Some(environment) = callable.env.as_ref() + && let Some(prototype) = self.program.callable_prototypes.get(prototype_id as usize) + { + let mut cells = environment + .cells + .lock() + .map_err(|_| VmError::InvalidFrameState("callable environment lock is poisoned"))?; + for (cell, slot) in cells.iter_mut().zip(&prototype.capture_slots) { + if prototype.self_slot == Some(*slot) { + continue; + } + let absolute = frame + .local_base + .checked_add(usize::from(*slot)) + .ok_or(VmError::InvalidFrameState("capture slot overflow"))?; + let value = + self.locals + .get(absolute) + .cloned() + .ok_or(VmError::InvalidFrameState( + "capture slot exceeds active frame locals", + ))?; + replaced_capture_values.push(std::mem::replace(cell, value)); + } + } + for value in replaced_capture_values { + self.drop_value_with_contract(value); + } + + if !matches!(frame.continuation, FrameContinuation::Halt) { + let frame_end = frame + .local_base + .checked_add(frame.local_count) + .ok_or(VmError::InvalidFrameState("local frame range overflow"))?; + if frame_end != self.locals.len() { + return Err(VmError::InvalidFrameState( + "active local frame does not end at the local stack tail", + )); + } + let drained = self.locals.drain(frame.local_base..).collect::>(); + for value in drained { + self.drop_value_with_contract(value); + } + } + + if let Some(prototype_id) = frame.prototype_id + && let Some(crate::compiler::TypeSchema::Callable { result: schema, .. }) = self + .program + .callable_prototypes + .get(prototype_id as usize) + .and_then(|prototype| prototype.schema.as_ref()) + && !value_matches_type_schema(&result, schema) + { + self.drop_value_with_contract(result); + return Err(VmError::TypeMismatch("callable return schema")); + } + + match frame.continuation { + FrameContinuation::Halt => { + self.stack.push(result); + Ok(ExecOutcome::Halted) + } + FrameContinuation::ResumeBytecode { return_ip } => { + self.ip = return_ip; + self.stack.push(result); + Ok(ExecOutcome::Continue) + } + FrameContinuation::ReturnToHost => { + self.host_return = Some(result); + Ok(ExecOutcome::Halted) + } + } + } + pub(super) fn can_fuse_call_ret_pattern(&self) -> bool { let code = &self.program.code; self.ip < code.len() && code[self.ip] == OpCode::Ret as u8 @@ -865,7 +1403,8 @@ impl Vm { | Value::Float(_) | Value::Bool(_) | Value::String(_) - | Value::Bytes(_) => { + | Value::Bytes(_) + | Value::Callable(_) => { self.drop_contract_events = self.drop_contract_events.saturating_add(1); } } @@ -1223,9 +1762,10 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { + let absolute = self.absolute_local_index(index)?; let slot = self .locals - .get_mut(index as usize) + .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; let previous = std::mem::replace(slot, value); self.drop_value_with_contract(previous); @@ -1265,6 +1805,23 @@ impl Vm { if target >= self.program.code.len() { return Err(VmError::BytecodeBounds); } + if !self.program.function_regions.is_empty() { + let active_prototype = self + .execution_frames + .last() + .and_then(|frame| frame.prototype_id); + let target_prototype = self + .program + .function_regions + .iter() + .find(|region| { + region.start_ip as usize <= target && target < region.end_ip as usize + }) + .and_then(|region| region.prototype_id); + if active_prototype != target_prototype { + return Err(VmError::InvalidBranchTarget { target }); + } + } self.ip = target; Ok(()) } @@ -1369,7 +1926,11 @@ impl Vm { active_debugger.on_instruction(self); } - if allow_jit && self.has_aot_program() && !self.drop_contract_events_enabled() { + if allow_jit + && self.has_aot_program() + && !self.aot_interpreter_boundary_hit + && !self.drop_contract_events_enabled() + { let outcome = match self.execute_aot_entry() { Ok(outcome) => outcome, Err(err) => { @@ -1395,6 +1956,7 @@ impl Vm { } if allow_jit + && self.call_depth == 0 && self.jit_config().enabled && self.builtin_overrides.is_empty() && !self.drop_contract_events_enabled() @@ -1484,9 +2046,10 @@ impl Vm { opcode: u8, allow_superinstructions: bool, ) -> VmResult { + let allow_superinstructions = allow_superinstructions && self.call_depth == 0; match opcode { x if x == OpCode::Nop as u8 => {} - x if x == OpCode::Ret as u8 => return Ok(ExecOutcome::Halted), + x if x == OpCode::Ret as u8 => return self.complete_active_frame(), x if x == OpCode::Ldc as u8 => { let opcode_ip = self.ip - 1; let value = if let Some(value) = self.decoded_ldc_value_at(opcode_ip).cloned() { @@ -1752,14 +2315,12 @@ impl Vm { } else { self.read_u8()? }; - if self.try_fuse_scalar_sequence(index, allow_superinstructions)? { + if self.call_depth == 0 + && self.try_fuse_scalar_sequence(index, allow_superinstructions)? + { return Ok(ExecOutcome::Continue); } - let value = self - .locals - .get(index as usize) - .cloned() - .ok_or(VmError::InvalidLocal(index))?; + let value = self.load_local_value(index)?; self.stack.push(value); } x if x == OpCode::Stloc as u8 => { @@ -1785,7 +2346,7 @@ impl Vm { self.charge_interrupt_tick()?; } self.ip = self.ip.saturating_add(1); - return Ok(ExecOutcome::Halted); + return self.complete_active_frame(); } } HostCallExecOutcome::Halted => return Ok(ExecOutcome::Halted), @@ -1796,13 +2357,27 @@ impl Vm { HostCallExecOutcome::Pending(op_id) => return Ok(ExecOutcome::Waiting(op_id)), } } + x if x == OpCode::MakeCallable as u8 => { + let prototype_id = self.read_u32()?; + self.make_callable(prototype_id)?; + } + x if x == OpCode::CallValue as u8 => { + let argc = self.read_u8()?; + return self.execute_call_value(argc); + } other => return Err(VmError::InvalidOpcode(other)), } Ok(ExecOutcome::Continue) } pub fn resume(&mut self) -> VmResult { - self.run() + let allow_jit = !matches!( + self.execution_frames + .last() + .map(|frame| &frame.continuation), + Some(FrameContinuation::ReturnToHost) + ); + self.run_internal(None, allow_jit) } pub fn stack(&self) -> &[Value] { @@ -1840,4 +2415,158 @@ impl Vm { pub fn call_depth(&self) -> usize { self.call_depth } + + pub fn program_instance_id(&self) -> ProgramInstanceId { + self.program_instance + } + + pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { + if self.shutdown { + return Err(VmError::InvalidFrameState("vm is shut down")); + } + match &callable { + Value::Callable(value) if value.program_instance == self.program_instance => {} + Value::Callable(value) => { + return Err(VmError::StaleCallable { + expected: self.program_instance, + found: value.program_instance, + }); + } + _ => return Err(VmError::InvalidCallable), + } + self.queued_callables + .push_back(QueuedCallable { callable, args }); + Ok(()) + } + + pub fn queued_callable_count(&self) -> usize { + self.queued_callables.len() + } + + pub fn drain_callable_queue(&mut self) -> VmResult> { + if self.draining_queued_callables { + return Err(VmError::InvalidFrameState( + "callable queue is already being drained", + )); + } + if !self.execution_frames.is_empty() { + return Err(VmError::InvalidFrameState( + "queued callables can only run after the root frame halts", + )); + } + self.draining_queued_callables = true; + let mut results = Vec::with_capacity(self.queued_callables.len()); + while let Some(queued) = self.queued_callables.pop_front() { + match self.invoke_callable(queued.callable, &queued.args) { + Ok(result) => results.push(result), + Err(err) => { + self.draining_queued_callables = false; + return Err(err); + } + } + } + self.draining_queued_callables = false; + Ok(results) + } + + pub fn shutdown(&mut self) { + self.queued_callables.clear(); + self.draining_queued_callables = false; + self.clear_stack_with_drop_contract(); + self.clear_locals_with_drop_contract(); + self.execution_frames.clear(); + self.call_depth = 0; + self.host_return = None; + self.waiting_host_op = None; + crate::builtins::runtime::close_all_handles(self); + self.program_instance = next_program_instance_id(); + self.shutdown = true; + } + + pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { + if self.shutdown { + return Err(VmError::InvalidFrameState("vm is shut down")); + } + match &callable { + Value::Callable(value) if value.program_instance != self.program_instance => { + return Err(VmError::StaleCallable { + expected: self.program_instance, + found: value.program_instance, + }); + } + Value::Callable(_) => {} + _ => return Err(VmError::InvalidCallable), + } + if !self.execution_frames.is_empty() { + return Err(VmError::InvalidFrameState( + "host invocation requires a halted VM", + )); + } + let stack_base = self.stack.len(); + self.stack.push(callable); + self.stack.extend_from_slice(args); + self.host_return = None; + let frame_count = self.execution_frames.len(); + let outcome = self.execute_call_value( + u8::try_from(args.len()) + .map_err(|_| VmError::InvalidFrameState("too many arguments"))?, + )?; + if self.execution_frames.len() == frame_count { + let result = match outcome { + ExecOutcome::Continue | ExecOutcome::Halted => { + self.stack.pop().unwrap_or(Value::Null) + } + ExecOutcome::Yielded => { + return Err(VmError::InvalidFrameState( + "direct host callable invocation yielded", + )); + } + ExecOutcome::Waiting(_) => { + return Err(VmError::InvalidFrameState( + "direct host callable invocation is waiting", + )); + } + }; + self.stack.truncate(stack_base); + self.host_return = Some(result); + return Ok(VmStatus::Halted); + } + if let Some(frame) = self.execution_frames.last_mut() { + frame.continuation = FrameContinuation::ReturnToHost; + } + self.run_internal(None, false) + } + + pub fn invoke_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { + match self.start_callable(callable, args)? { + VmStatus::Halted => self.host_return.take().ok_or(VmError::InvalidFrameState( + "host invocation completed without a result", + )), + VmStatus::Yielded => Err(VmError::InvalidFrameState("host invocation yielded")), + VmStatus::Waiting(_) => Err(VmError::InvalidFrameState("host invocation is waiting")), + } + } + + pub fn take_callable_result(&mut self) -> Option { + self.host_return.take() + } + + pub fn execution_frames(&self) -> Vec { + self.execution_frames + .iter() + .map(|frame| VmExecutionFrameSnapshot { + continuation: match frame.continuation { + FrameContinuation::Halt => VmFrameContinuation::Halt, + FrameContinuation::ResumeBytecode { return_ip } => { + VmFrameContinuation::ResumeBytecode { return_ip } + } + FrameContinuation::ReturnToHost => VmFrameContinuation::ReturnToHost, + }, + operand_stack_base: frame.operand_stack_base, + local_base: frame.local_base, + local_count: frame.local_count, + prototype_id: frame.prototype_id, + }) + .collect() + } } diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index a9816d36..71025ab7 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -515,6 +515,7 @@ pub(crate) extern "C" fn pd_vm_native_type_of(value_ptr: *const Value) -> *mut u Value::Bytes(_) => "bytes", Value::Array(_) => "array", Value::Map(_) => "map", + Value::Callable(_) => "callable", }; arc_into_repr_ptr(Arc::new(name.to_string())) } diff --git a/src/vm/store.rs b/src/vm/store.rs index d30e522e..13169f8e 100644 --- a/src/vm/store.rs +++ b/src/vm/store.rs @@ -1,14 +1,230 @@ -use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmResult, VmStatus}; +use std::marker::PhantomData; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use crate::compiler::TypeSchema; +use crate::{ProgramInstanceId, Value}; + +use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmError, VmResult, VmStatus}; + +static NEXT_STORE_ID: AtomicU64 = AtomicU64::new(1); /// Lightweight Wasmtime-style store wrapper for VM state and host context data. pub struct Store { vm: Vm, data: T, + id: u64, +} + +pub trait ScriptArgs { + const ARITY: Option; + + fn into_values(self) -> Vec; +} + +pub trait IntoScriptValue { + fn into_script_value(self) -> Value; +} + +pub trait ScriptResult: Sized { + fn from_value(value: Value) -> VmResult; +} + +pub struct ScriptCallback, Ret = Value> { + store_id: u64, + program_instance: ProgramInstanceId, + callable: Value, + schema: Option, + subscribed: Arc, + marker: PhantomData Ret>, +} + +pub struct QueuedScriptInvocation { + store_id: u64, + program_instance: ProgramInstanceId, + callable: Value, + args: Vec, +} + +impl Clone for ScriptCallback { + fn clone(&self) -> Self { + Self { + store_id: self.store_id, + program_instance: self.program_instance, + callable: self.callable.clone(), + schema: self.schema.clone(), + subscribed: Arc::clone(&self.subscribed), + marker: PhantomData, + } + } +} + +impl ScriptCallback +where + Args: ScriptArgs, + Ret: ScriptResult, +{ + pub fn schema(&self) -> Option<&TypeSchema> { + self.schema.as_ref() + } + + pub fn is_subscribed(&self) -> bool { + self.subscribed.load(Ordering::Acquire) + } + + pub fn unsubscribe(&self) { + self.subscribed.store(false, Ordering::Release); + } + + pub fn call(&self, store: &mut Store, args: Args) -> VmResult { + let invocation = self.prepare(args)?; + store.validate_invocation(&invocation)?; + Ret::from_value( + store + .vm + .invoke_callable(invocation.callable, &invocation.args)?, + ) + } + + pub fn start(&self, store: &mut Store, args: Args) -> VmResult { + let invocation = self.prepare(args)?; + store.validate_invocation(&invocation)?; + store + .vm + .start_callable(invocation.callable, &invocation.args) + } + + pub fn prepare(&self, args: Args) -> VmResult { + if !self.is_subscribed() { + return Err(VmError::InvalidFrameState( + "script callback is unsubscribed", + )); + } + Ok(QueuedScriptInvocation { + store_id: self.store_id, + program_instance: self.program_instance, + callable: self.callable.clone(), + args: args.into_values(), + }) + } +} + +impl ScriptArgs for () { + const ARITY: Option = Some(0); + + fn into_values(self) -> Vec { + Vec::new() + } +} + +impl ScriptArgs for Vec { + const ARITY: Option = None; + + fn into_values(self) -> Vec { + self + } +} + +impl ScriptArgs for Value { + const ARITY: Option = Some(1); + + fn into_values(self) -> Vec { + vec![self] + } +} + +macro_rules! impl_script_args_tuple { + ($arity:expr; $($name:ident),+) => { + impl<$($name),+> ScriptArgs for ($($name,)+) + where + $($name: IntoScriptValue,)+ + { + const ARITY: Option = Some($arity); + + #[allow(non_snake_case)] + fn into_values(self) -> Vec { + let ($($name,)+) = self; + vec![$($name.into_script_value(),)+] + } + } + }; +} + +impl_script_args_tuple!(1; A); +impl_script_args_tuple!(2; A, B); +impl_script_args_tuple!(3; A, B, C); +impl_script_args_tuple!(4; A, B, C, D); + +impl IntoScriptValue for Value { + fn into_script_value(self) -> Value { + self + } +} + +impl IntoScriptValue for i64 { + fn into_script_value(self) -> Value { + Value::Int(self) + } +} + +impl IntoScriptValue for bool { + fn into_script_value(self) -> Value { + Value::Bool(self) + } +} + +impl IntoScriptValue for String { + fn into_script_value(self) -> Value { + Value::string(self) + } +} + +impl IntoScriptValue for &str { + fn into_script_value(self) -> Value { + Value::string(self) + } +} + +impl ScriptResult for Value { + fn from_value(value: Value) -> VmResult { + Ok(value) + } +} + +impl ScriptResult for () { + fn from_value(value: Value) -> VmResult { + match value { + Value::Null => Ok(()), + _ => Err(VmError::TypeMismatch("null callback result")), + } + } +} + +impl ScriptResult for i64 { + fn from_value(value: Value) -> VmResult { + match value { + Value::Int(value) => Ok(value), + _ => Err(VmError::TypeMismatch("int callback result")), + } + } +} + +impl ScriptResult for bool { + fn from_value(value: Value) -> VmResult { + match value { + Value::Bool(value) => Ok(value), + _ => Err(VmError::TypeMismatch("bool callback result")), + } + } } impl Store { pub fn new(vm: Vm, data: T) -> Self { - Self { vm, data } + Self { + vm, + data, + id: NEXT_STORE_ID.fetch_add(1, Ordering::Relaxed), + } } pub fn vm(&self) -> &Vm { @@ -39,6 +255,76 @@ impl Store { self.vm.run() } + pub fn script_callback(&self, callable: Value) -> VmResult> + where + Args: ScriptArgs, + Ret: ScriptResult, + { + let Value::Callable(value) = &callable else { + return Err(VmError::InvalidCallable); + }; + if value.program_instance != self.vm.program_instance_id() { + return Err(VmError::StaleCallable { + expected: self.vm.program_instance_id(), + found: value.program_instance, + }); + } + let prototype = self + .vm + .program() + .callable_prototypes + .get(value.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(value.prototype_id))?; + if let Some(arity) = Args::ARITY + && arity != usize::from(prototype.arity) + { + return Err(VmError::CallableArityMismatch { + prototype_id: value.prototype_id, + expected: prototype.arity, + got: u8::try_from(arity).unwrap_or(u8::MAX), + }); + } + Ok(ScriptCallback { + store_id: self.id, + program_instance: value.program_instance, + callable, + schema: prototype.schema.clone(), + subscribed: Arc::new(AtomicBool::new(true)), + marker: PhantomData, + }) + } + + pub fn enqueue_callback(&mut self, invocation: QueuedScriptInvocation) -> VmResult<()> { + self.validate_invocation(&invocation)?; + self.vm.queue_callable(invocation.callable, invocation.args) + } + + pub fn drain_callbacks(&mut self) -> VmResult> { + self.vm.drain_callable_queue() + } + + pub fn take_callback_result(&mut self) -> VmResult> { + self.vm + .take_callable_result() + .map(Ret::from_value) + .transpose() + } + + fn validate_invocation(&self, invocation: &QueuedScriptInvocation) -> VmResult<()> { + if invocation.store_id != self.id { + return Err(VmError::InvalidFrameState( + "script callback belongs to another store", + )); + } + if invocation.program_instance != self.vm.program_instance_id() { + return Err(VmError::StaleCallable { + expected: self.vm.program_instance_id(), + found: invocation.program_instance, + }); + } + Ok(()) + } + pub fn resume(&mut self) -> VmResult { self.vm.resume() } diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 1c8541b5..7d5ddf42 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -9,6 +9,306 @@ fn native_cache_test_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +#[test] +fn root_ret_completes_explicit_halt_frame() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + assert_eq!(vm.execution_frames.len(), 1); + assert_eq!(vm.execution_frames[0].continuation, FrameContinuation::Halt); + + assert_eq!(vm.run().expect("root ret should run"), VmStatus::Halted); + assert!(vm.execution_frames.is_empty()); + assert!(vm.stack().is_empty()); + + vm.reset_for_reuse(); + assert_eq!(vm.execution_frames.len(), 1); + assert_eq!(vm.stack(), &[]); +} + +#[test] +fn callable_operand_type_hint_roundtrips() { + let packed = pack_operand_types(ValueType::Callable, ValueType::Callable); + assert_eq!( + unpack_operand_types(packed), + (ValueType::Callable, ValueType::Callable) + ); +} + +#[test] +fn callvalue_decodes_its_arity_before_callable_validation() { + let mut vm = Vm::new(Program::new( + Vec::new(), + vec![OpCode::CallValue as u8, 0, OpCode::Ret as u8], + )); + vm.stack.push(Value::Null); + assert!(matches!(vm.run(), Err(VmError::InvalidCallable))); + assert_eq!(vm.ip(), 2); +} + +#[test] +fn callvalue_enters_script_frame_and_resumes_caller() { + let mut bc = crate::BytecodeBuilder::new(); + bc.ldloc(0); + bc.ldc(0); + bc.call_value(1); + bc.ret(); + let function_entry = bc.position(); + bc.ldloc(0); + bc.ldc(1); + bc.add(); + bc.ret(); + let function_end = bc.position(); + + let program = Program::new(vec![Value::Int(41), Value::Int(1)], bc.finish()) + .with_local_count(1) + .with_callable_metadata( + vec![crate::ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_slots: Vec::new(), + self_slot: None, + schema: None, + }], + vec![ + crate::FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + crate::FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![crate::RootCallableBinding { + local_slot: 0, + prototype_id: 0, + }], + ); + 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 host_can_invoke_exported_callable_and_reset_makes_it_stale() { + let mut bc = crate::BytecodeBuilder::new(); + bc.ret(); + let entry = bc.position(); + bc.ldloc(0); + bc.ldc(0); + bc.add(); + bc.ret(); + let end = bc.position(); + let program = Program::new(vec![Value::Int(1)], bc.finish()) + .with_local_count(1) + .with_callable_metadata( + vec![crate::ScriptFunction { + entry_ip: entry, + end_ip: end, + }], + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_slots: Vec::new(), + self_slot: None, + schema: None, + }], + vec![ + crate::FunctionRegion { + start_ip: 0, + end_ip: entry, + prototype_id: None, + }, + crate::FunctionRegion { + start_ip: entry, + end_ip: end, + prototype_id: Some(0), + }, + ], + vec![crate::RootCallableBinding { + local_slot: 0, + prototype_id: 0, + }], + ); + let mut vm = Vm::new(program); + let callable = vm.locals()[0].clone(); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + assert_eq!( + vm.invoke_callable(callable.clone(), &[Value::Int(41)]) + .expect("host invocation should return"), + Value::Int(42) + ); + vm.queue_callable(callable.clone(), vec![Value::Int(1)]) + .expect("queue first callback"); + vm.queue_callable(callable.clone(), vec![Value::Int(2)]) + .expect("queue second callback"); + assert_eq!(vm.queued_callable_count(), 2); + assert_eq!( + vm.drain_callable_queue().expect("drain callbacks"), + vec![Value::Int(2), Value::Int(3)] + ); + vm.queue_callable(callable.clone(), vec![Value::Int(3)]) + .expect("queue callback before shutdown"); + vm.shutdown(); + assert_eq!(vm.queued_callable_count(), 0); + assert!(matches!( + vm.invoke_callable(callable.clone(), &[Value::Int(1)]), + Err(VmError::InvalidFrameState("vm is shut down")) + )); + + vm.reset_for_reuse(); + assert!(matches!( + vm.invoke_callable(callable, &[Value::Int(1)]), + Err(VmError::StaleCallable { .. }) + )); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_side_exits_at_callable_boundary_with_state_preserved() { + let compiled = crate::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one(41); + "#, + ) + .expect("script frame source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("aot execution should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert!(vm.aot_exec_count() > 0); + assert!(vm.aot_interpreter_boundary_hit); +} + +#[test] +fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { + let compiled = crate::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one; + "#, + ) + .expect("callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("callable result"); + let mut store = crate::Store::from_vm(vm); + let stale_callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable.clone()) + .expect("second callback should bind"); + let callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable.clone()) + .expect("typed callback should bind"); + + assert_eq!(callback.call(&mut store, (41,)).expect("direct call"), 42); + let queued = callback.prepare((40,)).expect("queued call should prepare"); + let queued = std::thread::spawn(move || queued) + .join() + .expect("queued invocation should cross threads"); + store + .enqueue_callback(queued) + .expect("queued call should bind to its store"); + assert_eq!( + store.drain_callbacks().expect("queue should drain"), + vec![Value::Int(41)] + ); + + let alias = callback.clone(); + let wrong_type: crate::ScriptCallback<(bool,), i64> = store + .script_callback(callable) + .expect("callable arity should bind"); + assert!(matches!( + wrong_type.call(&mut store, (false,)), + Err(VmError::TypeMismatch("callable argument schema")) + )); + + callback.unsubscribe(); + assert!(!alias.is_subscribed()); + assert!(matches!( + alias.prepare((1,)), + Err(VmError::InvalidFrameState( + "script callback is unsubscribed" + )) + )); + + store.vm_mut().reset_for_reuse(); + assert!(matches!( + stale_callback.call(&mut store, (1,)), + Err(VmError::StaleCallable { .. }) + )); +} + +#[test] +fn typed_script_callback_can_yield_resume_and_return_to_host() { + let compiled = crate::compile_source_for_repl( + r#" + fn sum_to(limit: int) -> int { + let mut index = 0; + let mut total = 0; + while index < limit { + total = total + index; + index = index + 1; + } + total; + } + sum_to; + "#, + ) + .expect("callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("callable result"); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable) + .expect("typed callback should bind"); + + store.set_fuel(4); + let mut status = callback + .start(&mut store, (100,)) + .expect("callback should start"); + assert_eq!(store.vm().call_depth(), 1); + let mut yields = 0usize; + loop { + match status { + VmStatus::Halted => break, + VmStatus::Yielded => { + yields += 1; + assert!(yields < 1_000, "callback should make progress"); + store.recharge(4).expect("fuel recharge should succeed"); + status = store.resume().expect("callback should resume"); + } + VmStatus::Waiting(_) => panic!("unexpected waiting callback"), + } + } + assert!(yields > 0); + assert_eq!(store.vm().call_depth(), 0); + assert_eq!( + store + .take_callback_result::() + .expect("typed callback result") + .expect("callback should produce a result"), + 4_950 + ); +} + #[test] fn vm_instances_share_decoded_instruction_metadata_across_program_clones() { let compiled = crate::compile_source( diff --git a/src/vmbc.rs b/src/vmbc.rs index d0daf487..e1d60a83 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -2,7 +2,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt::Write; use crate::builtins::BuiltinFunction; -use crate::bytecode::{TypeMap, ValueType}; +use crate::bytecode::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, RootCallableBinding, + ScriptFunction, TypeMap, ValueType, +}; use crate::compiler::ir::TypeSchema; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; use crate::vm::{HostImport, OpCode, Program, Value}; @@ -13,8 +16,8 @@ const VERSION_V3: u16 = 3; const VERSION_V4: u16 = 4; const VERSION_V5: u16 = 5; const VERSION_V6: u16 = 6; -const VERSION_V8: u16 = 8; -const ENCODE_VERSION: u16 = VERSION_V8; +const VERSION_V9: u16 = 9; +const ENCODE_VERSION: u16 = VERSION_V9; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -95,6 +98,7 @@ pub enum ValidationError { offset: usize, target: u32, }, + InvalidCallableMetadata(&'static str), } impl std::fmt::Display for ValidationError { @@ -131,6 +135,9 @@ impl std::fmt::Display for ValidationError { f, "invalid jump target {target} referenced by instruction at offset {offset}", ), + ValidationError::InvalidCallableMetadata(message) => { + write!(f, "invalid callable metadata: {message}") + } } } } @@ -177,6 +184,9 @@ pub fn encode_program(program: &Program) -> Result, WireError> { Value::Map(_) => { return Err(WireError::UnsupportedConstantType("map")); } + Value::Callable(_) => { + return Err(WireError::UnsupportedConstantType("callable")); + } } } @@ -199,6 +209,7 @@ pub fn encode_program(program: &Program) -> Result, WireError> { if ENCODE_VERSION >= VERSION_V2 { write_debug_info(&mut out, program.debug.as_ref())?; } + write_callable_metadata(&mut out, program)?; Ok(out) } @@ -212,7 +223,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V8 { + if version != VERSION_V9 { return Err(WireError::UnsupportedVersion(version)); } @@ -274,6 +285,8 @@ pub fn decode_program(bytes: &[u8]) -> Result { } else { None }; + let (script_functions, callable_prototypes, function_regions, root_callable_bindings) = + read_callable_metadata(&mut cursor)?; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -281,6 +294,10 @@ pub fn decode_program(bytes: &[u8]) -> Result { let mut program = Program::with_imports_and_debug(constants, code, imports, debug); program.type_map = type_map; + program.script_functions = script_functions; + program.callable_prototypes = callable_prototypes; + program.function_regions = function_regions; + program.root_callable_bindings = root_callable_bindings; Ok(program) } @@ -423,6 +440,22 @@ pub fn disassemble_program_with_options(program: &Program, options: DisassembleO truncated = true; } } + x if x == OpCode::CallValue as u8 => { + if let Some(argc) = read_u8(code, &mut ip) { + instruction.push_str(&format!("callvalue {argc}")); + } else { + instruction.push_str("callvalue "); + truncated = true; + } + } + x if x == OpCode::MakeCallable as u8 => { + if let Some(prototype_id) = read_u32(code, &mut ip) { + instruction.push_str(&format!("makecallable {prototype_id}")); + } else { + instruction.push_str("makecallable "); + truncated = true; + } + } x if x == OpCode::Shl as u8 => instruction.push_str("shl"), x if x == OpCode::Shr as u8 => instruction.push_str("shr"), x if x == OpCode::Lshr as u8 => instruction.push_str("lshr"), @@ -481,10 +514,91 @@ struct ProgramAnalysis { max_local_index: Option, } +fn region_index_for_ip(regions: &[FunctionRegion], ip: usize) -> Option { + regions + .iter() + .position(|region| (region.start_ip as usize) <= ip && ip < region.end_ip as usize) +} + +fn validate_callable_metadata(program: &Program) -> Result<(), ValidationError> { + let code_len = program.code.len(); + let mut previous_end = 0usize; + for region in &program.function_regions { + let start = region.start_ip as usize; + let end = region.end_ip as usize; + if start < previous_end || start >= end || end > code_len { + return Err(ValidationError::InvalidCallableMetadata( + "function regions overlap or exceed bytecode bounds", + )); + } + if let Some(prototype_id) = region.prototype_id + && prototype_id as usize >= program.callable_prototypes.len() + { + return Err(ValidationError::InvalidCallableMetadata( + "function region references an invalid prototype", + )); + } + previous_end = end; + } + if !program.function_regions.is_empty() + && (program.function_regions[0].start_ip != 0 || previous_end != code_len) + { + return Err(ValidationError::InvalidCallableMetadata( + "function regions do not cover the complete bytecode", + )); + } + + for prototype in &program.callable_prototypes { + if matches!(prototype.target, CallableTarget::ScriptFunction(_)) + && prototype.parameter_slots.len() != prototype.arity as usize + || prototype + .parameter_slots + .iter() + .chain(prototype.capture_slots.iter()) + .any(|slot| *slot as usize >= prototype.frame_local_count) + || prototype + .self_slot + .is_some_and(|slot| slot as usize >= prototype.frame_local_count) + { + return Err(ValidationError::InvalidCallableMetadata( + "callable frame layout is invalid", + )); + } + match prototype.target { + CallableTarget::ScriptFunction(id) if id as usize >= program.script_functions.len() => { + return Err(ValidationError::InvalidCallableMetadata( + "callable references an invalid script function", + )); + } + CallableTarget::HostImport(id) + if id as usize >= program.imports.len() + && BuiltinFunction::from_call_index(id).is_none() => + { + return Err(ValidationError::InvalidCallableMetadata( + "callable references an invalid host import", + )); + } + _ => {} + } + } + + for binding in &program.root_callable_bindings { + if binding.local_slot as usize >= program.local_count + || binding.prototype_id as usize >= program.callable_prototypes.len() + { + return Err(ValidationError::InvalidCallableMetadata( + "root callable binding is invalid", + )); + } + } + Ok(()) +} + fn analyze_program( program: &Program, host_fn_count: Option, ) -> Result { + validate_callable_metadata(program)?; let mut ip = 0usize; let mut instruction_starts = HashSet::new(); let mut jump_targets: Vec<(usize, u32)> = Vec::new(); @@ -593,6 +707,26 @@ fn analyze_program( } } } + x if x == OpCode::CallValue as u8 => { + read_u8(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 1, + })?; + } + x if x == OpCode::MakeCallable as u8 => { + let prototype_id = + read_u32(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 4, + })?; + if prototype_id as usize >= program.callable_prototypes.len() { + return Err(ValidationError::InvalidCallableMetadata( + "makecallable references an invalid prototype", + )); + } + } other => { return Err(ValidationError::InvalidOpcode { offset: start, @@ -610,11 +744,217 @@ fn analyze_program( target: target as u32, }); } + if !program.function_regions.is_empty() + && region_index_for_ip(&program.function_regions, *offset) + != region_index_for_ip(&program.function_regions, target) + { + return Err(ValidationError::InvalidJumpTarget { + offset: *offset, + target: target as u32, + }); + } + } + + for function in &program.script_functions { + let entry = function.entry_ip as usize; + let end = function.end_ip as usize; + if !instruction_starts.contains(&entry) + || end > code.len() + || (end < code.len() && !instruction_starts.contains(&end)) + { + return Err(ValidationError::InvalidCallableMetadata( + "script function boundary is not an instruction boundary", + )); + } } Ok(ProgramAnalysis { max_local_index }) } +fn write_callable_metadata(out: &mut Vec, program: &Program) -> Result<(), WireError> { + write_u32_count("script functions", program.script_functions.len(), out)?; + for function in &program.script_functions { + out.extend_from_slice(&function.entry_ip.to_le_bytes()); + out.extend_from_slice(&function.end_ip.to_le_bytes()); + } + + write_u32_count( + "callable prototypes", + program.callable_prototypes.len(), + out, + )?; + for prototype in &program.callable_prototypes { + out.push(match prototype.kind { + CallableKind::FunctionItem => 0, + CallableKind::Closure => 1, + CallableKind::HostFunction => 2, + }); + match prototype.target { + CallableTarget::ScriptFunction(id) => { + out.push(0); + out.extend_from_slice(&id.to_le_bytes()); + } + CallableTarget::HostImport(id) => { + out.push(1); + out.extend_from_slice(&u32::from(id).to_le_bytes()); + } + } + out.push(prototype.arity); + write_u32_count("callable frame locals", prototype.frame_local_count, out)?; + write_u16_list("callable parameters", &prototype.parameter_slots, out)?; + write_u16_list("callable captures", &prototype.capture_slots, out)?; + match prototype.self_slot { + Some(slot) => { + out.push(1); + out.extend_from_slice(&slot.to_le_bytes()); + } + None => out.push(0), + } + match &prototype.schema { + Some(schema) => { + out.push(1); + write_schema(schema, out)?; + } + None => out.push(0), + } + } + + write_u32_count("function regions", program.function_regions.len(), out)?; + for region in &program.function_regions { + out.extend_from_slice(®ion.start_ip.to_le_bytes()); + out.extend_from_slice(®ion.end_ip.to_le_bytes()); + match region.prototype_id { + Some(id) => { + out.push(1); + out.extend_from_slice(&id.to_le_bytes()); + } + None => out.push(0), + } + } + + write_u32_count( + "root callable bindings", + program.root_callable_bindings.len(), + out, + )?; + for binding in &program.root_callable_bindings { + out.extend_from_slice(&binding.local_slot.to_le_bytes()); + out.extend_from_slice(&binding.prototype_id.to_le_bytes()); + } + Ok(()) +} + +fn write_u16_list(field: &'static str, values: &[u16], out: &mut Vec) -> Result<(), WireError> { + write_u32_count(field, values.len(), out)?; + for value in values { + out.extend_from_slice(&value.to_le_bytes()); + } + Ok(()) +} + +type CallableMetadata = ( + Vec, + Vec, + Vec, + Vec, +); + +fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result { + let function_count = cursor.read_u32()? as usize; + let mut script_functions = Vec::with_capacity(function_count); + for _ in 0..function_count { + script_functions.push(ScriptFunction { + entry_ip: cursor.read_u32()?, + end_ip: cursor.read_u32()?, + }); + } + + let prototype_count = cursor.read_u32()? as usize; + let mut callable_prototypes = Vec::with_capacity(prototype_count); + for _ in 0..prototype_count { + let kind = match cursor.read_u8()? { + 0 => CallableKind::FunctionItem, + 1 => CallableKind::Closure, + 2 => CallableKind::HostFunction, + other => return Err(WireError::InvalidValueType(other)), + }; + let target_tag = cursor.read_u8()?; + let target_id = cursor.read_u32()?; + let target = match target_tag { + 0 => CallableTarget::ScriptFunction(target_id), + 1 => CallableTarget::HostImport( + u16::try_from(target_id).map_err(|_| WireError::InvalidValueType(target_tag))?, + ), + other => return Err(WireError::InvalidValueType(other)), + }; + let arity = cursor.read_u8()?; + let frame_local_count = cursor.read_u32()? as usize; + let parameter_slots = read_u16_list(cursor)?; + let capture_slots = read_u16_list(cursor)?; + let self_slot = match cursor.read_u8()? { + 0 => None, + 1 => Some(cursor.read_u16()?), + other => return Err(WireError::InvalidBool(other)), + }; + let schema = match cursor.read_u8()? { + 0 => None, + 1 => Some(read_schema(cursor)?), + other => return Err(WireError::InvalidBool(other)), + }; + callable_prototypes.push(CallablePrototype { + kind, + target, + arity, + frame_local_count, + parameter_slots, + capture_slots, + self_slot, + schema, + }); + } + + let region_count = cursor.read_u32()? as usize; + let mut function_regions = Vec::with_capacity(region_count); + for _ in 0..region_count { + let start_ip = cursor.read_u32()?; + let end_ip = cursor.read_u32()?; + let prototype_id = match cursor.read_u8()? { + 0 => None, + 1 => Some(cursor.read_u32()?), + other => return Err(WireError::InvalidBool(other)), + }; + function_regions.push(FunctionRegion { + start_ip, + end_ip, + prototype_id, + }); + } + + let binding_count = cursor.read_u32()? as usize; + let mut root_callable_bindings = Vec::with_capacity(binding_count); + for _ in 0..binding_count { + root_callable_bindings.push(RootCallableBinding { + local_slot: cursor.read_u16()?, + prototype_id: cursor.read_u32()?, + }); + } + Ok(( + script_functions, + callable_prototypes, + function_regions, + root_callable_bindings, + )) +} + +fn read_u16_list(cursor: &mut Cursor<'_>) -> Result, WireError> { + let len = cursor.read_u32()? as usize; + let mut values = Vec::with_capacity(len); + for _ in 0..len { + values.push(cursor.read_u16()?); + } + Ok(values) +} + fn write_debug_info(out: &mut Vec, debug: Option<&DebugInfo>) -> Result<(), WireError> { match debug { None => { @@ -819,6 +1159,7 @@ fn read_value_type(raw: u8) -> Result { 6 => Ok(ValueType::Bytes), 7 => Ok(ValueType::Array), 8 => Ok(ValueType::Map), + 9 => Ok(ValueType::Callable), other => Err(WireError::InvalidValueType(other)), } } diff --git a/tests/compiler/compiler_common_tests.rs b/tests/compiler/compiler_common_tests.rs index 4e6291ee..2dce7039 100644 --- a/tests/compiler/compiler_common_tests.rs +++ b/tests/compiler/compiler_common_tests.rs @@ -250,7 +250,7 @@ fn compiler_rejects_programs_with_more_than_256_simultaneously_live_locals() { } #[test] -fn compiler_reuses_slots_with_large_programs_that_inline_functions() { +fn compiler_reuses_slots_with_large_programs_that_call_script_functions() { let mut source = String::from("fn id(x) { x }\nlet mut out = 0;\n"); for idx in 0..400usize { source.push_str(&format!("let v{idx} = {idx};\n")); @@ -1493,6 +1493,6 @@ fn stack_is_clean_after_halt_with_single_result() { ); assert_eq!(vm.stack(), &[Value::Int(8)]); } -// NOTE: function parameter slot cleanup is already covered by -// `inline_function_call_frame_slots_are_cleared_after_return` in -// compiler_rustscript_tests.rs (which asserts ALL locals are Null). +// NOTE: function parameter slot cleanup is covered by +// `script_function_frame_values_are_released_after_return` in +// compiler_rustscript_tests.rs. diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 81944f49..18dcf45d 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -720,16 +720,6 @@ fn rustscript_named_function_capture_error_cases_work() { expected_kind: SourceErrorKind::Parse, expected_contains_all: &["lut", "moved"], }, - SourceErrorCase { - name: "named function recursion is still rejected", - source: r#" - fn recurse(x: int) -> int { recurse(x) } - recurse(1); - "#, - flavor: SourceFlavor::RustScript, - expected_kind: SourceErrorKind::Compile(CompileErrorKind::InlineFunctionRecursion), - expected_contains_all: &[], - }, ]; for case in &cases { @@ -758,6 +748,73 @@ fn closure_captures_outer_value_at_definition_time() { run_runtime_case_with_bindings(&case, &bindings); } +#[test] +fn named_function_recursion_uses_runtime_frames_and_hits_depth_limit() { + let compiled = vm::compile_source_for_repl( + r#" + fn recurse(x: int) -> int { recurse(x) } + recurse(1); + "#, + ) + .expect("recursive function should compile"); + assert!( + compiled + .program + .code + .contains(&(vm::OpCode::CallValue as u8)) + ); + assert_eq!(compiled.program.script_functions.len(), 1); + + let mut runtime = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + assert!(matches!( + runtime.run(), + Err(vm::VmError::CallStackOverflow { limit: 1024 }) + )); +} + +#[test] +fn mutual_recursion_resolves_forward_function_declarations() { + let case = rustscript_runtime_case( + "mutual recursion", + r#" + fn a(x) { if x == 0 => { 0 } else => { b(x - 1); x } } + fn b(x) { if x == 0 => { 0 } else => { a(x - 1); x } } + a(4); + "#, + vec![Value::Int(4)], + ); + run_runtime_case(&case); +} + +#[test] +fn finite_recursive_closure_unwinds_and_returns() { + let case = rustscript_runtime_case( + "finite recursive closure", + r#" + let recurse = |x| if x == 0 => { 0 } else => { recurse(x - 1); x }; + recurse(5); + "#, + vec![Value::Int(5)], + ); + run_runtime_case(&case); +} + +#[test] +fn recursive_closure_uses_self_binding_and_hits_depth_limit() { + let compiled = vm::compile_source_for_repl( + r#" + let recurse = |x| recurse(x); + recurse(1); + "#, + ) + .expect("recursive closure should compile"); + let mut runtime = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + assert!(matches!( + runtime.run(), + Err(vm::VmError::CallStackOverflow { .. }) + )); +} + #[test] fn rustscript_closure_value_runtime_cases_work() { let cases = vec![ @@ -984,10 +1041,10 @@ fn rustscript_closure_value_parse_rejection_cases_work() { } #[test] -fn rustscript_closure_captured_callable_invocation_is_rejected() { +fn rustscript_closure_captured_callable_invocation_works() { let cases = vec![ - SourceErrorCase { - name: "captured function-valued local cannot be invoked from closure body", + RuntimeCase { + name: "captured function-valued local can be invoked from closure body", source: r#" fn add_one(value) { value + 1; @@ -997,23 +1054,23 @@ fn rustscript_closure_captured_callable_invocation_is_rejected() { apply(41); "#, flavor: SourceFlavor::RustScript, - expected_kind: SourceErrorKind::Compile(CompileErrorKind::NonCallableLocal), - expected_contains_all: &[], + expected_stack: vec![Value::Int(42)], + expected_locals: None, }, - SourceErrorCase { - name: "captured closure-valued local cannot be invoked from closure body", + RuntimeCase { + name: "captured closure-valued local can be invoked from closure body", source: r#" let inc = |x| x + 1; let apply = |value| inc(value); apply(41); "#, flavor: SourceFlavor::RustScript, - expected_kind: SourceErrorKind::Compile(CompileErrorKind::NonCallableLocal), - expected_contains_all: &[], + expected_stack: vec![Value::Int(42)], + expected_locals: None, }, ]; for case in &cases { - expect_source_error_case(case); + run_runtime_case(case); } } @@ -1956,7 +2013,7 @@ fn liveness_clears_local_after_function_value_last_use() { } #[test] -fn inline_function_call_frame_slots_are_cleared_after_return() { +fn script_function_frame_values_are_released_after_return() { let source = r#" fn make_pair() { let left = "L"; @@ -1973,11 +2030,9 @@ fn inline_function_call_frame_slots_are_cleared_after_return() { let status = vm.run().expect("vm should run"); assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack().last(), Some(&Value::Int(0))); - assert!( - vm.locals().iter().all(|value| matches!(value, Value::Null)), - "expected all inline call-frame locals to be cleared after return, got {:?}", - vm.locals() - ); + assert!(!vm.locals().iter().any( + |value| matches!(value, Value::String(text) if matches!(text.as_str(), "L" | "R" | "LR")) + )); } #[test] @@ -1999,35 +2054,118 @@ fn interprocedural_closure_capture_slots_are_cleared_after_last_use() { assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack().last(), Some(&Value::Int(0))); assert!( - vm.locals().iter().all(|value| matches!(value, Value::Null)), - "expected closure capture and call-frame slots to clear after last use, got {:?}", - vm.locals() + !vm.locals().iter().any( + |value| matches!(value, Value::String(text) if matches!(text.as_str(), "!" | "a!")) + ) ); } #[test] -fn rustscript_callable_values_cannot_be_stored_in_arrays() { - let case = SourceErrorCase { - name: "callable values cannot be stored in arrays", +fn rustscript_callable_values_can_be_stored_in_arrays() { + let case = RuntimeCase { + name: "callable values can be stored in arrays", source: r#" - fn add_one(value) { + fn add_one(value: int) -> int { value + 1; } let func = add_one; let values = [func]; - values.length; + let stored = values[0]; + stored(41); "#, flavor: SourceFlavor::RustScript, - expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableUsedAsValue), - expected_contains_all: &[], + expected_stack: vec![Value::Int(42)], + expected_locals: None, }; - expect_source_error_case(&case); + run_runtime_case(&case); } #[test] -fn rustscript_callable_values_cannot_be_returned_from_functions() { - let case = SourceErrorCase { - name: "callable values cannot be returned from functions", +fn rustscript_callable_values_can_be_stored_in_maps() { + let case = RuntimeCase { + name: "callable values can be stored in maps", + source: r#" + fn add_one(value: int) -> int { + value + 1; + } + let values = { f: add_one }; + let stored = values.f; + stored(41); + "#, + flavor: SourceFlavor::RustScript, + expected_stack: vec![Value::Int(42)], + expected_locals: None, + }; + run_runtime_case(&case); +} + +#[test] +fn builtin_host_functions_can_be_values() { + let source = r#" + let f = len; + f("abc"); + "#; + let path = std::env::temp_dir().join(format!( + "rustscript_callable_builtin_{}_{}.rss", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + )); + std::fs::write(&path, source).expect("source should write"); + let compiled = + compile_source_file(path.as_path()).expect("builtin function value should compile"); + assert!( + compiled + .program + .code + .contains(&(vm::OpCode::CallValue as u8)) + ); + let mut runtime = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!( + runtime.run().expect("runtime should halt"), + VmStatus::Halted + ); + assert_eq!(runtime.stack(), &[Value::Int(3)]); + let _ = std::fs::remove_file(path); +} + +#[test] +fn compatible_callable_signatures_merge_across_branches() { + let case = rustscript_runtime_case( + "compatible callable branch merge", + r#" + fn add_one(value: int) -> int { value + 1 } + fn add_two(value: int) -> int { value + 2 } + let selected = if true => { add_one } else => { add_two }; + selected(40); + "#, + vec![Value::Int(41)], + ); + + run_runtime_case(&case); +} + +#[test] +fn explicit_generic_function_values_use_substituted_callable_schemas() { + let case = rustscript_runtime_case( + "explicit generic function values", + r#" + fn identity(value: T) -> T { value } + let int_identity = identity::; + int_identity(42); + "#, + vec![Value::Int(42)], + ); + + run_runtime_case(&case); +} + +#[test] +fn rustscript_callable_values_can_be_returned_from_functions() { + let case = RuntimeCase { + name: "callable values can be returned from functions", source: r#" fn add_one(value: int) -> int { value + 1; @@ -2035,15 +2173,15 @@ fn rustscript_callable_values_cannot_be_returned_from_functions() { fn get_adder() { add_one; } - let func = get_adder(); func(41); "#, + flavor: SourceFlavor::RustScript, - expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableUsedAsValue), - expected_contains_all: &[], + expected_stack: vec![Value::Int(42)], + expected_locals: None, }; - expect_source_error_case(&case); + run_runtime_case(&case); } #[test] diff --git a/tests/compiler/module_import_tests.rs b/tests/compiler/module_import_tests.rs index b327cf31..1973cb0b 100644 --- a/tests/compiler/module_import_tests.rs +++ b/tests/compiler/module_import_tests.rs @@ -221,7 +221,7 @@ fn compile_source_file_rustscript_module_exports_only_pub_functions() { } #[test] -fn rss_function_definition_is_inlined_without_host_imports() { +fn rss_function_definition_uses_script_target_without_host_imports() { let source = r#" fn eq(lhs, rhs) { lhs == rhs; diff --git a/tests/compiler/type_inference_tests.rs b/tests/compiler/type_inference_tests.rs index 757890f4..2afa3ec1 100644 --- a/tests/compiler/type_inference_tests.rs +++ b/tests/compiler/type_inference_tests.rs @@ -273,7 +273,7 @@ fn compiler_type_inference_runtime_cases_cover_operator_and_callable_flows() { rustscript_type_inference_runtime_case( "callable return types propagate through functions and closures", r#" - fn add_one(value) { + fn add_one(value: int) -> int { value + 1; } @@ -312,11 +312,6 @@ fn compiler_type_inference_runtime_cases_cover_operator_and_callable_flows() { (ValueType::Int, ValueType::Int), (ValueType::Int, ValueType::Int), (ValueType::Int, ValueType::Int), - (ValueType::Int, ValueType::Int), - (ValueType::Int, ValueType::Int), - (ValueType::Int, ValueType::Int), - (ValueType::Int, ValueType::Int), - (ValueType::Int, ValueType::Int), ], }], ), @@ -357,7 +352,7 @@ fn compiler_type_inference_runtime_cases_cover_operator_and_callable_flows() { rustscript_type_inference_runtime_case( "named function plus operands infer from consistent calls", r#" - fn addme(x) { + fn addme(x: int) -> int { x + x } diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index b2f043ea..7649f2a5 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -4326,7 +4326,7 @@ fn trace_jit_supports_float_comparisons_in_ssa() { } #[test] -fn trace_jit_executes_loop_with_live_entry_stack() { +fn trace_jit_skips_nested_loop_with_one_live_operand() { if !native_jit_supported() { return; } @@ -4354,23 +4354,14 @@ fn trace_jit_executes_loop_with_live_entry_stack() { assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); assert!( - vm.jit_native_exec_count() > 0, - "a trace with a modeled live entry stack should execute natively, dump:\n{}", - vm.dump_jit_info() - ); - assert!( - vm.jit_snapshot().traces.iter().any(|trace| { - trace.entry_stack_depth == 1 - && trace.ssa_text().contains("stack0") - && trace.ssa_text().contains("loop_stack0") - }), + vm.jit_snapshot().traces.is_empty(), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_executes_loop_with_two_live_entry_values() { +fn trace_jit_skips_nested_loop_with_two_live_operands() { if !native_jit_supported() { return; } @@ -4397,19 +4388,15 @@ fn trace_jit_executes_loop_with_two_live_entry_values() { let status = vm.run().expect("depth-two entry stack program should run"); assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(48)]); - assert!(vm.jit_native_exec_count() > 0, "{}", vm.dump_jit_info()); assert!( - vm.jit_snapshot() - .traces - .iter() - .any(|trace| trace.entry_stack_depth >= 2), + vm.jit_snapshot().traces.is_empty(), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_preserves_heap_values_on_live_entry_stack() { +fn trace_jit_skips_nested_loop_with_heap_live_operand() { if !native_jit_supported() { return; } @@ -4443,19 +4430,15 @@ fn trace_jit_preserves_heap_values_on_live_entry_stack() { Value::Int(45), ])] ); - assert!(vm.jit_native_exec_count() > 0, "{}", vm.dump_jit_info()); assert!( - vm.jit_snapshot() - .traces - .iter() - .any(|trace| trace.entry_stack_depth >= 1), + vm.jit_snapshot().traces.is_empty(), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_reuses_live_entry_stack_trace_after_reset() { +fn trace_jit_skips_nested_loop_after_reset() { if !native_jit_supported() { return; } @@ -4482,12 +4465,12 @@ fn trace_jit_reuses_live_entry_stack_trace_after_reset() { assert_eq!(vm.run().expect("first run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); let first_native_exec_count = vm.jit_native_exec_count(); - assert!(first_native_exec_count > 0, "{}", vm.dump_jit_info()); + assert_eq!(first_native_exec_count, 0, "{}", vm.dump_jit_info()); vm.reset_for_reuse(); assert_eq!(vm.run().expect("second run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); - assert!(vm.jit_native_exec_count() > first_native_exec_count); + assert_eq!(vm.jit_native_exec_count(), first_native_exec_count); } #[test] diff --git a/tests/vm/runtime_state_edge_tests.rs b/tests/vm/runtime_state_edge_tests.rs index 76b94f27..c4c461ab 100644 --- a/tests/vm/runtime_state_edge_tests.rs +++ b/tests/vm/runtime_state_edge_tests.rs @@ -256,8 +256,16 @@ fn waiting_host_op_preserves_interprocedural_closure_state_then_clears_on_resume assert_eq!(resumed, VmStatus::Halted); assert_eq!(vm.stack().last(), Some(&Value::Int(0))); assert!( - vm.locals().iter().all(|value| matches!(value, Value::Null)), - "expected closure and inline call frames to clear after resume, got {:?}", + vm.locals().iter().all(|value| { + matches!(value, Value::Null) + || matches!( + value, + Value::Callable(callable) + if callable.kind == vm::CallableKind::FunctionItem + && callable.env.is_none() + ) + }), + "expected closure and transient call-frame state to clear after resume, got {:?}", vm.locals() ); } diff --git a/tests/wire/assembler_vmbc_edge_tests.rs b/tests/wire/assembler_vmbc_edge_tests.rs index 62a1d41c..e2ba845d 100644 --- a/tests/wire/assembler_vmbc_edge_tests.rs +++ b/tests/wire/assembler_vmbc_edge_tests.rs @@ -84,6 +84,15 @@ fn assemble_supports_string_escapes_and_case_insensitive_bools() { ); } +#[test] +fn assemble_supports_callvalue_operand() { + let program = assemble("callvalue 3\nret\n").expect("assembly should succeed"); + assert_eq!( + program.code, + vec![OpCode::CallValue as u8, 3, OpCode::Ret as u8] + ); +} + #[test] fn assemble_rejects_invalid_string_escapes_and_trailing_text() { assert_asm_error_contains( @@ -137,8 +146,7 @@ fn decode_rejects_invalid_flag_tag_bool_utf8_and_trailing_bytes() { )); let mut bad_debug_flag = encoded_simple.clone(); - let last = bad_debug_flag.len() - 1; - bad_debug_flag[last] = 9; + bad_debug_flag[22] = 9; assert!(matches!( decode_program(&bad_debug_flag), Err(WireError::InvalidDebugFlag(9)) diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 12ad99cc..820c3cd9 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -55,6 +55,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]]), 9); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -83,6 +84,13 @@ fn decode_rejects_invalid_magic_version_and_truncation() { Err(WireError::UnsupportedVersion(99)) )); + let mut old_version = encoded.clone(); + old_version[4..6].copy_from_slice(&8u16.to_le_bytes()); + assert!(matches!( + decode_program(&old_version), + Err(WireError::UnsupportedVersion(8)) + )); + let truncated = &encoded[..encoded.len() - 1]; assert!(matches!( decode_program(truncated), @@ -137,6 +145,101 @@ fn validate_accepts_known_good_program() { validate_program(&program, 4).expect("program should validate"); } +#[test] +fn callable_metadata_roundtrips_vmbc_v9() { + let compiled = vm::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + let closure = |value| add_one(value); + closure(41); + "#, + ) + .expect("callable source should compile"); + let encoded = encode_program(&compiled.program).expect("encode callable metadata"); + let decoded = decode_program(&encoded).expect("decode callable metadata"); + assert_eq!(decoded.script_functions, compiled.program.script_functions); + assert_eq!( + decoded.callable_prototypes, + compiled.program.callable_prototypes + ); + assert_eq!(decoded.function_regions, compiled.program.function_regions); + assert_eq!( + decoded.root_callable_bindings, + compiled.program.root_callable_bindings + ); + validate_program(&decoded, 0).expect("decoded program should validate"); +} + +#[test] +fn callvalue_roundtrips_validation_and_disassembly() { + let mut bc = BytecodeBuilder::new(); + bc.call_value(2); + bc.ret(); + let program = Program::new(vec![], bc.finish()); + + validate_program(&program, 0).expect("callvalue should validate structurally"); + let bytes = encode_program(&program).expect("callvalue should encode"); + let decoded = decode_program(&bytes).expect("callvalue should decode"); + assert_eq!(decoded.code, program.code); + assert!(disassemble_vmbc(&bytes).unwrap().contains("callvalue 2")); +} + +#[test] +fn validate_rejects_truncated_callvalue() { + let program = Program::new(vec![], vec![vm::OpCode::CallValue as u8]); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::TruncatedOperand { + expected_bytes: 1, + .. + }) + )); +} + +#[test] +fn validation_rejects_cross_region_branches() { + let mut code = vec![vm::OpCode::Br as u8]; + code.extend_from_slice(&6u32.to_le_bytes()); + code.push(vm::OpCode::Ret as u8); + code.push(vm::OpCode::Ret as u8); + let program = Program::new(Vec::new(), code).with_callable_metadata( + vec![vm::ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + vec![vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target: vm::CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_slots: Vec::new(), + self_slot: None, + schema: None, + }], + vec![ + vm::FunctionRegion { + start_ip: 0, + end_ip: 6, + prototype_id: None, + }, + vm::FunctionRegion { + start_ip: 6, + end_ip: 7, + prototype_id: Some(0), + }, + ], + Vec::new(), + ); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::InvalidJumpTarget { + offset: 0, + target: 6 + }) + )); +} + #[test] fn validate_rejects_invalid_call_arity_for_import() { let mut bc = BytecodeBuilder::new(); From 8c9c84ef0cc2638d31bbcc64a8083488c7efa648 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 16:36:44 +0800 Subject: [PATCH 03/18] feat(compiler): characterize callable value semantics --- tests/compiler/compiler_rustscript_tests.rs | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 18dcf45d..444d041e 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -772,6 +772,51 @@ fn named_function_recursion_uses_runtime_frames_and_hits_depth_limit() { )); } +#[test] +fn repeated_named_calls_share_one_emitted_body() { + let compiled = vm::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one(1); + add_one(2); + add_one(3); + "#, + ) + .expect("repeated calls should compile"); + + assert_eq!(compiled.program.script_functions.len(), 1); + assert_eq!( + compiled + .program + .function_regions + .iter() + .filter(|region| region.prototype_id.is_some()) + .count(), + 1 + ); + let mut ip = 0usize; + let mut callvalue_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; + } + ip += 1 + opcode.operand_len(); + } + assert_eq!(callvalue_count, 3); + + let mut runtime = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!( + runtime.run().expect("runtime should halt"), + VmStatus::Halted + ); + assert_eq!( + runtime.stack(), + &[Value::Int(2), Value::Int(3), Value::Int(4)] + ); +} + #[test] fn mutual_recursion_resolves_forward_function_declarations() { let case = rustscript_runtime_case( @@ -2184,6 +2229,44 @@ fn rustscript_callable_values_can_be_returned_from_functions() { run_runtime_case(&case); } +#[test] +fn escaping_closure_retains_its_environment() { + let case = RuntimeCase { + name: "escaping closure retains captures after its defining frame returns", + source: r#" + fn make_adder(delta: int) { + |value| value + delta; + } + let add_one = make_adder(1); + add_one(41); + "#, + flavor: SourceFlavor::RustScript, + expected_stack: vec![Value::Int(42)], + expected_locals: None, + }; + run_runtime_case(&case); +} + +#[test] +fn callable_equality_distinguishes_items_aliases_and_closure_instances() { + let case = RuntimeCase { + name: "callable equality follows item and environment identity", + source: r#" + fn add_one(value: int) -> int { value + 1 } + let item = add_one; + let first = |value| value + 1; + let second = |value| value + 1; + item == add_one; + first == first; + first == second; + "#, + flavor: SourceFlavor::RustScript, + expected_stack: vec![Value::Bool(true), Value::Bool(true), Value::Bool(false)], + expected_locals: None, + }; + run_runtime_case(&case); +} + #[test] fn rustscript_if_and_match_runtime_cases_work() { let cases = vec![ From 8e87e857bc7e4d13ceefc8532c72bf13d57785f0 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 17:09:09 +0800 Subject: [PATCH 04/18] feat(native): add frame-relative callable ABI --- src/vm/aot/artifact.rs | 4 +- src/vm/aot/compile.rs | 85 ++++++++++++--- src/vm/mod.rs | 8 ++ src/vm/native/bridge.rs | 220 ++++++++++++++++++++++++++++++++++++++- src/vm/native/codegen.rs | 12 +++ src/vm/native/mod.rs | 40 +++---- src/vm/tests.rs | 55 ++++++++++ 7 files changed, 387 insertions(+), 37 deletions(-) diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 3a0623a2..721c15cb 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 = 3; -const ABI_VERSION: u16 = 2; +const VERSION: u16 = 4; +const ABI_VERSION: u16 = 3; const FLAGS: u16 = 0; #[derive(Debug)] diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index f4d8bb74..eb073161 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -14,18 +14,20 @@ use std::time::Instant; use crate::vm::native::ExecutableBuffer; #[cfg(feature = "cranelift-jit")] use crate::vm::native::{ - HeapIntrinsicAddrs, HeapIntrinsicRefs, OP_BUILTIN_CALL, OP_CALL, STATUS_CONTINUE, STATUS_ERROR, - alloc_buffer_signature, alloc_byte_buffer_entry_address, alloc_value_buffer_entry_address, - aot_call_boundary_interrupt_entry_address, array_push_entry_address, box_heap_value_signature, - 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, entry_signature, - free_buffer_signature, helper_entry_offset, helper_signature, + HeapIntrinsicAddrs, HeapIntrinsicRefs, NativeFrameState, OP_BUILTIN_CALL, OP_CALL, + STATUS_CONTINUE, STATUS_ERROR, alloc_buffer_signature, alloc_byte_buffer_entry_address, + alloc_value_buffer_entry_address, aot_call_boundary_interrupt_entry_address, + array_push_entry_address, box_heap_value_signature, 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, 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, pack_shared_signature, resolve_offsets, - 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, value_eq_entry_address, value_eq_signature, - value_slot_signature, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, + 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, + value_eq_entry_address, value_eq_signature, value_slot_signature, + write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; use crate::vm::{Program, Value, Vm, VmError, VmResult}; #[cfg(feature = "cranelift-jit")] @@ -219,6 +221,7 @@ struct AotDeoptHelperRefs { helper_ref: cranelift_codegen::ir::SigRef, vm_status_ref: cranelift_codegen::ir::SigRef, interrupt_ref: cranelift_codegen::ir::SigRef, + frame_state_ref: cranelift_codegen::ir::SigRef, clone_value_ref: cranelift_codegen::ir::SigRef, value_eq_ref: cranelift_codegen::ir::SigRef, init_null_slot_ref: cranelift_codegen::ir::SigRef, @@ -233,6 +236,7 @@ struct AotDeoptHelperRefs { #[derive(Clone, Copy)] struct AotDeoptHelperAddrs { aot_interrupt: usize, + frame_state: usize, clone_value: usize, value_eq: usize, init_null_slot: usize, @@ -381,6 +385,7 @@ fn compile_ssa( let sigs_started = Instant::now(); let helper_sig = helper_signature(pointer_type, call_conv); let alloc_buffer_sig = alloc_buffer_signature(pointer_type, call_conv); + let frame_state_sig = frame_state_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); let copy_bytes_sig = copy_bytes_signature(pointer_type, call_conv); @@ -408,6 +413,7 @@ fn compile_ssa( }; let helper_addrs = AotDeoptHelperAddrs { aot_interrupt: aot_call_boundary_interrupt_entry_address(), + frame_state: frame_state_entry_address(), clone_value: clone_value_to_slot_entry_address(), value_eq: value_eq_entry_address(), init_null_slot: init_null_value_slot_entry_address(), @@ -415,7 +421,7 @@ fn compile_ssa( box_heap_value: write_heap_value_to_slot_entry_address(), array_push: array_push_entry_address(), collection_set: collection_set_entry_address(), - restore_exit: restore_exit_state_entry_address(), + restore_exit: restore_active_exit_state_entry_address(), }; let addr_setup_elapsed = addr_setup_started.elapsed(); @@ -462,6 +468,7 @@ fn compile_ssa( helper_ref: b.import_signature(helper_sig), vm_status_ref: b.import_signature(vm_status_sig), interrupt_ref: b.import_signature(interrupt_sig), + frame_state_ref: b.import_signature(frame_state_sig), clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), init_null_slot_ref: b.import_signature(value_slot_sig.clone()), @@ -558,6 +565,8 @@ fn compile_ssa( pointer_type, layout, offsets, + helper_refs, + helper_addrs, checkpoint, )?; b.ins() @@ -685,6 +694,7 @@ fn compile_ssa( } #[cfg(feature = "cranelift-jit")] +#[allow(clippy::too_many_arguments)] fn load_checkpoint_args( b: &mut FunctionBuilder, vm_ptr: cranelift_codegen::ir::Value, @@ -692,8 +702,49 @@ fn load_checkpoint_args( pointer_type: cranelift_codegen::ir::Type, layout: crate::vm::native::NativeStackLayout, offsets: crate::vm::native::ResolvedOffsets, + helper_refs: AotDeoptHelperRefs, + helper_addrs: AotDeoptHelperAddrs, checkpoint: &AotCheckpoint, ) -> Result, AotCompileError> { + let frame_state_size = + u32::try_from(std::mem::size_of::()).map_err(|_| { + AotCompileError::Codegen("native frame state size out of range".to_string()) + })?; + let frame_state_align = std::mem::align_of::().trailing_zeros() as u8; + let frame_state_slot = b.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + frame_state_size, + frame_state_align, + )); + let frame_state_ptr = b.ins().stack_addr(pointer_type, frame_state_slot, 0); + call_status_helper( + b, + exit_block, + pointer_type, + helper_refs.frame_state_ref, + helper_addrs.frame_state, + &[vm_ptr, frame_state_ptr], + )?; + let stack_base_offset = + i32::try_from(std::mem::offset_of!(NativeFrameState, operand_stack_base)).map_err( + |_| AotCompileError::Codegen("frame stack-base offset out of range".to_string()), + )?; + let local_base_offset = i32::try_from(std::mem::offset_of!(NativeFrameState, local_base)) + .map_err(|_| { + AotCompileError::Codegen("frame local-base offset out of range".to_string()) + })?; + let stack_base = b.ins().load( + pointer_type, + MemFlags::new(), + frame_state_ptr, + stack_base_offset, + ); + let local_base = b.ins().load( + pointer_type, + MemFlags::new(), + frame_state_ptr, + local_base_offset, + ); let stack_len = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.stack_len); @@ -703,7 +754,8 @@ fn load_checkpoint_args( AotCompileError::Codegen("checkpoint stack length out of range".to_string()) })?, ); - let stack_ok = b.ins().icmp(IntCC::Equal, stack_len, expected_stack_len); + let expected_stack_total = b.ins().iadd(stack_base, expected_stack_len); + let stack_ok = b.ins().icmp(IntCC::Equal, stack_len, expected_stack_total); let stack_match = b.create_block(); let stack_error = b.ins().iconst(types::I32, STATUS_ERROR as i64); b.ins().brif( @@ -724,7 +776,10 @@ fn load_checkpoint_args( AotCompileError::Codegen("checkpoint locals length out of range".to_string()) })?, ); - let locals_ok = b.ins().icmp(IntCC::Equal, locals_len, expected_locals_len); + let expected_locals_total = b.ins().iadd(local_base, expected_locals_len); + let locals_ok = b + .ins() + .icmp(IntCC::Equal, locals_len, expected_locals_total); let locals_match = b.create_block(); let locals_error = b.ins().iconst(types::I32, STATUS_ERROR as i64); b.ins().brif( @@ -742,6 +797,8 @@ fn load_checkpoint_args( let locals_ptr = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.locals_ptr); + let stack_ptr = ssa_value_addr(b, pointer_type, stack_ptr, stack_base, layout.value.size); + let locals_ptr = ssa_value_addr(b, pointer_type, locals_ptr, local_base, layout.value.size); let mut args = Vec::with_capacity(checkpoint.stack.len() + checkpoint.locals.len()); for (index, repr) in checkpoint.stack.iter().copied().enumerate() { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index c3aa0ffd..6c0fb87a 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -459,6 +459,7 @@ pub(crate) fn checked_int_rem(lhs: i64, rhs: i64) -> VmResult { fn compute_program_cache_key(program: &Program) -> u64 { let mut hasher = StableHasher::default(); crate::bytecode::BYTECODE_ABI_VERSION.hash(&mut hasher); + native::NATIVE_CALLABLE_ABI_VERSION.hash(&mut hasher); program.code.hash(&mut hasher); program.local_count.hash(&mut hasher); for constant in &program.constants { @@ -966,6 +967,13 @@ impl Vm { } #[inline(always)] + fn active_operand_stack_base(&self) -> usize { + self.execution_frames + .last() + .map(|frame| frame.operand_stack_base) + .unwrap_or(0) + } + fn active_local_base(&self) -> usize { self.execution_frames .last() diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 71025ab7..0a1c48b2 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -2,8 +2,8 @@ use crate::builtins::BuiltinFunction; use crate::bytecode::{Value, ValueType, VmMap}; use crate::vm::{ - CallOutcome, CallReturn, HostCallExecOutcome, NumericValue, Vm, VmError, VmHostFunction, - VmResult, logical_shr_i64, + CallOutcome, CallReturn, FrameContinuation, HostCallExecOutcome, NumericValue, Vm, VmError, + VmHostFunction, VmResult, logical_shr_i64, }; use std::cell::RefCell; use std::sync::Arc; @@ -17,6 +17,20 @@ pub(crate) const STATUS_OUT_OF_FUEL: i32 = 5; pub(crate) const STATUS_LINKED_CONTINUE: i32 = 6; pub(crate) const STATUS_ERROR: i32 = -1; +pub(crate) const ROOT_FRAME_KEY: u64 = u64::MAX; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct NativeFrameState { + pub(crate) frame_key: u64, + pub(crate) operand_stack_base: usize, + pub(crate) active_stack_len: usize, + pub(crate) local_base: usize, + pub(crate) local_count: usize, + pub(crate) frame_depth: usize, + pub(crate) continuation_kind: u8, +} + pub(crate) const OP_LDC: i64 = 1; pub(crate) const OP_ADD: i64 = 2; pub(crate) const OP_SUB: i64 = 3; @@ -259,6 +273,22 @@ pub(crate) fn restore_exit_state_entry_address() -> usize { pd_vm_native_restore_exit_state as *const () as usize } +pub(crate) fn active_stack_base_entry_address() -> usize { + pd_vm_native_active_stack_base as *const () as usize +} + +pub(crate) fn frame_state_entry_address() -> usize { + pd_vm_native_frame_state as *const () as usize +} + +pub(crate) fn active_local_base_entry_address() -> usize { + pd_vm_native_active_local_base as *const () as usize +} + +pub(crate) fn restore_active_exit_state_entry_address() -> usize { + pd_vm_native_restore_active_exit_state as *const () as usize +} + pub(crate) fn restore_sparse_exit_state_entry_address() -> usize { pd_vm_native_restore_sparse_exit_state as *const () as usize } @@ -710,6 +740,120 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( }) } +pub(crate) extern "C" fn pd_vm_native_frame_state(vm: *mut Vm, out: *mut NativeFrameState) -> i32 { + run_step(vm, "frame_state", |vm| { + if out.is_null() { + return Err(VmError::JitNative( + "native frame-state helper received null output".to_string(), + )); + } + let frame = vm.execution_frames.last(); + let operand_stack_base = frame.map(|frame| frame.operand_stack_base).unwrap_or(0); + let local_base = frame.map(|frame| frame.local_base).unwrap_or(0); + let local_count = frame + .map(|frame| frame.local_count) + .unwrap_or(vm.locals.len()); + let active_stack_len = vm + .stack + .len() + .checked_sub(operand_stack_base) + .ok_or_else(|| { + VmError::JitNative("native frame-state stack base exceeds stack length".to_string()) + })?; + let frame_key = frame + .and_then(|frame| frame.prototype_id) + .map(u64::from) + .unwrap_or(ROOT_FRAME_KEY); + let continuation_kind = match frame.map(|frame| &frame.continuation) { + Some(FrameContinuation::Halt) | None => 0, + Some(FrameContinuation::ResumeBytecode { .. }) => 1, + Some(FrameContinuation::ReturnToHost) => 2, + }; + unsafe { + out.write(NativeFrameState { + frame_key, + operand_stack_base, + active_stack_len, + local_base, + local_count, + frame_depth: vm.call_depth, + continuation_kind, + }); + } + Ok(STATUS_CONTINUE) + }) +} + +pub(crate) extern "C" fn pd_vm_native_active_stack_base(vm: *const Vm) -> usize { + unsafe { vm.as_ref() } + .map(Vm::active_operand_stack_base) + .unwrap_or(0) +} + +pub(crate) extern "C" fn pd_vm_native_active_local_base(vm: *const Vm) -> usize { + unsafe { vm.as_ref() } + .map(Vm::active_local_base) + .unwrap_or(0) +} + +pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( + vm: *mut Vm, + stack_src: *const Value, + stack_len: usize, + locals_src: *const Value, + locals_len: usize, + ip: usize, +) -> i32 { + run_step(vm, "restore_active_exit_state", |vm| { + let stack_base = vm.active_operand_stack_base(); + let local_base = vm.active_local_base(); + let expected_locals_len = local_base + .checked_add(locals_len) + .ok_or_else(|| VmError::JitNative("native active local length overflow".to_string()))?; + if expected_locals_len != vm.locals.len() { + return Err(VmError::JitNative(format!( + "native active exit restore locals length mismatch: expected {}, got {}", + vm.locals.len(), + expected_locals_len + ))); + } + if stack_base > vm.stack.len() { + return Err(VmError::JitNative(format!( + "native active stack base {stack_base} exceeds stack length {}", + vm.stack.len() + ))); + } + if stack_len != 0 && stack_src.is_null() { + return Err(VmError::JitNative( + "native active exit restore received null stack buffer".to_string(), + )); + } + if locals_len != 0 && locals_src.is_null() { + return Err(VmError::JitNative( + "native active exit restore received null locals buffer".to_string(), + )); + } + + vm.stack.truncate(stack_base); + vm.stack.reserve(stack_len); + for index in 0..stack_len { + let value = unsafe { std::ptr::read(stack_src.add(index)) }; + vm.stack.push(value); + } + + for index in 0..locals_len { + let local_index = u8::try_from(index).map_err(|_| { + VmError::JitNative("native active local index out of range".to_string()) + })?; + let value = unsafe { std::ptr::read(locals_src.add(index)) }; + vm.store_local_with_drop_contract(local_index, value)?; + } + + vm.jump_to(ip)?; + Ok(STATUS_CONTINUE) + }) +} + pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( vm: *mut Vm, stack_src: *const Value, @@ -1274,7 +1418,77 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, #[cfg(test)] mod tests { use super::*; - use std::mem::ManuallyDrop; + use std::mem::{ManuallyDrop, MaybeUninit}; + + #[test] + fn native_frame_state_and_active_restore_are_frame_relative() { + let program = + crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(2); + let mut vm = Vm::new(program); + vm.stack = vec![Value::Int(10), Value::Int(20)]; + vm.locals = vec![ + Value::Int(1), + Value::Int(2), + Value::Int(3), + Value::Int(4), + Value::Int(5), + ]; + vm.execution_frames.push(crate::vm::ExecutionFrame { + continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, + operand_stack_base: 1, + local_base: 2, + local_count: 3, + prototype_id: Some(7), + active_callable: None, + }); + vm.call_depth = 1; + + let mut state = MaybeUninit::::uninit(); + assert_eq!( + pd_vm_native_frame_state(&mut vm, state.as_mut_ptr()), + STATUS_CONTINUE + ); + let state = unsafe { state.assume_init() }; + assert_eq!( + state, + NativeFrameState { + frame_key: 7, + operand_stack_base: 1, + active_stack_len: 1, + local_base: 2, + local_count: 3, + frame_depth: 1, + continuation_kind: 1, + } + ); + + let stack = [Value::Int(99)]; + let locals = [Value::Int(30), Value::Int(40), Value::Int(50)]; + assert_eq!( + pd_vm_native_restore_active_exit_state( + &mut vm, + stack.as_ptr(), + stack.len(), + locals.as_ptr(), + locals.len(), + 0, + ), + STATUS_CONTINUE + ); + std::mem::forget(stack); + std::mem::forget(locals); + assert_eq!(vm.stack, vec![Value::Int(10), Value::Int(99)]); + assert_eq!( + vm.locals, + vec![ + Value::Int(1), + Value::Int(2), + Value::Int(30), + Value::Int(40), + Value::Int(50), + ] + ); + } #[test] fn bridge_errors_are_isolated_between_threads() { diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 7768260e..6e71f050 100644 --- a/src/vm/native/codegen.rs +++ b/src/vm/native/codegen.rs @@ -38,6 +38,18 @@ pub(crate) fn alloc_buffer_signature( sig } +#[cfg(feature = "cranelift-jit")] +pub(crate) fn frame_state_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.returns.push(AbiParam::new(types::I32)); + sig +} + #[cfg(feature = "cranelift-jit")] pub(crate) fn free_buffer_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 0501cd92..94f72bd0 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -6,18 +6,20 @@ mod layout; mod offsets; pub(crate) use bridge::{ - NativeInterruptMode, NativeInterruptSettings, OP_BUILTIN_CALL, OP_CALL, STATUS_CONTINUE, - STATUS_ERROR, STATUS_HALTED, STATUS_LINKED_CONTINUE, STATUS_OUT_OF_FUEL, STATUS_TRACE_EXIT, - STATUS_WAITING, STATUS_YIELDED, alloc_byte_buffer_entry_address, - alloc_value_buffer_entry_address, aot_call_boundary_interrupt_entry_address, - array_push_entry_address, array_set_entry_address, clear_bridge_error, - clear_value_slot_entry_address, clone_value_to_slot_entry_address, - collection_set_entry_address, copy_bytes_entry_address, helper_entry_address, - helper_entry_offset, init_null_value_slot_entry_address, interrupt_helper_entry_address, - interrupt_helper_entry_offset, map_get_entry_address, map_has_entry_address, - map_iter_next_entry_address, map_iter_take_key_entry_address, + NativeFrameState, NativeInterruptMode, NativeInterruptSettings, OP_BUILTIN_CALL, OP_CALL, + ROOT_FRAME_KEY, STATUS_CONTINUE, STATUS_ERROR, STATUS_HALTED, STATUS_LINKED_CONTINUE, + STATUS_OUT_OF_FUEL, STATUS_TRACE_EXIT, STATUS_WAITING, STATUS_YIELDED, + active_local_base_entry_address, active_stack_base_entry_address, + alloc_byte_buffer_entry_address, alloc_value_buffer_entry_address, + aot_call_boundary_interrupt_entry_address, array_push_entry_address, array_set_entry_address, + clear_bridge_error, clear_value_slot_entry_address, clone_value_to_slot_entry_address, + collection_set_entry_address, copy_bytes_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, 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, - regex_match_entry_address, regex_replace_entry_address, restore_exit_state_entry_address, + regex_match_entry_address, regex_replace_entry_address, + restore_active_exit_state_entry_address, restore_exit_state_entry_address, restore_sparse_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, store_bridge_error, string_contains_entry_address, string_lower_ascii_entry_address, @@ -29,13 +31,13 @@ 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, entry_signature, free_buffer_signature, helper_signature, - jump_with_status, map_iter_next_signature, map_iter_take_signature, map_set_signature, - non_yielding_host_call_signature, pack_shared_signature, regex_match_signature, - regex_replace_signature, restore_exit_signature, sparse_restore_exit_signature, - string_binary_transform_signature, string_contains_signature, string_replace_signature, - string_unary_transform_signature, value_eq_signature, value_len_signature, - value_slot_signature, + copy_bytes_signature, entry_signature, frame_state_signature, free_buffer_signature, + helper_signature, jump_with_status, map_iter_next_signature, map_iter_take_signature, + map_set_signature, non_yielding_host_call_signature, pack_shared_signature, + regex_match_signature, regex_replace_signature, restore_exit_signature, + sparse_restore_exit_signature, string_binary_transform_signature, string_contains_signature, + string_replace_signature, string_unary_transform_signature, value_eq_signature, + value_len_signature, value_slot_signature, }; pub(crate) use exec::{ExecutableBuffer, prepare_for_execution}; pub(crate) use layout::{ @@ -44,6 +46,8 @@ 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 = 1; + #[cfg(feature = "cranelift-jit")] pub(crate) fn selected_codegen_backend() -> &'static str { "native" diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 7d5ddf42..1f3067e1 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -255,6 +255,61 @@ fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { )); } +#[test] +fn typed_script_callback_can_wait_resume_and_return_to_host() { + struct PendingCallbackHost; + + impl HostFunction for PendingCallbackHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Pending(811)) + } + } + + let compiled = crate::compile_source_for_repl( + r#" + fn wait(); + fn callback() -> int { + wait(); + 42; + } + callback; + "#, + ) + .expect("callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.register_function(Box::new(PendingCallbackHost)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("callable result"); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(), i64> = store + .script_callback(callable) + .expect("typed callback should bind"); + + assert_eq!( + callback + .start(&mut store, ()) + .expect("callback should start"), + VmStatus::Waiting(811) + ); + assert_eq!(store.vm().call_depth(), 1); + store + .vm_mut() + .complete_host_op(811, Vec::new()) + .expect("host completion should succeed"); + assert_eq!( + store.resume().expect("callback should resume"), + VmStatus::Halted + ); + assert_eq!(store.vm().call_depth(), 0); + assert_eq!( + store + .take_callback_result::() + .expect("typed callback result") + .expect("callback should produce a result"), + 42 + ); +} + #[test] fn typed_script_callback_can_yield_resume_and_return_to_host() { let compiled = crate::compile_source_for_repl( From 84c299c0383f63a194bf85262aac69f598b21393 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 17:21:25 +0800 Subject: [PATCH 05/18] feat(aot): model callable function regions --- src/vm/aot/cfg.rs | 191 ++++++++++++++++++++++++++++++++++++++++++---- src/vm/aot/ir.rs | 105 +++++++++++++++++++++++-- src/vm/aot/ssa.rs | 13 +++- 3 files changed, 288 insertions(+), 21 deletions(-) diff --git a/src/vm/aot/cfg.rs b/src/vm/aot/cfg.rs index 4771a24c..f0f1f886 100644 --- a/src/vm/aot/cfg.rs +++ b/src/vm/aot/cfg.rs @@ -5,9 +5,17 @@ use crate::vm::{OpCode, Program}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AotCfg { pub(crate) entry_ip: usize, + pub(crate) regions: Vec, pub(crate) blocks: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AotCfgRegion { + pub(crate) start_ip: usize, + pub(crate) end_ip: usize, + pub(crate) prototype_id: Option, +} + impl AotCfg { pub(crate) fn block(&self, start_ip: usize) -> Option<&AotBasicBlock> { self.blocks.iter().find(|block| block.start_ip == start_ip) @@ -34,6 +42,11 @@ pub(crate) enum AotBlockTerminal { Fallthrough { next_ip: usize, }, + CallValue { + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterpreterExit { exit_ip: usize, }, @@ -43,7 +56,9 @@ pub(crate) enum AotBlockTerminal { impl AotBlockTerminal { pub(crate) fn successor_ips(&self) -> Vec { match self { - Self::Return | Self::InterpreterExit { .. } | Self::Stop => Vec::new(), + Self::Return | Self::CallValue { .. } | Self::InterpreterExit { .. } | Self::Stop => { + Vec::new() + } Self::Jump { target_ip } => vec![*target_ip], Self::ConditionalJump { target_ip, @@ -70,21 +85,29 @@ pub(crate) enum AotCfgError { ip: usize, target: usize, }, + CrossRegionJump { + ip: usize, + target: usize, + }, + CrossRegionFallthrough { + ip: usize, + target: usize, + }, + InvalidRegionLayout, } pub(crate) fn build_cfg(program: &Program) -> Result { - build_cfg_from_code(&program.code) -} - -fn build_cfg_from_code(code: &[u8]) -> Result { + let code = &program.code; + let regions = cfg_regions(program)?; if code.is_empty() { return Ok(AotCfg { entry_ip: 0, + regions, blocks: Vec::new(), }); } - let block_starts = collect_block_starts(code)?; + let block_starts = collect_block_starts(program, ®ions)?; let mut blocks = Vec::with_capacity(block_starts.len()); for (index, start_ip) in block_starts.iter().copied().enumerate() { @@ -101,11 +124,14 @@ fn build_cfg_from_code(code: &[u8]) -> Result { target_ip: read_jump_target(code, ip)?, fallthrough_ip: next_ip, }), - OpCode::CallValue | OpCode::MakeCallable => { - Some(AotBlockTerminal::InterpreterExit { exit_ip: ip }) - } + OpCode::CallValue => Some(AotBlockTerminal::CallValue { + argc: code[ip + 1], + 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)?; Some(AotBlockTerminal::Fallthrough { next_ip }) } _ => None, @@ -126,28 +152,38 @@ fn build_cfg_from_code(code: &[u8]) -> Result { Ok(AotCfg { entry_ip: 0, + regions, blocks, }) } -fn collect_block_starts(code: &[u8]) -> Result, AotCfgError> { +fn collect_block_starts( + program: &Program, + regions: &[AotCfgRegion], +) -> Result, AotCfgError> { + let code = &program.code; let mut starts = BTreeSet::new(); starts.insert(0usize); + starts.extend(regions.iter().map(|region| region.start_ip)); let mut ip = 0usize; while ip < code.len() { let (opcode, next_ip) = decode_instruction_bounds(code, ip)?; match opcode { OpCode::Br => { - starts.insert(read_jump_target(code, ip)?); + let target = read_jump_target(code, ip)?; + validate_same_region(regions, ip, target)?; + starts.insert(target); } OpCode::Brfalse => { - starts.insert(read_jump_target(code, ip)?); + let target = read_jump_target(code, ip)?; + validate_same_region(regions, ip, target)?; + starts.insert(target); if next_ip < code.len() { starts.insert(next_ip); } } - OpCode::CallValue | OpCode::MakeCallable => { + OpCode::CallValue => { if next_ip < code.len() { starts.insert(next_ip); } @@ -160,6 +196,66 @@ fn collect_block_starts(code: &[u8]) -> Result, AotCfgError> { Ok(starts.into_iter().collect()) } +fn cfg_regions(program: &Program) -> Result, AotCfgError> { + if program.function_regions.is_empty() { + return Ok(vec![AotCfgRegion { + start_ip: 0, + end_ip: program.code.len(), + prototype_id: None, + }]); + } + let regions = program + .function_regions + .iter() + .map(|region| AotCfgRegion { + start_ip: region.start_ip as usize, + end_ip: region.end_ip as usize, + prototype_id: region.prototype_id, + }) + .collect::>(); + let mut expected_start = 0usize; + for region in ®ions { + if region.start_ip != expected_start + || region.start_ip >= region.end_ip + || region.end_ip > program.code.len() + { + return Err(AotCfgError::InvalidRegionLayout); + } + expected_start = region.end_ip; + } + if expected_start != program.code.len() { + return Err(AotCfgError::InvalidRegionLayout); + } + Ok(regions) +} + +fn validate_fallthrough_region( + regions: &[AotCfgRegion], + ip: usize, + target: usize, +) -> Result<(), AotCfgError> { + validate_same_region(regions, ip, target) + .map_err(|_| AotCfgError::CrossRegionFallthrough { ip, target }) +} + +fn validate_same_region( + regions: &[AotCfgRegion], + ip: usize, + target: usize, +) -> Result<(), AotCfgError> { + let source_region = regions + .iter() + .position(|region| region.start_ip <= ip && ip < region.end_ip); + let target_region = regions + .iter() + .position(|region| region.start_ip <= target && target < region.end_ip); + if source_region == target_region && source_region.is_some() { + Ok(()) + } else { + Err(AotCfgError::CrossRegionJump { ip, target }) + } +} + fn decode_instruction_bounds(code: &[u8], ip: usize) -> Result<(OpCode, usize), AotCfgError> { let opcode_byte = *code .get(ip) @@ -202,7 +298,7 @@ fn read_jump_target(code: &[u8], ip: usize) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::{BytecodeBuilder, Value}; + use crate::{BytecodeBuilder, FunctionRegion, Value}; fn patch_branch_target(code: &mut [u8], instr_ip: u32, target: u32) { let start = instr_ip as usize + 1; @@ -317,6 +413,73 @@ mod tests { ); } + #[test] + fn aot_cfg_tracks_callable_regions_and_call_terminators() { + let compiled = crate::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + let function = add_one; + function(41); + "#, + ) + .expect("callable source should compile"); + let cfg = build_cfg(&compiled.program).expect("cfg should build"); + + assert_eq!(cfg.regions.len(), 2); + assert_eq!(cfg.regions[0].prototype_id, None); + assert_eq!(cfg.regions[1].prototype_id, Some(0)); + assert!( + cfg.blocks + .iter() + .any(|block| matches!(block.terminal, AotBlockTerminal::CallValue { argc: 1, .. })) + ); + assert!( + !cfg.blocks + .iter() + .any(|block| matches!(block.terminal, AotBlockTerminal::InterpreterExit { .. })) + ); + assert_eq!( + cfg.block(cfg.regions[1].start_ip) + .expect("function entry block") + .terminal, + AotBlockTerminal::Return + ); + } + + #[test] + fn aot_cfg_rejects_cross_region_branches() { + let mut bc = BytecodeBuilder::new(); + let branch_ip = bc.position(); + bc.br(0); + bc.ret(); + let function_ip = bc.position(); + bc.ret(); + + let mut code = bc.finish(); + patch_branch_target(&mut code, branch_ip, function_ip); + let mut program = Program::new(Vec::new(), code); + program.function_regions = vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_ip, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_ip, + end_ip: function_ip + 1, + prototype_id: Some(0), + }, + ]; + + assert_eq!( + build_cfg(&program), + Err(AotCfgError::CrossRegionJump { + ip: branch_ip as usize, + target: function_ip as usize, + }) + ); + } + #[test] fn aot_cfg_rejects_out_of_bounds_branch_targets() { let mut bc = BytecodeBuilder::new(); diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index d71838b6..a987e33c 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -3,11 +3,12 @@ use std::collections::BTreeSet; use crate::builtins::BuiltinFunction; use crate::vm::{OpCode, Program, ValueType}; -use super::cfg::{AotBasicBlock, AotBlockTerminal, AotCfg, AotCfgError, build_cfg}; +use super::cfg::{AotBasicBlock, AotBlockTerminal, AotCfg, AotCfgError, AotCfgRegion, build_cfg}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AotProgram { pub(crate) entry_ip: usize, + pub(crate) regions: Vec, pub(crate) blocks: Vec, pub(crate) resume_ips: Vec, } @@ -47,7 +48,9 @@ pub(crate) enum AotBytesCodecKind { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AotInstruction { Nop, - Ldc { const_index: u32 }, + Ldc { + const_index: u32, + }, Add, IAdd, FAdd, @@ -89,9 +92,19 @@ pub(crate) enum AotInstruction { FCgt, Pop, Dup, - Ldloc { index: u8 }, - LdlocOwned { index: u8 }, - Stloc { index: u8 }, + Ldloc { + index: u8, + }, + LdlocOwned { + index: u8, + }, + Stloc { + index: u8, + }, + MakeCallable { + prototype_id: u32, + capture_count: u8, + }, Call(AotCall), } @@ -151,6 +164,7 @@ fn lower_from_cfg(program: &Program, cfg: &AotCfg) -> Result { + OpCode::MakeCallable => { + let prototype_id = + read_u32(code, ip + 1).ok_or(AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "callable prototype id", + })?; + let prototype = program + .callable_prototypes + .get(prototype_id as usize) + .ok_or(AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "callable prototype id", + })?; + let capture_count = u8::try_from(prototype.capture_slots.len()).map_err(|_| { + AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "callable capture count", + } + })?; + instructions.push(AotInstruction::MakeCallable { + prototype_id, + capture_count, + }); + if let Some(stack) = provenance.as_mut() { + let capture_count = capture_count as usize; + if stack.len() < capture_count { + provenance = None; + } else { + stack.truncate(stack.len() - capture_count); + stack.push(AotStackProvenance::Derived); + } + } + } + OpCode::CallValue => { return Err(AotLowerError::InvalidImmediate { ip, opcode, @@ -494,6 +544,14 @@ fn is_explicit_terminal_opcode( && read_u32(code, ip + 1) == Some(*target_ip as u32) && next_ip == *fallthrough_ip), AotBlockTerminal::Fallthrough { .. } => Ok(false), + AotBlockTerminal::CallValue { + argc, + call_ip, + resume_ip, + } => Ok(opcode == OpCode::CallValue + && read_u8(code, ip + 1) == Some(*argc) + && ip == *call_ip + && next_ip == *resume_ip), AotBlockTerminal::InterpreterExit { exit_ip } => { Ok(matches!(opcode, OpCode::CallValue | OpCode::MakeCallable) && ip == *exit_ip) } @@ -908,6 +966,41 @@ mod tests { ); } + #[test] + fn aot_ir_keeps_make_callable_and_call_value_explicit() { + let compiled = crate::compile_source_for_repl( + r#" + let delta = 1; + let function = |value| value + delta; + function(41); + "#, + ) + .expect("closure source should compile"); + let lowered = lower_program(&compiled.program).expect("lowering should succeed"); + + assert_eq!(lowered.regions.len(), 2); + assert!( + lowered + .blocks + .iter() + .any( + |block| block.instructions.iter().any(|instruction| matches!( + instruction, + AotInstruction::MakeCallable { + prototype_id: 0, + capture_count: 1 + } + )) + ) + ); + assert!( + lowered + .blocks + .iter() + .any(|block| matches!(block.terminal, AotBlockTerminal::CallValue { argc: 1, .. })) + ); + } + #[test] fn aot_ir_preserves_loop_terminal_shape() { let mut bc = BytecodeBuilder::new(); diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 66bca70f..2ae58bb0 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -1351,6 +1351,12 @@ impl<'a> Builder<'a> { resume_frame, }); } + if matches!(step.instruction, AotInstruction::MakeCallable { .. }) { + return Ok(ProcessResult::InterpreterBoundary { + ip: step.ip, + frame: frame.clone(), + }); + } return Err(AotSsaBuildError::UnsupportedInstruction { ip: step.ip, instruction: format!("{:?}", step.instruction), @@ -1402,6 +1408,10 @@ impl<'a> Builder<'a> { frame: frame.clone(), }) } + AotBlockTerminal::CallValue { call_ip, .. } => Ok(ProcessResult::InterpreterBoundary { + ip: *call_ip, + frame: frame.clone(), + }), AotBlockTerminal::Return => Ok(ProcessResult::Return { ip: block .terminal_ip @@ -1459,6 +1469,7 @@ 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::InterpreterExit { exit_ip } => Some(exit_ip), } } @@ -1951,7 +1962,7 @@ fn apply_direct_instruction( _ => None, }) } - AotInstruction::Call(_) => Ok(false), + AotInstruction::Call(_) | AotInstruction::MakeCallable { .. } => Ok(false), } } From 7e1e1a51c3bfb3785d320a705d50987e18990616 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 18:10:52 +0800 Subject: [PATCH 06/18] feat(aot): execute callable frames natively --- src/vm/aot/artifact.rs | 4 +- src/vm/aot/compile.rs | 223 ++++++++++++++++++++++++++++++++++++--- src/vm/aot/runtime.rs | 8 +- src/vm/aot/ssa.rs | 153 +++++++++++++++++++++++++-- src/vm/host.rs | 14 ++- src/vm/native/bridge.rs | 164 +++++++++++++++++++++++++++- src/vm/native/codegen.rs | 39 +++++++ src/vm/native/mod.rs | 30 +++--- src/vm/tests.rs | 152 +++++++++++++++++++++++++- 9 files changed, 737 insertions(+), 50 deletions(-) diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 721c15cb..b0e3bb6d 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 = 4; -const ABI_VERSION: u16 = 3; +const VERSION: u16 = 5; +const ABI_VERSION: u16 = 4; const FLAGS: u16 = 0; #[derive(Debug)] diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index eb073161..f3ffc655 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -20,14 +20,16 @@ use crate::vm::native::{ array_push_entry_address, box_heap_value_signature, 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, 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, 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, - value_eq_entry_address, value_eq_signature, value_slot_signature, - write_heap_value_to_slot_entry_address, zero_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, make_callable_entry_address, make_callable_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, value_eq_entry_address, value_eq_signature, + value_slot_signature, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; use crate::vm::{Program, Value, Vm, VmError, VmResult}; #[cfg(feature = "cranelift-jit")] @@ -222,6 +224,9 @@ struct AotDeoptHelperRefs { vm_status_ref: cranelift_codegen::ir::SigRef, interrupt_ref: cranelift_codegen::ir::SigRef, frame_state_ref: cranelift_codegen::ir::SigRef, + enter_call_value_ref: cranelift_codegen::ir::SigRef, + leave_frame_ref: cranelift_codegen::ir::SigRef, + make_callable_ref: cranelift_codegen::ir::SigRef, clone_value_ref: cranelift_codegen::ir::SigRef, value_eq_ref: cranelift_codegen::ir::SigRef, init_null_slot_ref: cranelift_codegen::ir::SigRef, @@ -237,6 +242,9 @@ struct AotDeoptHelperRefs { struct AotDeoptHelperAddrs { aot_interrupt: usize, frame_state: usize, + enter_call_value: usize, + leave_frame: usize, + make_callable: usize, clone_value: usize, value_eq: usize, init_null_slot: usize, @@ -252,12 +260,14 @@ struct AotDeoptHelperAddrs { enum AotTempValueSlotKey { Output(AotSsaValueId), MutationArg(AotSsaValueId, u8), + CallableArg(AotSsaValueId, u16), } #[cfg(feature = "cranelift-jit")] struct AotOwnedValueTemps { ordered: Vec, slots: HashMap, + callable_pointer_buffers: HashMap, } #[cfg(feature = "cranelift-jit")] @@ -386,6 +396,9 @@ fn compile_ssa( let helper_sig = helper_signature(pointer_type, call_conv); 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 leave_frame_sig = leave_frame_signature(pointer_type, call_conv); + let make_callable_sig = make_callable_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); let copy_bytes_sig = copy_bytes_signature(pointer_type, call_conv); @@ -414,6 +427,9 @@ fn compile_ssa( let helper_addrs = AotDeoptHelperAddrs { aot_interrupt: aot_call_boundary_interrupt_entry_address(), frame_state: frame_state_entry_address(), + enter_call_value: enter_call_value_entry_address(), + leave_frame: leave_frame_entry_address(), + make_callable: make_callable_entry_address(), clone_value: clone_value_to_slot_entry_address(), value_eq: value_eq_entry_address(), init_null_slot: init_null_value_slot_entry_address(), @@ -469,6 +485,9 @@ fn compile_ssa( vm_status_ref: b.import_signature(vm_status_sig), 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), + leave_frame_ref: b.import_signature(leave_frame_sig), + make_callable_ref: b.import_signature(make_callable_sig), clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), init_null_slot_ref: b.import_signature(value_slot_sig.clone()), @@ -924,6 +943,18 @@ fn lower_aot_ssa_inst( )?; out } + AotSsaInstKind::MakeCallable { + prototype_id, + captures, + } => aot_lower_make_callable( + b, + ctx, + inst.output.id, + *prototype_id, + captures, + values, + value_reprs, + )?, AotSsaInstKind::StringLen { text } => aot_lower_string_len( b, ctx, @@ -1312,6 +1343,75 @@ fn aot_ensure_boxed_value_addr( Ok(addr) } +#[cfg(feature = "cranelift-jit")] +fn aot_lower_make_callable( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + output_id: AotSsaValueId, + prototype_id: u32, + captures: &[AotSsaValueId], + values: &HashMap, + value_reprs: &HashMap, +) -> Result { + let out = owned_value_temp_slot_addr( + b, + ctx.pointer_type, + ctx.owned_value_temps, + AotTempValueSlotKey::Output(output_id), + )?; + clear_owned_value_temp_slot(b, ctx.pointer_type, ctx.helper_refs, ctx.helper_addrs, out)?; + let captures_ptr = if captures.is_empty() { + b.ins().iconst(ctx.pointer_type, 0) + } else { + let slot = *ctx + .owned_value_temps + .callable_pointer_buffers + .get(&output_id) + .ok_or_else(|| { + AotCompileError::Codegen("AOT callable pointer buffer missing".to_string()) + })?; + let ptr = b.ins().stack_addr(ctx.pointer_type, slot, 0); + for (index, capture) in captures.iter().copied().enumerate() { + let capture_addr = aot_ensure_boxed_value_addr( + b, + ctx, + values, + value_reprs, + capture, + AotTempValueSlotKey::CallableArg( + output_id, + u16::try_from(index).map_err(|_| { + AotCompileError::Codegen( + "AOT callable capture index out of range".to_string(), + ) + })?, + ), + )?; + let offset = i32::try_from(index * std::mem::size_of::()).map_err(|_| { + AotCompileError::Codegen("AOT callable pointer offset out of range".to_string()) + })?; + b.ins().store(MemFlags::new(), capture_addr, ptr, offset); + } + ptr + }; + let prototype_id = b.ins().iconst(types::I64, i64::from(prototype_id)); + let capture_count = b.ins().iconst( + types::I64, + i64::try_from(captures.len()).map_err(|_| { + AotCompileError::Codegen("AOT callable capture count out of range".to_string()) + })?, + ); + call_status_helper( + b, + ctx.exit_block, + ctx.pointer_type, + ctx.helper_refs.make_callable_ref, + ctx.helper_addrs.make_callable, + &[ctx.vm_ptr, prototype_id, captures_ptr, capture_count, out], + )?; + Ok(out) +} + #[cfg(feature = "cranelift-jit")] #[allow(clippy::too_many_arguments)] fn lower_aot_ssa_terminator( @@ -1452,6 +1552,56 @@ fn lower_aot_ssa_terminator( ); jump_with_status(b, exit_block, status); } + AotSsaTerminator::CallValue { + 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_value)?; + 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("callvalue ip does not fit i64".to_string()) + })?, + ); + let resume_ip = b.ins().iconst( + types::I64, + i64::try_from(*resume_ip).map_err(|_| { + AotCompileError::Codegen("callvalue resume ip does not fit i64".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_value_ref, + helper_ptr, + &[vm_ptr, 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, @@ -1485,10 +1635,17 @@ fn lower_aot_ssa_terminator( values, *ip, )?; - let halted = b - .ins() - .iconst(types::I32, crate::vm::native::STATUS_HALTED as i64); - jump_with_status(b, exit_block, halted); + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.leave_frame)?; + let ret_ip = b.ins().iconst( + types::I64, + i64::try_from(*ip) + .map_err(|_| AotCompileError::Codegen("ret ip does not fit i64".to_string()))?, + ); + let call = + b.ins() + .call_indirect(helper_refs.leave_frame_ref, helper_ptr, &[vm_ptr, ret_ip]); + let status = b.inst_results(call)[0]; + jump_with_status(b, exit_block, status); } AotSsaTerminator::Stop { ip } => { store_vm_ip( @@ -1512,6 +1669,7 @@ fn aot_inst_requires_owned_value_slot(kind: &AotSsaInstKind) -> bool { matches!( kind, AotSsaInstKind::CloneTagged { .. } + | AotSsaInstKind::MakeCallable { .. } | AotSsaInstKind::StringSlice { .. } | AotSsaInstKind::BytesSlice { .. } | AotSsaInstKind::StringGet { .. } @@ -1533,6 +1691,7 @@ fn allocate_owned_value_temps( ) -> Result { let mut ordered = Vec::new(); let mut slots = HashMap::new(); + let mut callable_pointer_buffers = HashMap::new(); for block in &ssa.blocks { for inst in &block.insts { if aot_inst_requires_owned_value_slot(&inst.kind) { @@ -1554,10 +1713,48 @@ fn allocate_owned_value_temps( ); } } + if let AotSsaInstKind::MakeCallable { captures, .. } = &inst.kind { + for (index, _) in captures.iter().enumerate() { + let arg_slot = aot_create_value_stack_slot(b, value_size)?; + ordered.push(arg_slot); + slots.insert( + AotTempValueSlotKey::CallableArg( + inst.output.id, + u16::try_from(index).map_err(|_| { + AotCompileError::Codegen( + "AOT callable capture index out of range".to_string(), + ) + })?, + ), + arg_slot, + ); + } + if !captures.is_empty() { + let pointer_bytes = captures + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|bytes| u32::try_from(bytes).ok()) + .ok_or_else(|| { + AotCompileError::Codegen( + "AOT callable pointer buffer size out of range".to_string(), + ) + })?; + let pointer_slot = b.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + pointer_bytes, + std::mem::align_of::().trailing_zeros() as u8, + )); + callable_pointer_buffers.insert(inst.output.id, pointer_slot); + } + } } } } - Ok(AotOwnedValueTemps { ordered, slots }) + Ok(AotOwnedValueTemps { + ordered, + slots, + callable_pointer_buffers, + }) } #[cfg(feature = "cranelift-jit")] diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index 7f3d51f7..8320e5ef 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -1,8 +1,8 @@ use super::compile::{CompiledProgram, compile_program}; use crate::vm::native::{ - STATUS_CONTINUE, STATUS_ERROR, STATUS_HALTED, STATUS_OUT_OF_FUEL, STATUS_TRACE_EXIT, - STATUS_WAITING, STATUS_YIELDED, clear_bridge_error, selected_codegen_backend, - take_bridge_error, + STATUS_CONTINUE, STATUS_ERROR, STATUS_HALTED, STATUS_LINKED_CONTINUE, STATUS_OUT_OF_FUEL, + STATUS_TRACE_EXIT, STATUS_WAITING, STATUS_YIELDED, clear_bridge_error, + selected_codegen_backend, take_bridge_error, }; use crate::vm::{ExecOutcome, Vm, VmError, VmResult}; @@ -61,7 +61,7 @@ impl Vm { self.aot_exec_count = self.aot_exec_count.saturating_add(1); match status { - STATUS_CONTINUE => Ok(ExecOutcome::Continue), + STATUS_CONTINUE | STATUS_LINKED_CONTINUE => Ok(ExecOutcome::Continue), STATUS_HALTED => Ok(ExecOutcome::Halted), STATUS_YIELDED => { self.last_yield_reason = Some(super::super::VmYieldReason::Host); diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 2ae58bb0..e7b91070 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -113,6 +113,10 @@ pub(crate) enum AotSsaInstKind { CloneTagged { input: AotSsaValueId, }, + MakeCallable { + prototype_id: u32, + captures: Vec, + }, StringLen { text: AotSsaValueId, }, @@ -301,6 +305,7 @@ impl AotSsaInstKind { | Self::BoolConst(_) | Self::ConstSlot { .. } => Vec::new(), Self::CloneTagged { input } => vec![*input], + Self::MakeCallable { captures, .. } => captures.clone(), Self::StringLen { text } => vec![*text], Self::BytesLen { bytes } => vec![*bytes], Self::StringSlice { @@ -412,6 +417,13 @@ pub(crate) enum AotSsaTerminator { stack: Vec, locals: Vec, }, + CallValue { + argc: u8, + call_ip: usize, + resume_ip: usize, + stack: Vec, + locals: Vec, + }, InterpreterBoundary { ip: usize, stack: Vec, @@ -735,6 +747,7 @@ fn verify_terminator( verify_jump_target(if_false, block_params, available_values) } AotSsaTerminator::CallBoundary { stack, locals, .. } + | AotSsaTerminator::CallValue { stack, locals, .. } | AotSsaTerminator::InterpreterBoundary { stack, locals, .. } | AotSsaTerminator::Return { stack, locals, .. } => { for materialization in stack.iter().chain(locals.iter()) { @@ -836,6 +849,13 @@ enum ProcessResult { frame: Frame, resume_frame: Frame, }, + CallValue { + argc: u8, + call_ip: usize, + resume_ip: usize, + frame: Frame, + resume_frame: Frame, + }, InterpreterBoundary { ip: usize, frame: Frame, @@ -928,6 +948,12 @@ impl<'a> Builder<'a> { checkpoint_ips.insert(block.start_ip); } let mut external_resume_ips = BTreeSet::from([lowered.entry_ip]); + for region in &lowered.regions { + if region.prototype_id.is_some() { + checkpoint_ips.insert(region.start_ip); + external_resume_ips.insert(region.start_ip); + } + } for block in &decoded_blocks { for step in &block.steps { if let AotInstruction::Call(call) = &step.instruction { @@ -937,6 +963,15 @@ impl<'a> Builder<'a> { external_resume_ips.insert(call.resume_ip); } } + if let AotBlockTerminal::CallValue { + call_ip, resume_ip, .. + } = block.terminal + { + checkpoint_ips.insert(call_ip); + checkpoint_ips.insert(resume_ip); + external_resume_ips.insert(call_ip); + external_resume_ips.insert(resume_ip); + } } Ok(Self { program, @@ -1070,6 +1105,19 @@ impl<'a> Builder<'a> { stack: materialize_values(&frame.stack), locals: materialize_values(&frame.locals), }, + ProcessResult::CallValue { + argc, + call_ip, + resume_ip, + frame, + resume_frame: _, + } => AotSsaTerminator::CallValue { + argc, + call_ip, + resume_ip, + stack: materialize_values(&frame.stack), + locals: materialize_values(&frame.locals), + }, ProcessResult::InterpreterBoundary { ip, frame } => { AotSsaTerminator::InterpreterBoundary { ip, @@ -1123,6 +1171,13 @@ impl<'a> Builder<'a> { self.incoming_shapes .insert(self.lowered.entry_ip, entry_shape.clone()); let mut queue = VecDeque::from([self.lowered.entry_ip]); + for region in &self.lowered.regions { + if region.prototype_id.is_some() { + self.incoming_shapes + .insert(region.start_ip, entry_shape.clone()); + queue.push_back(region.start_ip); + } + } while let Some(ip) = queue.pop_front() { let shape = self @@ -1155,6 +1210,13 @@ impl<'a> Builder<'a> { } => { self.merge_shape(call.resume_ip, resume_frame.shape(), &mut queue)?; } + ProcessResult::CallValue { + resume_ip, + resume_frame, + .. + } => { + self.merge_shape(resume_ip, resume_frame.shape(), &mut queue)?; + } ProcessResult::InterpreterBoundary { .. } | ProcessResult::Return { .. } | ProcessResult::Stop { .. } => {} @@ -1351,12 +1413,6 @@ impl<'a> Builder<'a> { resume_frame, }); } - if matches!(step.instruction, AotInstruction::MakeCallable { .. }) { - return Ok(ProcessResult::InterpreterBoundary { - ip: step.ip, - frame: frame.clone(), - }); - } return Err(AotSsaBuildError::UnsupportedInstruction { ip: step.ip, instruction: format!("{:?}", step.instruction), @@ -1408,10 +1464,26 @@ impl<'a> Builder<'a> { frame: frame.clone(), }) } - AotBlockTerminal::CallValue { call_ip, .. } => Ok(ProcessResult::InterpreterBoundary { - ip: *call_ip, - frame: frame.clone(), - }), + AotBlockTerminal::CallValue { + argc, + call_ip, + resume_ip, + } => { + let mut resume_frame = frame.clone(); + for _ in 0..=usize::from(*argc) { + resume_frame.pop(*call_ip, "callvalue")?; + } + resume_frame.stack.push(FrameValue { + value: AotSsaValue::new(AotSsaValueId::new(0), AotSsaValueRepr::Tagged), + }); + Ok(ProcessResult::CallValue { + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + frame: frame.clone(), + resume_frame, + }) + } AotBlockTerminal::Return => Ok(ProcessResult::Return { ip: block .terminal_ip @@ -1962,7 +2034,30 @@ fn apply_direct_instruction( _ => None, }) } - AotInstruction::Call(_) | AotInstruction::MakeCallable { .. } => Ok(false), + AotInstruction::MakeCallable { + prototype_id, + capture_count, + } => { + let capture_count = usize::from(*capture_count); + if frame.stack.len() < capture_count { + return Err(AotSsaBuildError::StackUnderflow { + ip, + instruction: "makecallable", + }); + } + let captures = frame.stack.split_off(frame.stack.len() - capture_count); + let value = emitter.emit( + ip, + AotSsaInstKind::MakeCallable { + prototype_id: *prototype_id, + captures: captures.iter().map(|capture| capture.value.id).collect(), + }, + AotSsaValueRepr::Tagged, + ); + frame.stack.push(FrameValue { value }); + Ok(true) + } + AotInstruction::Call(_) => Ok(false), } } @@ -2675,6 +2770,42 @@ mod tests { assert!(ssa.resume_ips.contains(&(resume_ip as usize))); } + #[test] + fn aot_ssa_keeps_callable_creation_calls_and_returns_native() { + let compiled = crate::compile_source_for_repl( + r#" + let answer = 42; + let get_answer = || answer; + get_answer(); + "#, + ) + .expect("closure source should compile"); + let ssa = build_aot_ssa(&compiled.program).expect("callable ssa should build"); + + assert!(ssa.blocks.iter().any(|block| { + block + .insts + .iter() + .any(|inst| matches!(inst.kind, AotSsaInstKind::MakeCallable { .. })) + })); + assert!( + ssa.blocks.iter().any(|block| { + matches!(block.terminator, Some(AotSsaTerminator::CallValue { .. })) + }) + ); + assert!( + ssa.blocks + .iter() + .any(|block| { matches!(block.terminator, Some(AotSsaTerminator::Return { .. })) }) + ); + assert!(!ssa.blocks.iter().any(|block| { + matches!( + block.terminator, + Some(AotSsaTerminator::InterpreterBoundary { .. }) + ) + })); + } + #[test] fn aot_ssa_limits_resume_ips_to_entry_without_host_calls() { let mut bc = BytecodeBuilder::new(); diff --git a/src/vm/host.rs b/src/vm/host.rs index 1e29cede..72036909 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1588,7 +1588,19 @@ impl Vm { } pub(super) fn call_resume_ip(&self, call_ip: usize) -> VmResult { - let resume_ip = call_ip.checked_add(4).ok_or(VmError::BytecodeBounds)?; + let opcode = self + .program + .code + .get(call_ip) + .copied() + .ok_or(VmError::BytecodeBounds) + .and_then(|raw| OpCode::try_from(raw).map_err(|_| VmError::InvalidOpcode(raw)))?; + if !matches!(opcode, OpCode::Call | OpCode::CallValue) { + return Err(VmError::InvalidOpcode(opcode as u8)); + } + let resume_ip = call_ip + .checked_add(1 + opcode.operand_len()) + .ok_or(VmError::BytecodeBounds)?; if resume_ip > self.program.code.len() { return Err(VmError::BytecodeBounds); } diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 0a1c48b2..d3d7df66 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -2,8 +2,8 @@ use crate::builtins::BuiltinFunction; use crate::bytecode::{Value, ValueType, VmMap}; use crate::vm::{ - CallOutcome, CallReturn, FrameContinuation, HostCallExecOutcome, NumericValue, Vm, VmError, - VmHostFunction, VmResult, logical_shr_i64, + CallOutcome, CallReturn, ExecOutcome, FrameContinuation, HostCallExecOutcome, NumericValue, Vm, + VmError, VmHostFunction, VmResult, logical_shr_i64, }; use std::cell::RefCell; use std::sync::Arc; @@ -281,6 +281,18 @@ pub(crate) fn frame_state_entry_address() -> usize { pd_vm_native_frame_state as *const () as usize } +pub(crate) fn enter_call_value_entry_address() -> usize { + pd_vm_native_enter_call_value as *const () as usize +} + +pub(crate) fn leave_frame_entry_address() -> usize { + pd_vm_native_leave_frame as *const () as usize +} + +pub(crate) fn make_callable_entry_address() -> usize { + pd_vm_native_make_callable as *const () as usize +} + pub(crate) fn active_local_base_entry_address() -> usize { pd_vm_native_active_local_base as *const () as usize } @@ -784,6 +796,98 @@ pub(crate) extern "C" fn pd_vm_native_frame_state(vm: *mut Vm, out: *mut NativeF }) } +pub(crate) extern "C" fn pd_vm_native_enter_call_value( + vm: *mut Vm, + argc: i64, + call_ip: i64, + resume_ip: i64, +) -> i32 { + run_step(vm, "enter_call_value", |vm| { + let argc = u8::try_from(argc) + .map_err(|_| VmError::InvalidFrameState("native call argc out of range"))?; + let call_ip = usize::try_from(call_ip) + .map_err(|_| VmError::InvalidFrameState("native call ip out of range"))?; + let resume_ip = usize::try_from(resume_ip) + .map_err(|_| VmError::InvalidFrameState("native resume ip out of range"))?; + if vm.ip != call_ip { + vm.jump_to(call_ip)?; + } + if resume_ip > vm.program.code.len() { + return Err(VmError::BytecodeBounds); + } + vm.ip = resume_ip; + match vm.execute_call_value(argc)? { + ExecOutcome::Continue => Ok(STATUS_LINKED_CONTINUE), + ExecOutcome::Halted => Ok(STATUS_HALTED), + ExecOutcome::Yielded => Ok(STATUS_YIELDED), + ExecOutcome::Waiting(_) => Ok(STATUS_WAITING), + } + }) +} + +pub(crate) extern "C" fn pd_vm_native_leave_frame(vm: *mut Vm, ret_ip: i64) -> i32 { + run_step(vm, "leave_frame", |vm| { + let ret_ip = usize::try_from(ret_ip) + .map_err(|_| VmError::InvalidFrameState("native ret ip out of range"))?; + vm.jump_to(ret_ip)?; + match vm.complete_active_frame()? { + ExecOutcome::Continue => Ok(STATUS_LINKED_CONTINUE), + ExecOutcome::Halted => Ok(STATUS_HALTED), + ExecOutcome::Yielded => Ok(STATUS_YIELDED), + ExecOutcome::Waiting(_) => Ok(STATUS_WAITING), + } + }) +} + +pub(crate) extern "C" fn pd_vm_native_make_callable( + vm: *mut Vm, + prototype_id: i64, + captures: *const *mut Value, + capture_count: i64, + out: *mut Value, +) -> i32 { + run_step(vm, "make_callable", |vm| { + if out.is_null() { + return Err(VmError::InvalidFrameState( + "native callable output buffer is null", + )); + } + let prototype_id = u32::try_from(prototype_id) + .map_err(|_| VmError::InvalidFrameState("native prototype id out of range"))?; + let capture_count = usize::try_from(capture_count) + .map_err(|_| VmError::InvalidFrameState("native capture count out of range"))?; + if capture_count > 0 && captures.is_null() { + return Err(VmError::InvalidFrameState( + "native callable capture buffer is null", + )); + } + let stack_base = vm.stack.len(); + for index in 0..capture_count { + let capture = unsafe { captures.add(index).read() }; + if capture.is_null() { + vm.stack.truncate(stack_base); + return Err(VmError::InvalidFrameState( + "native callable capture slot is null", + )); + } + vm.stack.push(unsafe { capture.read() }); + unsafe { + capture.write(Value::Null); + } + } + if let Err(err) = vm.make_callable(prototype_id) { + vm.stack.truncate(stack_base); + return Err(err); + } + let callable = vm.pop_value()?; + vm.stack.truncate(stack_base); + unsafe { + out.write(callable); + } + Ok(STATUS_CONTINUE) + }) +} + pub(crate) extern "C" fn pd_vm_native_active_stack_base(vm: *const Vm) -> usize { unsafe { vm.as_ref() } .map(Vm::active_operand_stack_base) @@ -1490,6 +1594,62 @@ mod tests { ); } + #[test] + fn native_callable_helpers_create_enter_and_leave_frames() { + let compiled = crate::compile_source_for_repl( + r#" + let delta = 1; + let function = |value| value + delta; + function(41); + "#, + ) + .expect("closure source should compile"); + let call_ip = compiled + .program + .code + .iter() + .position(|byte| *byte == crate::OpCode::CallValue as u8) + .expect("callvalue opcode"); + let resume_ip = call_ip + 2; + let prototype = compiled.program.callable_prototypes[0].clone(); + let function = match prototype.target { + crate::CallableTarget::ScriptFunction(function_id) => { + compiled.program.script_functions[function_id as usize].clone() + } + _ => panic!("expected script target"), + }; + let ret_ip = function.end_ip as usize - 1; + let mut vm = Vm::new(compiled.program); + + let mut capture = Value::Int(1); + let captures = [&mut capture as *mut Value]; + let mut callable = MaybeUninit::::uninit(); + assert_eq!( + pd_vm_native_make_callable(&mut vm, 0, captures.as_ptr(), 1, callable.as_mut_ptr(),), + STATUS_CONTINUE + ); + assert_eq!(capture, Value::Null); + let callable = unsafe { callable.assume_init() }; + assert!(matches!(callable, Value::Callable(_))); + + vm.stack.extend([callable, Value::Int(41)]); + assert_eq!( + pd_vm_native_enter_call_value(&mut vm, 1, call_ip as i64, resume_ip as i64,), + STATUS_LINKED_CONTINUE + ); + assert_eq!(vm.call_depth, 1); + assert_eq!(vm.ip, function.entry_ip as usize); + + vm.stack.push(Value::Int(42)); + assert_eq!( + pd_vm_native_leave_frame(&mut vm, ret_ip as i64), + STATUS_LINKED_CONTINUE + ); + assert_eq!(vm.call_depth, 0); + assert_eq!(vm.ip, resume_ip); + assert_eq!(vm.stack, vec![Value::Int(42)]); + } + #[test] fn bridge_errors_are_isolated_between_threads() { clear_bridge_error(); diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 6e71f050..56b324a8 100644 --- a/src/vm/native/codegen.rs +++ b/src/vm/native/codegen.rs @@ -38,6 +38,45 @@ pub(crate) fn alloc_buffer_signature( sig } +#[cfg(feature = "cranelift-jit")] +pub(crate) fn enter_call_value_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.extend((0..3).map(|_| AbiParam::new(types::I64))); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + +#[cfg(feature = "cranelift-jit")] +pub(crate) fn leave_frame_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(types::I64)); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + +#[cfg(feature = "cranelift-jit")] +pub(crate) fn make_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(types::I64)); + sig.params.push(AbiParam::new(pointer_type)); + sig.params.push(AbiParam::new(types::I64)); + sig.params.push(AbiParam::new(pointer_type)); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + #[cfg(feature = "cranelift-jit")] pub(crate) fn frame_state_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 94f72bd0..cfe93916 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -13,12 +13,13 @@ pub(crate) use bridge::{ alloc_byte_buffer_entry_address, alloc_value_buffer_entry_address, aot_call_boundary_interrupt_entry_address, array_push_entry_address, array_set_entry_address, clear_bridge_error, clear_value_slot_entry_address, clone_value_to_slot_entry_address, - collection_set_entry_address, copy_bytes_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, 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, - regex_match_entry_address, regex_replace_entry_address, + collection_set_entry_address, copy_bytes_entry_address, enter_call_value_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, make_callable_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, regex_match_entry_address, regex_replace_entry_address, restore_active_exit_state_entry_address, restore_exit_state_entry_address, restore_sparse_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, @@ -31,13 +32,14 @@ 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, entry_signature, frame_state_signature, free_buffer_signature, - helper_signature, jump_with_status, map_iter_next_signature, map_iter_take_signature, - map_set_signature, non_yielding_host_call_signature, pack_shared_signature, - regex_match_signature, regex_replace_signature, restore_exit_signature, - sparse_restore_exit_signature, string_binary_transform_signature, string_contains_signature, - string_replace_signature, string_unary_transform_signature, value_eq_signature, - value_len_signature, value_slot_signature, + copy_bytes_signature, enter_call_value_signature, entry_signature, frame_state_signature, + free_buffer_signature, helper_signature, jump_with_status, leave_frame_signature, + make_callable_signature, map_iter_next_signature, map_iter_take_signature, map_set_signature, + non_yielding_host_call_signature, pack_shared_signature, regex_match_signature, + regex_replace_signature, restore_exit_signature, sparse_restore_exit_signature, + string_binary_transform_signature, string_contains_signature, string_replace_signature, + string_unary_transform_signature, value_eq_signature, value_len_signature, + value_slot_signature, }; pub(crate) use exec::{ExecutableBuffer, prepare_for_execution}; pub(crate) use layout::{ @@ -46,7 +48,7 @@ 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 = 1; +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 2; #[cfg(feature = "cranelift-jit")] pub(crate) fn selected_codegen_backend() -> &'static str { diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 1f3067e1..c1c49180 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -178,7 +178,7 @@ fn host_can_invoke_exported_callable_and_reset_makes_it_stale() { #[cfg(feature = "cranelift-jit")] #[test] -fn aot_side_exits_at_callable_boundary_with_state_preserved() { +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 } @@ -193,8 +193,154 @@ fn aot_side_exits_at_callable_boundary_with_state_preserved() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(vm.aot_exec_count() > 0); - assert!(vm.aot_interpreter_boundary_hit); + assert!(vm.aot_exec_count() >= 3); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_executes_capturing_closure_without_interpreter_boundary() { + let compiled = crate::compile_source_for_repl( + r#" + let answer = 42; + let get_answer = || answer; + get_answer(); + "#, + ) + .expect("closure source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("aot execution should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert!(vm.aot_exec_count() >= 3); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_executes_builtin_callable_values_without_interpreter_boundary() { + let compiled = crate::compile_source_for_repl( + r#" + let function = len; + function("abc"); + "#, + ) + .expect("builtin callable source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("aot execution should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(3)]); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +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 } + add_one(41); + "#, + ) + .expect("callable source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + vm.set_fuel(0); + assert_eq!( + vm.run().expect("fuel exhaustion should yield"), + VmStatus::Yielded + ); + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Fuel)); + vm.set_fuel(100); + assert_eq!( + vm.resume().expect("aot callable should resume"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_executes_nested_script_callables_without_interpreter_boundary() { + let compiled = crate::compile_source_for_repl( + r#" + fn inc(value: int) -> int { value + 1 } + fn twice(value: int) -> int { inc(inc(value)) } + twice(40); + "#, + ) + .expect("nested callable source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("nested aot call should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundary() { + let compiled = crate::compile_source_for_repl( + r#" + fn recurse(value: int) -> int { recurse(value) } + recurse(1); + "#, + ) + .expect("recursive callable source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert!(matches!( + vm.run(), + Err(VmError::CallStackOverflow { limit: 1024 }) + )); + assert!(!vm.aot_interpreter_boundary_hit); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { + struct PendingAotHost; + + impl HostFunction for PendingAotHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Pending(812)) + } + } + + let compiled = crate::compile_source_for_repl( + r#" + fn action(value: int) -> int; + let function = action; + function(41); + "#, + ) + .expect("host callable source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.register_function(Box::new(PendingAotHost)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("pending host callable should wait"), + VmStatus::Waiting(812) + ); + assert!(!vm.aot_interpreter_boundary_hit); + vm.complete_host_op(812, vec![Value::Int(42)]) + .expect("host operation should complete"); + assert_eq!( + vm.resume().expect("aot host callable should resume"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert!(!vm.aot_interpreter_boundary_hit); } #[test] From 3fcf761ecd69884fe5f9101e1789ac8b88449f7c Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 18:53:01 +0800 Subject: [PATCH 07/18] feat(jit): trace inside script callable frames --- src/vm/jit/native/lower.rs | 255 ++++++++++++++++++++++++++++++------- src/vm/jit/recorder.rs | 33 ++++- src/vm/jit/runtime.rs | 54 ++++++-- src/vm/jit/trace.rs | 131 +++++++++++++++---- src/vm/mod.rs | 25 +++- src/vm/native/bridge.rs | 118 +++++++++++++++++ src/vm/native/mod.rs | 15 ++- tests/jit/jit_tests.rs | 63 +++++++-- 8 files changed, 591 insertions(+), 103 deletions(-) diff --git a/src/vm/jit/native/lower.rs b/src/vm/jit/native/lower.rs index 931b85f2..7ab8cc50 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -8,20 +8,21 @@ use crate::vm::jit::ir::{ SsaTrace, SsaValueId, SsaValueRepr, }; use crate::vm::native::{ - ExecutableBuffer, HeapIntrinsicAddrs, HeapIntrinsicRefs, NativeInterruptMode, - NativeInterruptSettings, NativeStackLayout, STATUS_CONTINUE, STATUS_ERROR, STATUS_HALTED, - STATUS_OUT_OF_FUEL, STATUS_TRACE_EXIT, alloc_buffer_signature, alloc_byte_buffer_entry_address, + ExecutableBuffer, HeapIntrinsicAddrs, HeapIntrinsicRefs, NativeFrameState, NativeInterruptMode, + NativeInterruptSettings, NativeStackLayout, STATUS_CONTINUE, STATUS_ERROR, STATUS_OUT_OF_FUEL, + STATUS_TRACE_EXIT, alloc_buffer_signature, alloc_byte_buffer_entry_address, alloc_value_buffer_entry_address, array_push_entry_address, array_set_entry_address, array_set_signature, box_heap_value_signature, checked_add_i32, 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, entry_signature, free_buffer_signature, jump_with_status, + detect_native_stack_layout, entry_signature, frame_state_entry_address, frame_state_signature, + free_buffer_signature, jump_with_status, leave_frame_entry_address, leave_frame_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, non_yielding_host_call_entry_address, non_yielding_host_call_signature, pack_shared_signature, regex_match_entry_address, regex_match_signature, regex_replace_entry_address, - regex_replace_signature, restore_sparse_exit_state_entry_address, + regex_replace_signature, restore_active_sparse_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, sparse_restore_exit_signature, string_binary_transform_signature, string_contains_entry_address, string_contains_signature, @@ -118,6 +119,8 @@ fn try_compile_ssa_trace( let array_set_sig = array_set_signature(pointer_type, call_conv); let map_set_sig = map_set_signature(pointer_type, call_conv); let sparse_restore_exit_sig = sparse_restore_exit_signature(pointer_type, call_conv); + let frame_state_sig = frame_state_signature(pointer_type, call_conv); + let leave_frame_sig = leave_frame_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); let regex_match_sig = regex_match_signature(pointer_type, call_conv); @@ -175,6 +178,7 @@ fn try_compile_ssa_trace( to_string: to_string_entry_address(), split_literal: string_split_literal_entry_address(), }; + let frame_state_ref = b.import_signature(frame_state_sig); let deopt_refs = SsaDeoptHelperRefs { clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), @@ -191,6 +195,7 @@ fn try_compile_ssa_trace( array_set_ref: b.import_signature(array_set_sig), map_set_ref: b.import_signature(map_set_sig), sparse_restore_exit_ref: b.import_signature(sparse_restore_exit_sig), + leave_frame_ref: b.import_signature(leave_frame_sig), resume_linked_trace_ref: b.import_signature(resume_linked_trace_sig), }; let deopt_addrs = SsaDeoptHelperAddrs { @@ -208,7 +213,8 @@ fn try_compile_ssa_trace( array_push: array_push_entry_address(), array_set: array_set_entry_address(), map_set: map_set_entry_address(), - sparse_restore_exit: restore_sparse_exit_state_entry_address(), + sparse_restore_exit: restore_active_sparse_exit_state_entry_address(), + leave_frame: leave_frame_entry_address(), resume_linked_trace: resume_linked_trace_entry_address(), }; // The outer native dispatch loop links trace exits directly. Re-entering it through the @@ -270,8 +276,31 @@ fn try_compile_ssa_trace( b.switch_to_block(entry_block); b.append_block_params_for_function_params(entry_block); let vm_ptr = b.block_params(entry_block)[0]; + let entry_local_count = ssa + .blocks + .get(ssa.entry.index()) + .and_then(|block| block.params.len().checked_sub(ssa.entry_stack_depth)) + .ok_or_else(|| { + VmError::JitNative( + "SSA entry stack depth exceeds entry parameter count".to_string(), + ) + })?; + let (active_stack_base, active_local_base) = emit_frame_state_entry_guard( + &mut b, + vm_ptr, + exit_block, + pointer_type, + offsets, + frame_state_ref, + frame_state_entry_address(), + trace.frame_key, + ssa.entry_stack_depth, + entry_local_count, + )?; let lower_ctx = SsaLowerCtx { vm_ptr, + active_stack_base, + active_local_base, exit_block, pointer_type, layout, @@ -295,19 +324,6 @@ fn try_compile_ssa_trace( ); b.ins() .store(MemFlags::new(), root_ip, vm_ptr, offsets.vm_ip); - emit_entry_stack_depth_guard( - &mut b, - vm_ptr, - exit_block, - pointer_type, - offsets, - ssa.entry_stack_depth, - )?; - - let entry_ssa_block = ssa - .blocks - .get(ssa.entry.index()) - .ok_or_else(|| VmError::JitNative("SSA entry block missing".to_string()))?; let entry_handle = *block_handles .get(&ssa.entry) .ok_or_else(|| VmError::JitNative("SSA entry block handle missing".to_string()))?; @@ -317,16 +333,10 @@ fn try_compile_ssa_trace( pointer_type, layout, offsets, + active_stack_base, + active_local_base, ssa.entry_stack_depth, - entry_ssa_block - .params - .len() - .checked_sub(ssa.entry_stack_depth) - .ok_or_else(|| { - VmError::JitNative( - "SSA entry stack depth exceeds entry parameter count".to_string(), - ) - })?, + entry_local_count, )?; init_owned_value_temps(&mut b, pointer_type, layout.value, &owned_value_temps)?; let entry_args = ssa_block_args(entry_args); @@ -458,6 +468,7 @@ struct SsaDeoptHelperRefs { array_set_ref: cranelift_codegen::ir::SigRef, map_set_ref: cranelift_codegen::ir::SigRef, sparse_restore_exit_ref: cranelift_codegen::ir::SigRef, + leave_frame_ref: cranelift_codegen::ir::SigRef, resume_linked_trace_ref: cranelift_codegen::ir::SigRef, } @@ -478,6 +489,7 @@ struct SsaDeoptHelperAddrs { array_set: usize, map_set: usize, sparse_restore_exit: usize, + leave_frame: usize, resume_linked_trace: usize, } @@ -522,6 +534,8 @@ struct SsaOwnedValueTemps { #[derive(Clone, Copy)] struct SsaLowerCtx<'a> { vm_ptr: cranelift_codegen::ir::Value, + active_stack_base: cranelift_codegen::ir::Value, + active_local_base: cranelift_codegen::ir::Value, exit_block: Block, pointer_type: cranelift_codegen::ir::Type, layout: crate::vm::native::NativeStackLayout, @@ -1027,21 +1041,38 @@ fn ssa_backedge_targets( targets } +#[allow(clippy::too_many_arguments)] fn build_entry_args( b: &mut FunctionBuilder, vm_ptr: cranelift_codegen::ir::Value, pointer_type: cranelift_codegen::ir::Type, layout: crate::vm::native::NativeStackLayout, offsets: ResolvedOffsets, + active_stack_base: cranelift_codegen::ir::Value, + active_local_base: cranelift_codegen::ir::Value, stack_depth: usize, local_count: usize, ) -> VmResult> { let stack_ptr = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.stack_ptr); + let stack_ptr = ssa_value_addr( + b, + pointer_type, + stack_ptr, + active_stack_base, + layout.value.size, + ); let locals_ptr = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.locals_ptr); + let locals_ptr = ssa_value_addr( + b, + pointer_type, + locals_ptr, + active_local_base, + layout.value.size, + ); let mut args = Vec::with_capacity(stack_depth + local_count); for stack_index in 0..stack_depth { let index = b.ins().iconst( @@ -1074,33 +1105,122 @@ fn build_entry_args( Ok(args) } -fn emit_entry_stack_depth_guard( +#[allow(clippy::too_many_arguments)] +fn emit_frame_state_entry_guard( b: &mut FunctionBuilder, vm_ptr: cranelift_codegen::ir::Value, exit_block: Block, pointer_type: cranelift_codegen::ir::Type, offsets: ResolvedOffsets, - expected_depth: usize, -) -> VmResult<()> { - let actual_depth = b + frame_state_ref: cranelift_codegen::ir::SigRef, + frame_state_addr: usize, + expected_frame_key: u64, + expected_stack_depth: usize, + expected_local_count: usize, +) -> VmResult<(cranelift_codegen::ir::Value, cranelift_codegen::ir::Value)> { + let frame_state_size = u32::try_from(std::mem::size_of::()) + .map_err(|_| VmError::JitNative("native frame state size out of range".to_string()))?; + let frame_state_align = std::mem::align_of::().trailing_zeros() as u8; + let frame_state_slot = b.create_sized_stack_slot(StackSlotData::new( + StackSlotKind::ExplicitSlot, + frame_state_size, + frame_state_align, + )); + let frame_state_ptr = b.ins().stack_addr(pointer_type, frame_state_slot, 0); + ssa_call_status_helper( + b, + exit_block, + pointer_type, + frame_state_ref, + frame_state_addr, + &[vm_ptr, frame_state_ptr], + )?; + + let frame_key_offset = i32::try_from(std::mem::offset_of!(NativeFrameState, frame_key)) + .map_err(|_| VmError::JitNative("native frame key offset out of range".to_string()))?; + let stack_base_offset = + i32::try_from(std::mem::offset_of!(NativeFrameState, operand_stack_base)).map_err( + |_| VmError::JitNative("native frame stack-base offset out of range".to_string()), + )?; + let local_base_offset = i32::try_from(std::mem::offset_of!(NativeFrameState, local_base)) + .map_err(|_| { + VmError::JitNative("native frame local-base offset out of range".to_string()) + })?; + let frame_key = b.ins().load( + types::I64, + MemFlags::new(), + frame_state_ptr, + frame_key_offset, + ); + let active_stack_base = b.ins().load( + pointer_type, + MemFlags::new(), + frame_state_ptr, + stack_base_offset, + ); + let active_local_base = b.ins().load( + pointer_type, + MemFlags::new(), + frame_state_ptr, + local_base_offset, + ); + + let expected_frame_key = b.ins().iconst(types::I64, expected_frame_key as i64); + let frame_matches = b.ins().icmp(IntCC::Equal, frame_key, expected_frame_key); + let frame_match = b.create_block(); + let mismatch = b.create_block(); + b.ins().brif(frame_matches, frame_match, &[], mismatch, &[]); + + b.switch_to_block(mismatch); + let status = b.ins().iconst(types::I32, STATUS_CONTINUE as i64); + jump_with_status(b, exit_block, status); + + b.switch_to_block(frame_match); + let actual_stack_len = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.stack_len); - let expected_depth = b.ins().iconst( + let expected_stack_depth = b.ins().iconst( pointer_type, - i64::try_from(expected_depth) + i64::try_from(expected_stack_depth) .map_err(|_| VmError::JitNative("SSA entry stack depth out of range".to_string()))?, ); - let matches = b.ins().icmp(IntCC::Equal, actual_depth, expected_depth); - let matched = b.create_block(); - let mismatch = b.create_block(); - b.ins().brif(matches, matched, &[], mismatch, &[]); + let expected_stack_len = b.ins().iadd(active_stack_base, expected_stack_depth); + let stack_matches = b + .ins() + .icmp(IntCC::Equal, actual_stack_len, expected_stack_len); + let stack_match = b.create_block(); + let stack_mismatch = b.create_block(); + b.ins() + .brif(stack_matches, stack_match, &[], stack_mismatch, &[]); - b.switch_to_block(mismatch); + b.switch_to_block(stack_mismatch); let status = b.ins().iconst(types::I32, STATUS_CONTINUE as i64); jump_with_status(b, exit_block, status); - b.switch_to_block(matched); - Ok(()) + b.switch_to_block(stack_match); + let actual_local_len = b + .ins() + .load(pointer_type, MemFlags::new(), vm_ptr, offsets.locals_len); + let expected_local_count = b.ins().iconst( + pointer_type, + i64::try_from(expected_local_count) + .map_err(|_| VmError::JitNative("SSA entry local count out of range".to_string()))?, + ); + let expected_local_len = b.ins().iadd(active_local_base, expected_local_count); + let locals_match = b + .ins() + .icmp(IntCC::Equal, actual_local_len, expected_local_len); + let locals_matched = b.create_block(); + let locals_mismatch = b.create_block(); + b.ins() + .brif(locals_match, locals_matched, &[], locals_mismatch, &[]); + + b.switch_to_block(locals_mismatch); + let status = b.ins().iconst(types::I32, STATUS_CONTINUE as i64); + jump_with_status(b, exit_block, status); + + b.switch_to_block(locals_matched); + Ok((active_stack_base, active_local_base)) } fn ssa_block_args(values: impl IntoIterator) -> Vec { @@ -3007,6 +3127,26 @@ fn ssa_branch_target_block( } } +fn ssa_leave_frame_status( + b: &mut FunctionBuilder, + pointer_type: cranelift_codegen::ir::Type, + helper_refs: SsaDeoptHelperRefs, + helper_addrs: SsaDeoptHelperAddrs, + vm_ptr: cranelift_codegen::ir::Value, + ret_ip: usize, +) -> VmResult { + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.leave_frame)?; + let ret_ip = b.ins().iconst( + types::I64, + i64::try_from(ret_ip) + .map_err(|_| VmError::JitNative("SSA return ip out of range".to_string()))?, + ); + let call = b + .ins() + .call_indirect(helper_refs.leave_frame_ref, helper_ptr, &[vm_ptr, ret_ip]); + Ok(b.inst_results(call)[0]) +} + fn lower_ssa_exit_block( b: &mut FunctionBuilder, ctx: SsaLowerCtx<'_>, @@ -3017,6 +3157,7 @@ fn lower_ssa_exit_block( ) -> VmResult<()> { let SsaLowerCtx { vm_ptr, + active_local_base, exit_block, pointer_type, layout, @@ -3077,6 +3218,13 @@ fn lower_ssa_exit_block( let vm_locals_ptr = b .ins() .load(pointer_type, MemFlags::new(), vm_ptr, offsets.locals_ptr); + let vm_locals_ptr = ssa_value_addr( + b, + pointer_type, + vm_locals_ptr, + active_local_base, + layout.value.size, + ); for (local_index, materialization) in exit.locals .iter() @@ -3111,7 +3259,14 @@ fn lower_ssa_exit_block( b.ins() .store(MemFlags::new(), ip_val, vm_ptr, offsets.vm_ip); let status = if halted { - b.ins().iconst(types::I32, STATUS_HALTED as i64) + ssa_leave_frame_status( + b, + pointer_type, + deopt_refs, + deopt_addrs, + vm_ptr, + exit.exit_ip, + )? } else if allow_link_handoff { let helper_ptr = iconst_ptr_from_addr(b, pointer_type, deopt_addrs.resume_linked_trace)?; @@ -3206,7 +3361,14 @@ fn lower_ssa_exit_block( ], )?; let status = if halted { - b.ins().iconst(types::I32, STATUS_HALTED as i64) + ssa_leave_frame_status( + b, + pointer_type, + deopt_refs, + deopt_addrs, + vm_ptr, + exit.exit_ip, + )? } else if allow_link_handoff { let helper_ptr = iconst_ptr_from_addr(b, pointer_type, deopt_addrs.resume_linked_trace)?; let call = b @@ -4108,6 +4270,7 @@ struct ResolvedOffsets { stack_ptr: i32, stack_len: i32, locals_ptr: i32, + locals_len: i32, vm_ip: i32, fuel_remaining: i32, fuel_ops_until_check: i32, @@ -4319,11 +4482,17 @@ fn resolve_offsets(layout: NativeStackLayout) -> VmResult { layout.stack_vec.ptr_offset, "locals ptr offset overflow", )?; + let locals_len = checked_add_i32( + layout.vm_locals_offset, + layout.stack_vec.len_offset, + "locals len offset overflow", + )?; Ok(ResolvedOffsets { stack_ptr, stack_len, locals_ptr, + locals_len, vm_ip: layout.vm_ip_offset, fuel_remaining: layout.vm_fuel_remaining_offset, fuel_ops_until_check: layout.vm_fuel_ops_until_check_offset, diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index c55757b0..fc4ee312 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -197,10 +197,10 @@ struct AnalysisFrame { } impl AnalysisFrame { - fn new(program: &Program, entry_stack_depth: usize) -> Self { + fn new(program: &Program, entry_stack_depth: usize, local_count: usize) -> Self { Self { stack: vec![ValueInfo::tagged(); entry_stack_depth], - locals: (0..program.local_count) + locals: (0..local_count) .map(|local| entry_local_info(program, local)) .collect(), } @@ -692,17 +692,37 @@ impl<'a> TraceCursor<'a> { } } +#[cfg(test)] pub(crate) fn record_trace( program: &Program, root_ip: usize, entry_stack_depth: usize, max_trace_len: usize, non_yielding_host_imports: &[bool], +) -> Result { + record_trace_with_local_count( + program, + root_ip, + entry_stack_depth, + program.local_count, + max_trace_len, + non_yielding_host_imports, + ) +} + +pub(crate) fn record_trace_with_local_count( + program: &Program, + root_ip: usize, + entry_stack_depth: usize, + local_count: usize, + max_trace_len: usize, + non_yielding_host_imports: &[bool], ) -> Result { let loop_header_plan = infer_loop_header_plan( program, root_ip, entry_stack_depth, + local_count, max_trace_len, non_yielding_host_imports, )?; @@ -721,7 +741,7 @@ pub(crate) fn record_trace( }) .collect::, _>>()?; - let entry_locals = (0..program.local_count) + let entry_locals = (0..local_count) .map(|local| { builder .append_param(entry, SsaValueRepr::Tagged, format!("local{local}")) @@ -1281,13 +1301,14 @@ fn infer_loop_header_plan( program: &Program, root_ip: usize, entry_stack_depth: usize, + local_count: usize, 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); - let mut entry_use = vec![EntryUseState::Untouched; program.local_count]; - let mut local_written = vec![false; program.local_count]; + let mut frame = AnalysisFrame::new(program, entry_stack_depth, local_count); + let mut entry_use = vec![EntryUseState::Untouched; local_count]; + let mut local_written = vec![false; local_count]; loop { let Some(decoded) = cursor.next()? else { diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index a1eebd52..83887919 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -64,6 +64,7 @@ struct NativeTraceCacheKey { interrupt_settings: Option, compile_profile: native::NativeCompileProfile, drop_contract_events_enabled: bool, + frame_key: u64, root_ip: usize, entry_stack_depth: usize, terminal: JitTraceTerminal, @@ -145,6 +146,7 @@ fn native_trace_cache_key( interrupt_settings, compile_profile, drop_contract_events_enabled, + frame_key: trace.frame_key, root_ip: trace.root_ip, entry_stack_depth: trace.entry_stack_depth, terminal: trace.terminal, @@ -229,10 +231,16 @@ impl Vm { self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); let mut current_trace_id = { let ip = self.ip; - let mut next_trace_id = self.jit.compiled_trace_for_entry(ip, self.stack.len()); + let frame_key = self.active_frame_key(); + let stack_depth = self.active_operand_stack_len(); + let mut next_trace_id = self + .jit + .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { let program = &self.program; - next_trace_id = self.jit.observe_exit_entry(ip, self.stack.len(), program); + next_trace_id = self + .jit + .observe_exit_entry(frame_key, ip, stack_depth, program); } let Some(next_trace_id) = next_trace_id else { return Ok(native::STATUS_LINKED_CONTINUE); @@ -263,9 +271,16 @@ impl Vm { match status { native::STATUS_CONTINUE => { - if !has_yielding_call - && let Some(next_trace_id) = - self.jit.compiled_trace_for_entry(self.ip, self.stack.len()) + let next_trace_id = if has_yielding_call { + None + } else { + self.jit.compiled_trace_for_entry( + self.active_frame_key(), + self.ip, + self.active_operand_stack_len(), + ) + }; + if let Some(next_trace_id) = next_trace_id && next_trace_id != current_trace_id { self.record_jit_link_handoff(); @@ -299,12 +314,16 @@ impl Vm { } if !has_yielding_call { let ip = self.ip; + let frame_key = self.active_frame_key(); + let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = - self.jit.compiled_trace_for_entry(ip, self.stack.len()); + self.jit + .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { let program = &self.program; next_trace_id = - self.jit.observe_exit_entry(ip, self.stack.len(), program); + self.jit + .observe_exit_entry(frame_key, ip, stack_depth, program); } if let Some(next_trace_id) = next_trace_id && next_trace_id != current_trace_id @@ -506,9 +525,16 @@ impl Vm { match status { native::STATUS_CONTINUE => { - if !has_yielding_call - && let Some(next_trace_id) = - self.jit.compiled_trace_for_entry(self.ip, self.stack.len()) + let next_trace_id = if has_yielding_call { + None + } else { + self.jit.compiled_trace_for_entry( + self.active_frame_key(), + self.ip, + self.active_operand_stack_len(), + ) + }; + if let Some(next_trace_id) = next_trace_id && next_trace_id != current_trace_id { self.record_jit_link_handoff(); @@ -557,12 +583,16 @@ impl Vm { } if !has_yielding_call { let ip = self.ip; + let frame_key = self.active_frame_key(); + let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = - self.jit.compiled_trace_for_entry(ip, self.stack.len()); + self.jit + .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { next_trace_id = { let program = &self.program; - self.jit.observe_exit_entry(ip, self.stack.len(), program) + self.jit + .observe_exit_entry(frame_key, ip, stack_depth, program) }; } if let Some(next_trace_id) = next_trace_id diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index 55097972..eccdb4da 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -1,14 +1,16 @@ use std::collections::{HashMap, HashSet}; use crate::debug_info::DebugInfo; +use crate::vm::native::ROOT_FRAME_KEY; use crate::vm::{OpCode, Program}; use super::ir::SsaTrace; use super::liveness::{boxed_load_site_count, boxed_store_site_count}; -use super::recorder::{RecordedTrace, TraceRecordError, record_trace}; +use super::recorder::{RecordedTrace, TraceRecordError, record_trace_with_local_count}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) struct TraceEntryKey { + pub(crate) frame_key: u64, pub(crate) root_ip: usize, pub(crate) stack_depth: usize, } @@ -84,6 +86,7 @@ impl JitNyiReason { #[derive(Clone, Debug, PartialEq)] pub struct JitTrace { pub id: usize, + pub frame_key: u64, pub root_ip: usize, pub entry_stack_depth: usize, pub start_line: Option, @@ -138,6 +141,7 @@ impl JitTrace { #[derive(Clone, Debug, PartialEq, Eq)] pub struct JitAttempt { + pub frame_key: u64, pub root_ip: usize, pub entry_stack_depth: usize, pub line: Option, @@ -179,7 +183,7 @@ pub struct JitNyiDoc { pub struct TraceJitEngine { config: JitConfig, hot_counts: HashMap, - compiled_by_ip: Vec>, + compiled_by_ip: Vec>, blocked_entries: HashSet, loop_headers: Option>, non_yielding_host_imports: Vec, @@ -236,11 +240,12 @@ impl TraceJitEngine { } pub fn observe_hot_ip(&mut self, ip: usize, program: &Program) -> Option { - self.observe_hot_entry(ip, 0, program) + self.observe_hot_entry(ROOT_FRAME_KEY, ip, 0, program) } pub(crate) fn observe_hot_entry( &mut self, + frame_key: u64, ip: usize, stack_depth: usize, program: &Program, @@ -249,6 +254,7 @@ impl TraceJitEngine { return None; } let key = TraceEntryKey { + frame_key, root_ip: ip, stack_depth, }; @@ -284,11 +290,12 @@ impl TraceJitEngine { } pub fn observe_exit_ip(&mut self, ip: usize, program: &Program) -> Option { - self.observe_exit_entry(ip, 0, program) + self.observe_exit_entry(ROOT_FRAME_KEY, ip, 0, program) } pub(crate) fn observe_exit_entry( &mut self, + frame_key: u64, ip: usize, stack_depth: usize, program: &Program, @@ -297,6 +304,7 @@ impl TraceJitEngine { return None; } let key = TraceEntryKey { + frame_key, root_ip: ip, stack_depth, }; @@ -332,11 +340,17 @@ impl TraceJitEngine { } pub fn compiled_trace_for_ip(&self, ip: usize) -> Option { - self.compiled_trace_for_entry(ip, 0) + self.compiled_trace_for_entry(ROOT_FRAME_KEY, ip, 0) } - pub(crate) fn compiled_trace_for_entry(&self, ip: usize, stack_depth: usize) -> Option { + pub(crate) fn compiled_trace_for_entry( + &self, + frame_key: u64, + ip: usize, + stack_depth: usize, + ) -> Option { self.compiled_trace_for_key(TraceEntryKey { + frame_key, root_ip: ip, stack_depth, }) @@ -351,6 +365,7 @@ impl TraceJitEngine { pub(crate) fn block_trace(&mut self, trace_id: usize) { if let Some(trace) = self.traces.get(trace_id) { let key = TraceEntryKey { + frame_key: trace.frame_key, root_ip: trace.root_ip, stack_depth: trace.entry_stack_depth, }; @@ -472,6 +487,7 @@ impl TraceJitEngine { match result { Ok(trace_id) => { self.attempts.push(JitAttempt { + frame_key: key.frame_key, root_ip: key.root_ip, entry_stack_depth: key.stack_depth, line, @@ -482,6 +498,7 @@ impl TraceJitEngine { } Err(reason) => { self.attempts.push(JitAttempt { + frame_key: key.frame_key, root_ip: key.root_ip, entry_stack_depth: key.stack_depth, line, @@ -498,10 +515,25 @@ impl TraceJitEngine { program: &Program, key: TraceEntryKey, ) -> Result { - let recorded = record_trace( + let local_count = if key.frame_key == ROOT_FRAME_KEY { + program.local_count + } else { + program + .callable_prototypes + .get(key.frame_key as usize) + .map(|prototype| prototype.frame_local_count) + .ok_or_else(|| { + JitNyiReason::UnsupportedTrace(format!( + "unknown callable prototype frame key {}", + key.frame_key + )) + })? + }; + let recorded = record_trace_with_local_count( program, key.root_ip, key.stack_depth, + local_count, self.config.max_trace_len, &self.non_yielding_host_imports, ) @@ -518,12 +550,12 @@ impl TraceJitEngine { #[inline(always)] fn compiled_trace_for_key(&self, key: TraceEntryKey) -> Option { - self.compiled_by_ip - .get(key.root_ip)? - .iter() - .find_map(|(stack_depth, trace_id)| { - (*stack_depth == key.stack_depth).then_some(*trace_id) - }) + self.compiled_by_ip.get(key.root_ip)?.iter().find_map( + |(frame_key, stack_depth, trace_id)| { + (*frame_key == key.frame_key && *stack_depth == key.stack_depth) + .then_some(*trace_id) + }, + ) } fn insert_compiled_trace(&mut self, key: TraceEntryKey, trace_id: usize) { @@ -531,13 +563,14 @@ impl TraceJitEngine { self.compiled_by_ip.resize_with(key.root_ip + 1, Vec::new); } let entries = &mut self.compiled_by_ip[key.root_ip]; - if let Some((_, existing_trace_id)) = entries - .iter_mut() - .find(|(stack_depth, _)| *stack_depth == key.stack_depth) + if let Some((_, _, existing_trace_id)) = + entries.iter_mut().find(|(frame_key, stack_depth, _)| { + *frame_key == key.frame_key && *stack_depth == key.stack_depth + }) { *existing_trace_id = trace_id; } else { - entries.push((key.stack_depth, trace_id)); + entries.push((key.frame_key, key.stack_depth, trace_id)); } } @@ -545,10 +578,9 @@ impl TraceJitEngine { let Some(entries) = self.compiled_by_ip.get_mut(key.root_ip) else { return; }; - if let Some(index) = entries - .iter() - .position(|(stack_depth, _)| *stack_depth == key.stack_depth) - { + if let Some(index) = entries.iter().position(|(frame_key, stack_depth, _)| { + *frame_key == key.frame_key && *stack_depth == key.stack_depth + }) { entries.swap_remove(index); } } @@ -589,6 +621,7 @@ fn build_jit_trace( ) -> JitTrace { JitTrace { id, + frame_key: key.frame_key, root_ip: key.root_ip, entry_stack_depth: key.stack_depth, start_line, @@ -703,7 +736,9 @@ fn nyi_reference() -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::{BytecodeBuilder, Value}; + use crate::{ + BytecodeBuilder, CallableKind, CallablePrototype, CallableTarget, ScriptFunction, Value, + }; #[test] fn scan_loop_headers_finds_backward_targets() { @@ -718,4 +753,56 @@ mod tests { assert!(headers[root_ip as usize]); assert!(!headers[branch_ip as usize]); } + + #[test] + fn trace_entry_cache_separates_root_and_script_frames() { + if !native_jit_supported() { + return; + } + let mut bc = BytecodeBuilder::new(); + let root_ip = bc.position(); + bc.ldloc(0); + bc.ldc(0); + bc.add(); + bc.stloc(0); + bc.br(root_ip); + let mut program = Program::new(vec![Value::Int(1)], bc.finish()).with_local_count(4); + program.script_functions.push(ScriptFunction { + entry_ip: root_ip, + end_ip: program.code.len() as u32, + }); + program.callable_prototypes.push(CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_slots: Vec::new(), + self_slot: None, + schema: None, + }); + let mut engine = TraceJitEngine::new(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 64, + }); + + let root_trace = engine + .observe_hot_entry(ROOT_FRAME_KEY, root_ip as usize, 0, &program) + .expect("root trace should compile"); + let frame_trace = engine + .observe_hot_entry(0, root_ip as usize, 0, &program) + .expect("script-frame trace should compile separately"); + assert_ne!(root_trace, frame_trace); + assert_eq!(engine.traces[root_trace].ssa.blocks[0].params.len(), 4); + assert_eq!(engine.traces[frame_trace].ssa.blocks[0].params.len(), 1); + assert_eq!( + engine.compiled_trace_for_entry(ROOT_FRAME_KEY, root_ip as usize, 0), + Some(root_trace) + ); + assert_eq!( + engine.compiled_trace_for_entry(0, root_ip as usize, 0), + Some(frame_trace) + ); + } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 6c0fb87a..88e5b085 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -967,14 +967,30 @@ impl Vm { } #[inline(always)] - fn active_operand_stack_base(&self) -> usize { + pub(super) fn active_operand_stack_base(&self) -> usize { self.execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0) } - fn active_local_base(&self) -> usize { + #[inline(always)] + pub(super) fn active_operand_stack_len(&self) -> usize { + self.stack + .len() + .saturating_sub(self.active_operand_stack_base()) + } + + #[inline(always)] + pub(super) fn active_frame_key(&self) -> u64 { + self.execution_frames + .last() + .and_then(|frame| frame.prototype_id) + .map(u64::from) + .unwrap_or(crate::vm::native::ROOT_FRAME_KEY) + } + + pub(super) fn active_local_base(&self) -> usize { self.execution_frames .last() .map(|frame| frame.local_base) @@ -1964,15 +1980,16 @@ impl Vm { } if allow_jit - && self.call_depth == 0 && self.jit_config().enabled && self.builtin_overrides.is_empty() && !self.drop_contract_events_enabled() { + let frame_key = self.active_frame_key(); + let stack_depth = self.active_operand_stack_len(); let trace_id = { let program = &self.program; self.jit - .observe_hot_entry(self.ip, self.stack.len(), program) + .observe_hot_entry(frame_key, self.ip, stack_depth, program) }; if let Some(trace_id) = trace_id { let outcome = match self.execute_jit_entry(trace_id) { diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index d3d7df66..043a2222 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -305,6 +305,10 @@ pub(crate) fn restore_sparse_exit_state_entry_address() -> usize { pd_vm_native_restore_sparse_exit_state as *const () as usize } +pub(crate) fn restore_active_sparse_exit_state_entry_address() -> usize { + pd_vm_native_restore_active_sparse_exit_state as *const () as usize +} + pub(crate) fn map_has_entry_address() -> usize { pd_vm_native_map_has as *const () as usize } @@ -1028,6 +1032,88 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( }) } +pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( + vm: *mut Vm, + stack_src: *const Value, + stack_len: usize, + dirty_local_indices: *const u32, + dirty_local_values: *const Value, + dirty_local_count: usize, + ip: usize, +) -> i32 { + run_step(vm, "restore_active_sparse_exit_state", |vm| { + if stack_len != 0 && stack_src.is_null() { + return Err(VmError::JitNative( + "native active sparse exit restore received null stack buffer".to_string(), + )); + } + if dirty_local_count != 0 && dirty_local_indices.is_null() { + return Err(VmError::JitNative( + "native active sparse exit restore received null local index buffer".to_string(), + )); + } + if dirty_local_count != 0 && dirty_local_values.is_null() { + return Err(VmError::JitNative( + "native active sparse exit restore received null local value buffer".to_string(), + )); + } + + let local_count = vm + .execution_frames + .last() + .map(|frame| frame.local_count) + .unwrap_or(vm.locals.len()); + let mut validated_indices = Vec::with_capacity(dirty_local_count); + for compact_index in 0..dirty_local_count { + let local_index = unsafe { *dirty_local_indices.add(compact_index) }; + let local_index_usize = usize::try_from(local_index).map_err(|_| { + VmError::JitNative( + "native active sparse exit restore local index out of range".to_string(), + ) + })?; + if local_index_usize >= local_count { + return Err(VmError::JitNative(format!( + "native active sparse exit restore local index {local_index} out of range for {local_count} active locals" + ))); + } + let local_index = u8::try_from(local_index).map_err(|_| { + VmError::JitNative( + "native active sparse exit restore local index exceeds VM local range" + .to_string(), + ) + })?; + if validated_indices.contains(&local_index) { + return Err(VmError::JitNative(format!( + "native active sparse exit restore received duplicate local index {local_index}" + ))); + } + validated_indices.push(local_index); + } + + let stack_base = vm.active_operand_stack_base(); + if stack_base > vm.stack.len() { + return Err(VmError::JitNative(format!( + "native active sparse stack base {stack_base} exceeds stack length {}", + vm.stack.len() + ))); + } + vm.stack.truncate(stack_base); + vm.stack.reserve(stack_len); + for index in 0..stack_len { + let value = unsafe { std::ptr::read(stack_src.add(index)) }; + vm.stack.push(value); + } + + for (compact_index, local_index) in validated_indices.into_iter().enumerate() { + let value = unsafe { std::ptr::read(dirty_local_values.add(compact_index)) }; + vm.store_local_with_drop_contract(local_index, value)?; + } + + vm.jump_to(ip)?; + Ok(STATUS_CONTINUE) + }) +} + pub(crate) extern "C" fn pd_vm_native_map_has(repr_ptr: *mut u8, key: *const Value) -> i32 { if repr_ptr.is_null() || key.is_null() { store_bridge_error(VmError::JitNative( @@ -1592,6 +1678,38 @@ mod tests { Value::Int(50), ] ); + + let sparse_stack = [Value::Int(77), Value::Int(88)]; + let dirty_indices = [1_u32]; + let dirty_values = [Value::Int(44)]; + assert_eq!( + pd_vm_native_restore_active_sparse_exit_state( + &mut vm, + sparse_stack.as_ptr(), + sparse_stack.len(), + dirty_indices.as_ptr(), + dirty_values.as_ptr(), + dirty_indices.len(), + 0, + ), + STATUS_CONTINUE + ); + std::mem::forget(sparse_stack); + std::mem::forget(dirty_values); + assert_eq!( + vm.stack, + vec![Value::Int(10), Value::Int(77), Value::Int(88)] + ); + assert_eq!( + vm.locals, + vec![ + Value::Int(1), + Value::Int(2), + Value::Int(30), + Value::Int(44), + Value::Int(50), + ] + ); } #[test] diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index cfe93916..cbfc4709 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -20,12 +20,13 @@ pub(crate) use bridge::{ 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, regex_match_entry_address, regex_replace_entry_address, - restore_active_exit_state_entry_address, restore_exit_state_entry_address, - restore_sparse_exit_state_entry_address, shared_array_from_buffer_entry_address, - shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, - store_bridge_error, string_contains_entry_address, string_lower_ascii_entry_address, - string_replace_literal_entry_address, string_split_literal_entry_address, take_bridge_error, - to_string_entry_address, type_of_entry_address, value_eq_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, + shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, + shared_string_from_buffer_entry_address, store_bridge_error, string_contains_entry_address, + string_lower_ascii_entry_address, string_replace_literal_entry_address, + string_split_literal_entry_address, take_bridge_error, to_string_entry_address, + type_of_entry_address, value_eq_entry_address, value_len_entry_address, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; #[cfg(feature = "cranelift-jit")] @@ -48,7 +49,7 @@ 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 = 2; +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 3; #[cfg(feature = "cranelift-jit")] pub(crate) fn selected_codegen_backend() -> &'static str { diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index 7649f2a5..856700bb 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -4326,7 +4326,7 @@ fn trace_jit_supports_float_comparisons_in_ssa() { } #[test] -fn trace_jit_skips_nested_loop_with_one_live_operand() { +fn trace_jit_executes_nested_loop_with_one_live_caller_operand() { if !native_jit_supported() { return; } @@ -4353,15 +4353,17 @@ fn trace_jit_skips_nested_loop_with_one_live_operand() { let status = vm.run().expect("live entry stack program should run"); assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); + let snapshot = vm.jit_snapshot(); + assert!(vm.jit_native_exec_count() > 0, "{}", vm.dump_jit_info()); assert!( - vm.jit_snapshot().traces.is_empty(), + snapshot.traces.iter().any(|trace| trace.frame_key == 0), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_skips_nested_loop_with_two_live_operands() { +fn trace_jit_executes_nested_loop_with_two_live_caller_operands() { if !native_jit_supported() { return; } @@ -4388,15 +4390,17 @@ fn trace_jit_skips_nested_loop_with_two_live_operands() { let status = vm.run().expect("depth-two entry stack program should run"); assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(48)]); + let snapshot = vm.jit_snapshot(); + assert!(vm.jit_native_exec_count() > 0, "{}", vm.dump_jit_info()); assert!( - vm.jit_snapshot().traces.is_empty(), + snapshot.traces.iter().any(|trace| trace.frame_key == 0), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_skips_nested_loop_with_heap_live_operand() { +fn trace_jit_executes_nested_loop_with_heap_caller_operand() { if !native_jit_supported() { return; } @@ -4430,15 +4434,17 @@ fn trace_jit_skips_nested_loop_with_heap_live_operand() { Value::Int(45), ])] ); + let snapshot = vm.jit_snapshot(); + assert!(vm.jit_native_exec_count() > 0, "{}", vm.dump_jit_info()); assert!( - vm.jit_snapshot().traces.is_empty(), + snapshot.traces.iter().any(|trace| trace.frame_key == 0), "{}", vm.dump_jit_info() ); } #[test] -fn trace_jit_skips_nested_loop_after_reset() { +fn trace_jit_reuses_nested_frame_trace_after_reset() { if !native_jit_supported() { return; } @@ -4465,12 +4471,12 @@ fn trace_jit_skips_nested_loop_after_reset() { assert_eq!(vm.run().expect("first run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); let first_native_exec_count = vm.jit_native_exec_count(); - assert_eq!(first_native_exec_count, 0, "{}", vm.dump_jit_info()); + assert!(first_native_exec_count > 0, "{}", vm.dump_jit_info()); vm.reset_for_reuse(); assert_eq!(vm.run().expect("second run"), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(145)]); - assert_eq!(vm.jit_native_exec_count(), first_native_exec_count); + assert!(vm.jit_native_exec_count() > first_native_exec_count); } #[test] @@ -4794,3 +4800,42 @@ fn trace_jit_specializes_regex_builtins_without_call_boundary() { assert_eq!(vm.regex_cache_compile_count(), 2); assert!(vm.regex_cache_hit_count() >= 14); } + +#[test] +fn trace_jit_executes_hot_loop_inside_script_callable_frame() { + if !native_jit_supported() { + return; + } + let source = r#" + fn sum_to(limit: int) -> int { + let mut i = 0; + let mut total = 0; + while i < limit { + total = total + i; + i = i + 1; + } + total + } + sum_to(100); + "#; + let compiled = compile_source(source).expect("nested-frame 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("nested-frame trace should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(4_950)]); + assert!(vm.jit_native_exec_count() > 0); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.frame_key == 0), + "expected a prototype-keyed trace: {}", + vm.dump_jit_info() + ); +} From 062cff64e50aa9b2a6ed8369765918aab5f1b298 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 20:39:44 +0800 Subject: [PATCH 08/18] fix(vm): complete native callable frame dispatch --- build.rs | 16 +- docs/callable-runtime.md | 6 +- pd-vm-nostd/src/program.rs | 4 +- pd-vm-nostd/src/vm.rs | 48 +++- pd-vm-nostd/tests/embedded_vmbc.rs | 32 ++- .../examples/rss-strings-regex-example.rss | 12 +- pd-vm-wasm/src/lib.rs | 15 +- src/assembler.rs | 24 -- src/builtins/runtime/core.rs | 10 +- src/builtins/runtime/mod.rs | 16 ++ src/bytecode.rs | 4 - src/compiler/codegen.rs | 38 ++- src/vm/aot/compile.rs | 144 +--------- src/vm/aot/ir.rs | 86 ++---- src/vm/aot/ssa.rs | 36 +-- src/vm/jit/ir.rs | 19 +- src/vm/jit/native/lower.rs | 250 +++++++++++++----- src/vm/jit/recorder.rs | 111 +++++--- src/vm/jit/runtime.rs | 87 +++++- src/vm/jit/trace.rs | 31 ++- src/vm/mod.rs | 58 ++-- src/vm/native/bridge.rs | 65 +---- src/vm/native/codegen.rs | 15 -- src/vm/native/mod.rs | 14 +- src/vmbc.rs | 23 +- tests/jit/jit_tests.rs | 250 +++++++++++++++++- 26 files changed, 869 insertions(+), 545 deletions(-) diff --git a/build.rs b/build.rs index 1955b5fa..245bb434 100644 --- a/build.rs +++ b/build.rs @@ -494,7 +494,7 @@ fn render_builtin_catalog( .and_then(|value| value.checked_add(1)) .expect("builtin call base should fit in u16"); assert!( - builtin_call_base >= 14, + builtin_call_base >= 15, "builtin call base must leave room for reserved special builtins" ); @@ -667,6 +667,11 @@ fn render_builtin_catalog( " (BUILTIN_CALL_BASE - 13, BuiltinFunction::MapIterClose)," ) .unwrap(); + writeln!( + &mut out, + " (BUILTIN_CALL_BASE - 14, BuiltinFunction::BindCallable)," + ) + .unwrap(); writeln!(&mut out, "];").unwrap(); writeln!(&mut out).unwrap(); @@ -860,6 +865,11 @@ fn render_builtin_catalog( " BuiltinFunction::MapIterClose => BUILTIN_CALL_BASE - 13," ) .unwrap(); + writeln!( + &mut out, + " BuiltinFunction::BindCallable => BUILTIN_CALL_BASE - 14," + ) + .unwrap(); writeln!( &mut out, " _ => BUILTIN_CALL_BASE + self as u16," @@ -1492,6 +1502,7 @@ fn appended_builtin_order() -> &'static [&'static str] { "__map_iter_take_key", "__map_iter_take_value", "__map_iter_close", + "__bind_callable", ] } @@ -1518,7 +1529,7 @@ fn required_language_builtin_stubs() -> &'static [&'static str] { } fn required_internal_builtin_stubs() -> &'static [&'static str] { - &["__format_template", "__to_string"] + &["__format_template", "__to_string", "__bind_callable"] } fn is_language_builtin_stub_name(name: &str) -> bool { @@ -1662,6 +1673,7 @@ fn main_range_builtin_variants(builtin_variant_order: &[String]) -> Vec | "MapIterTakeKey" | "MapIterTakeValue" | "MapIterClose" + | "BindCallable" ) }) .cloned() diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 866098ec..8229eb37 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -6,7 +6,7 @@ RustScript bytecode format version 9 introduces runtime script call frames and f - `call ` remains the direct host/builtin operation. - `callvalue ` consumes a stack segment in `callee, arg0, ..., argN` order. -- `makecallable ` consumes the capture values declared by the prototype and pushes a callable value. +- 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 v9 is a hard format boundary. Decoders reject older versions. The stream includes script-function entry ranges, callable prototypes, function regions, and root callable bindings. PDRC recordings and AOT artifacts use their corresponding bumped format/ABI versions and include callable metadata in cache identity. @@ -36,8 +36,8 @@ PDRC recordings preserve full execution-frame metadata. Callable environments us ## Optimized backends -Whole-program AOT treats callable creation and invocation as verified interpreter boundaries: live stack/local state is materialized and execution resumes at the boundary opcode. Trace JIT records the same operations as explicit side exits. Script-frame bodies currently execute through the frame-aware interpreter after that boundary; the backend never treats `callvalue` as an ordinary host call. +Whole-program AOT and Trace JIT use the same builtin call path for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. ## Embedded runtime -`pd-vm-nostd` decodes the same VMBC v9 callable metadata and executes `makecallable`, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`. +`pd-vm-nostd` decodes the same VMBC v9 callable metadata and executes callable binding, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`. diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index 2bfd38d9..b434a2dc 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -189,13 +189,12 @@ pub enum OpCode { Not = 0x17, Lshr = 0x18, CallValue = 0x19, - MakeCallable = 0x1a, } impl OpCode { pub const fn operand_len(self) -> usize { match self { - Self::Ldc | Self::Br | Self::Brfalse | Self::MakeCallable => 4, + Self::Ldc | Self::Br | Self::Brfalse => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, _ => 0, @@ -234,7 +233,6 @@ impl TryFrom for OpCode { 0x17 => Ok(Self::Not), 0x18 => Ok(Self::Lshr), 0x19 => Ok(Self::CallValue), - 0x1a => Ok(Self::MakeCallable), _ => Err(()), } } diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index e30ebde0..4d3fed30 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -125,25 +125,22 @@ impl Vm { .ok_or(VmError::InvalidLocal(index)) } - fn make_callable(&mut self, prototype_id: u32) -> VmResult<()> { + fn bind_callable_value(&self, prototype_id: u32, captures: Vec) -> VmResult { let prototype = self .program .callable_prototypes() .get(prototype_id as usize) .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; - let capture_count = prototype.capture_slots.len(); - if self.stack.len() < capture_count { - return Err(VmError::StackUnderflow); + if captures.len() != prototype.capture_slots.len() { + return Err(VmError::InvalidCallablePrototype(prototype_id)); } - let captures = self.stack.split_off(self.stack.len() - capture_count); let env: CallableEnvironment = Rc::new(RefCell::new(captures)); - self.stack.push(Value::Callable(Rc::new(CallableValue { + Ok(Value::Callable(Rc::new(CallableValue { program_instance: self.program_instance, prototype_id, kind: prototype.kind, env: Some(env), - }))); - Ok(()) + }))) } fn call_value(&mut self, argc: u8) -> VmResult<()> { @@ -387,10 +384,7 @@ impl Vm { let arity = self.read_u8()?; self.call_value(arity)?; } - OpCode::MakeCallable => { - let prototype_id = self.read_u32()?; - self.make_callable(prototype_id)?; - } + OpCode::Shl => { let rhs = self.pop_shift()?; let lhs = self.pop_int()?; @@ -533,6 +527,7 @@ impl Vm { const ARRAY_PUSH: u16 = BUILTIN_BASE + 4; const MAP_NEW: u16 = BUILTIN_BASE + 5; const SET: u16 = BUILTIN_BASE + 8; + const BIND_CALLABLE: u16 = BUILTIN_BASE - 14; Some(match index { ARRAY_NEW => { @@ -590,6 +585,35 @@ impl Vm { self.stack.push(Value::Map(entries)); Ok(()) } + BIND_CALLABLE => { + if let Err(error) = self.require_builtin_arity("__bind_callable", arity, 2) { + return Some(Err(error)); + } + let start = match self.stack.len().checked_sub(2) { + Some(start) => start, + None => return Some(Err(VmError::StackUnderflow)), + }; + let prototype_id = match self.stack.get(start) { + Some(Value::Int(value)) => match u32::try_from(*value) { + Ok(value) => value, + Err(_) => { + return Some(Err(VmError::InvalidCallablePrototype(u32::MAX))); + } + }, + _ => return Some(Err(VmError::TypeMismatch("callable prototype id"))), + }; + let captures = match self.stack.get(start + 1).cloned() { + Some(Value::Array(values)) => values.as_ref().clone(), + _ => return Some(Err(VmError::TypeMismatch("callable captures"))), + }; + let callable = match self.bind_callable_value(prototype_id, captures) { + Ok(value) => value, + Err(error) => return Some(Err(error)), + }; + self.stack.truncate(start); + self.stack.push(callable); + Ok(()) + } _ => return None, }) } diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 632fba82..8737080e 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -1,8 +1,11 @@ -use pd_vm_nostd::{Value as EmbeddedValue, WireError, decode_program}; +use pd_vm_nostd::{ + OpCode as EmbeddedOpCode, Value as EmbeddedValue, Vm as EmbeddedVm, + VmStatus as EmbeddedVmStatus, WireError, decode_program, +}; use vm::compiler::TypeSchema; use vm::{ HostImport, OpCode, Program, ReplLocalBinding, Value, ValueType, compile_source, - compile_source_for_repl_with_locals, encode_program, + compile_source_for_repl, compile_source_for_repl_with_locals, encode_program, }; fn encoded_scalar_program() -> Vec { @@ -104,3 +107,28 @@ fn embedded_decoder_rejects_invalid_magic() { Err(WireError::InvalidMagic(_)) )); } + +#[test] +fn embedded_runtime_executes_compiler_generated_capturing_callable() { + let compiled = compile_source_for_repl( + r#" + let base = 40; + let add = |value| value + base; + add(2); + "#, + ) + .expect("capturing callable source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("compiler output should encode"); + let program = decode_program(&bytes).expect("embedded decoder should accept callable VMBC"); + let mut runtime = EmbeddedVm::new(program); + + assert_eq!(runtime.run(), Ok(EmbeddedVmStatus::Halted)); + assert_eq!(runtime.stack(), &[EmbeddedValue::Int(42)]); +} + +#[test] +fn removed_callable_creation_opcode_is_rejected() { + assert!(OpCode::try_from(0x1a).is_err()); + assert!(EmbeddedOpCode::try_from(0x1a).is_err()); +} diff --git a/pd-vm-wasm/examples/rss-strings-regex-example.rss b/pd-vm-wasm/examples/rss-strings-regex-example.rss index a5cd5661..223394af 100644 --- a/pd-vm-wasm/examples/rss-strings-regex-example.rss +++ b/pd-vm-wasm/examples/rss-strings-regex-example.rss @@ -26,12 +26,20 @@ fn parse_log_line(line) { entry } +fn is_some_string_equal(actual: string?, expected: string) -> bool { + if type(actual) == "string" => { + actual == expected + } else => { + false + } +} + let raw_log = "[INFO] feed/world - 18ms - cache hit\n[WARN] feed/markets - 241ms - cache miss\n[ERROR] feed/culture - 502ms - bad payload id=77"; let normalized_log: string = string::trim(raw_log); assert(string::non_empty(normalized_log)); assert(re::match("(?m)^\\[INFO\\]", normalized_log)); -assert(re::find("feed/[a-z]+", normalized_log) == "feed/world"); +assert(is_some_string_equal(re::find("feed/[a-z]+", normalized_log), "feed/world")); let lines = re::split("\\n+", normalized_log); let first_entry = parse_log_line(lines[0]); @@ -58,7 +66,7 @@ let rewritten_warn: string = string::replace(warn_message, "cache", "edge-cache" let rewritten_route = re::replace("^feed/", warn_route, "rss::"); let error_words = re::split("\\s+", &error_message); let error_word_count = error_words.length; -let id_groups = re::captures("id=([0-9]+)", error_message); +let id_groups: [string] = re::captures("id=([0-9]+)", error_message); let error_id: string = if id_groups.length == 2 => { id_groups[1] } else => { diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 9c0be4ea..4444447f 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -1740,7 +1740,7 @@ mod runtime_tests { fn lint_accepts_embedded_parse_stdlib_imports() { let source = r#" use stdlib::rss::parse as parse; - let value = parse::try_parse_int_base("ff", 16); + let value: int = parse::parse_int_base_or("ff", 16, 0); value == 255; "#; let report = lint_source_with_flavor(source, SourceFlavor::RustScript); @@ -1752,15 +1752,7 @@ mod runtime_tests { "expected embedded parse stdlib lint to emit warnings only, got {:?}", report.diagnostics ); - assert!( - report.diagnostics.iter().any(|diagnostic| { - diagnostic - .message - .contains("compiler could not determine the type of local 'value'") - }), - "expected an unknown warning for 'value', got {:?}", - report.diagnostics - ); + assert!(report.diagnostics.is_empty()); } #[test] @@ -1787,7 +1779,8 @@ mod runtime_tests { use json; let matched = re::match("(?i)^rss$", "RSS"); let payload = json::encode({ ok: matched }); - let decoded = json::decode(payload); + struct Payload { ok: bool } + let decoded = json::decode::(payload); let ok = decoded.ok.copy(); if ok { print(1); diff --git a/src/assembler.rs b/src/assembler.rs index 04cdbf02..3fd5572c 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -304,11 +304,6 @@ impl Assembler { self.emit_u8(argc); } - pub fn make_callable(&mut self, prototype_id: u32) { - self.emit_opcode(OpCode::MakeCallable); - self.emit_u32(prototype_id); - } - pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); } @@ -457,11 +452,6 @@ impl BytecodeBuilder { self.emit_u8(argc); } - pub fn make_callable(&mut self, prototype_id: u32) { - self.emit_opcode(OpCode::MakeCallable); - self.emit_u32(prototype_id); - } - pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); } @@ -758,13 +748,6 @@ 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::MakeCallable => { - let prototype_id = parse_u32( - next_token(&mut parts, line_no, "callable prototype id")?, - line_no, - )?; - assembler.make_callable(prototype_id); - } OpCode::Shl => assembler.shl(), OpCode::Shr => assembler.shr(), OpCode::Lshr => assembler.lshr(), @@ -823,13 +806,6 @@ fn parse_u16(token: &str, line_no: usize) -> Result { }) } -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 { line: line_no, diff --git a/src/builtins/runtime/core.rs b/src/builtins/runtime/core.rs index d47eb921..04f76f5c 100644 --- a/src/builtins/runtime/core.rs +++ b/src/builtins/runtime/core.rs @@ -9,6 +9,13 @@ use pd_host_function::pd_host_function; use rt_format::{Format, FormatArgument, NoNamedArguments, ParsedFormat, Specifier}; use std::sync::Arc; +/// Bind a callable prototype to an owned capture array. +#[allow(dead_code)] +#[pd_host_function(name = "__bind_callable")] +fn builtin_bind_callable_metadata(_prototype_id: i64, captures: VmArrayRef<'_>) -> VmArray { + captures.to_vec() +} + /// Return the length of a string, array, or map. #[pd_host_function(name = "len")] pub(super) fn builtin_len_string_impl(text: VmStringRef<'_>) -> i64 { @@ -883,13 +890,14 @@ mod tests { use std::sync::Arc; #[test] - fn map_iterator_builtins_have_unique_reserved_call_indices() { + fn internal_builtins_have_unique_reserved_call_indices() { let reserved = [ (BuiltinFunction::MapIterInit, BUILTIN_CALL_BASE - 9), (BuiltinFunction::MapIterNext, BUILTIN_CALL_BASE - 10), (BuiltinFunction::MapIterTakeKey, BUILTIN_CALL_BASE - 11), (BuiltinFunction::MapIterTakeValue, BUILTIN_CALL_BASE - 12), (BuiltinFunction::MapIterClose, BUILTIN_CALL_BASE - 13), + (BuiltinFunction::BindCallable, BUILTIN_CALL_BASE - 14), ]; for (builtin, index) in reserved { assert_eq!(builtin.call_index(), index); diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 96f03d35..214806ef 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -74,6 +74,22 @@ pub(crate) fn execute_builtin_call( map_iter::take_value(vm, args).map(BuiltinCallOutcome::Return) } BuiltinFunction::MapIterClose => map_iter::close(vm, args).map(BuiltinCallOutcome::Return), + BuiltinFunction::BindCallable => { + let prototype_id = match args.first() { + Some(Value::Int(value)) => u32::try_from(*value) + .map_err(|_| crate::vm::VmError::InvalidCallablePrototype(u32::MAX))?, + _ => return Err(crate::vm::VmError::TypeMismatch("callable prototype id")), + }; + let captures = std::mem::replace( + args.get_mut(1) + .ok_or(crate::vm::VmError::TypeMismatch("callable captures"))?, + Value::Null, + ) + .into_owned_array() + .map_err(|_| crate::vm::VmError::TypeMismatch("callable captures"))?; + vm.bind_callable_value(prototype_id, captures) + .map(|value| BuiltinCallOutcome::Return(return_one(value))) + } BuiltinFunction::StringContains => core::builtin_string_contains(args) .map(IntoBuiltinCallOutcome::into_builtin_call_outcome), BuiltinFunction::StringReplaceLiteral => core::builtin_string_replace_literal(args) diff --git a/src/bytecode.rs b/src/bytecode.rs index 4f0c2dde..fad20ae9 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -772,7 +772,6 @@ pub enum OpCode { Not = 0x17, Lshr = 0x18, CallValue = 0x19, - MakeCallable = 0x1a, } impl TryFrom for OpCode { @@ -806,7 +805,6 @@ 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::MakeCallable as u8 => Ok(Self::MakeCallable), _ => Err(()), } } @@ -835,7 +833,6 @@ impl OpCode { | Self::Not | Self::Lshr => 0, Self::Ldc | Self::Br | Self::Brfalse => 4, - Self::MakeCallable => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, } @@ -869,7 +866,6 @@ impl OpCode { OpCode::Not => "not", OpCode::Lshr => "lshr", Self::CallValue => "callvalue", - Self::MakeCallable => "makecallable", } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 70c63294..2fbaf9fd 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -1033,9 +1033,6 @@ impl Compiler { if function_impl.capture_copies.is_empty() { return Ok(()); } - for (source_index, _) in &function_impl.capture_copies { - self.emit_copy_ldloc(*source_index)?; - } let prototype_id = *self .function_prototype_ids .get(&index) @@ -1044,7 +1041,13 @@ impl Compiler { .function_slots .get(&index) .ok_or(CompileError::CallableUsedAsValue)?; - self.assembler.make_callable(prototype_id); + self.emit_bind_callable( + prototype_id, + function_impl + .capture_copies + .iter() + .map(|(source, _)| *source), + )?; self.emit_stloc(slot)?; Ok(()) } @@ -1442,9 +1445,6 @@ impl Compiler { closure: &ClosureExpr, binding_slot: Option, ) -> Result { - for (source_slot, _) in &closure.capture_copies { - self.emit_copy_ldloc(*source_slot)?; - } let prototype_id = self.callable_prototypes.len() as u32; self.callable_prototypes.push(CallablePrototype { kind: CallableKind::Closure, @@ -1467,10 +1467,32 @@ impl Compiler { schema: None, }); self.pending_closures.push((prototype_id, closure.clone())); - self.assembler.make_callable(prototype_id); + self.emit_bind_callable( + prototype_id, + closure.capture_copies.iter().map(|(source, _)| *source), + )?; Ok(prototype_id) } + fn emit_bind_callable( + &mut self, + prototype_id: u32, + capture_slots: impl IntoIterator, + ) -> Result<(), CompileError> { + self.assembler + .push_const(Value::Int(i64::from(prototype_id))); + self.assembler + .call(BuiltinFunction::ArrayNew.call_index(), 0); + for source_slot in capture_slots { + self.emit_copy_ldloc(source_slot)?; + self.assembler + .call(BuiltinFunction::ArrayPush.call_index(), 2); + } + self.assembler + .call(BuiltinFunction::BindCallable.call_index(), 2); + Ok(()) + } + fn compile_direct_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { for arg in args { self.compile_scalar_expr(arg)?; diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index f3ffc655..34bf2a50 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -24,12 +24,12 @@ use crate::vm::native::{ 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, make_callable_entry_address, make_callable_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, value_eq_entry_address, value_eq_signature, - value_slot_signature, write_heap_value_to_slot_entry_address, zero_bytes_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, + value_eq_entry_address, value_eq_signature, value_slot_signature, + write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; use crate::vm::{Program, Value, Vm, VmError, VmResult}; #[cfg(feature = "cranelift-jit")] @@ -226,7 +226,6 @@ struct AotDeoptHelperRefs { frame_state_ref: cranelift_codegen::ir::SigRef, enter_call_value_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, - make_callable_ref: cranelift_codegen::ir::SigRef, clone_value_ref: cranelift_codegen::ir::SigRef, value_eq_ref: cranelift_codegen::ir::SigRef, init_null_slot_ref: cranelift_codegen::ir::SigRef, @@ -244,7 +243,6 @@ struct AotDeoptHelperAddrs { frame_state: usize, enter_call_value: usize, leave_frame: usize, - make_callable: usize, clone_value: usize, value_eq: usize, init_null_slot: usize, @@ -260,14 +258,12 @@ struct AotDeoptHelperAddrs { enum AotTempValueSlotKey { Output(AotSsaValueId), MutationArg(AotSsaValueId, u8), - CallableArg(AotSsaValueId, u16), } #[cfg(feature = "cranelift-jit")] struct AotOwnedValueTemps { ordered: Vec, slots: HashMap, - callable_pointer_buffers: HashMap, } #[cfg(feature = "cranelift-jit")] @@ -398,7 +394,6 @@ fn compile_ssa( 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 leave_frame_sig = leave_frame_signature(pointer_type, call_conv); - let make_callable_sig = make_callable_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); let copy_bytes_sig = copy_bytes_signature(pointer_type, call_conv); @@ -429,7 +424,6 @@ fn compile_ssa( frame_state: frame_state_entry_address(), enter_call_value: enter_call_value_entry_address(), leave_frame: leave_frame_entry_address(), - make_callable: make_callable_entry_address(), clone_value: clone_value_to_slot_entry_address(), value_eq: value_eq_entry_address(), init_null_slot: init_null_value_slot_entry_address(), @@ -487,7 +481,6 @@ fn compile_ssa( frame_state_ref: b.import_signature(frame_state_sig), enter_call_value_ref: b.import_signature(enter_call_value_sig), leave_frame_ref: b.import_signature(leave_frame_sig), - make_callable_ref: b.import_signature(make_callable_sig), clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), init_null_slot_ref: b.import_signature(value_slot_sig.clone()), @@ -943,18 +936,7 @@ fn lower_aot_ssa_inst( )?; out } - AotSsaInstKind::MakeCallable { - prototype_id, - captures, - } => aot_lower_make_callable( - b, - ctx, - inst.output.id, - *prototype_id, - captures, - values, - value_reprs, - )?, + AotSsaInstKind::StringLen { text } => aot_lower_string_len( b, ctx, @@ -1343,75 +1325,6 @@ fn aot_ensure_boxed_value_addr( Ok(addr) } -#[cfg(feature = "cranelift-jit")] -fn aot_lower_make_callable( - b: &mut FunctionBuilder, - ctx: AotLowerCtx<'_>, - output_id: AotSsaValueId, - prototype_id: u32, - captures: &[AotSsaValueId], - values: &HashMap, - value_reprs: &HashMap, -) -> Result { - let out = owned_value_temp_slot_addr( - b, - ctx.pointer_type, - ctx.owned_value_temps, - AotTempValueSlotKey::Output(output_id), - )?; - clear_owned_value_temp_slot(b, ctx.pointer_type, ctx.helper_refs, ctx.helper_addrs, out)?; - let captures_ptr = if captures.is_empty() { - b.ins().iconst(ctx.pointer_type, 0) - } else { - let slot = *ctx - .owned_value_temps - .callable_pointer_buffers - .get(&output_id) - .ok_or_else(|| { - AotCompileError::Codegen("AOT callable pointer buffer missing".to_string()) - })?; - let ptr = b.ins().stack_addr(ctx.pointer_type, slot, 0); - for (index, capture) in captures.iter().copied().enumerate() { - let capture_addr = aot_ensure_boxed_value_addr( - b, - ctx, - values, - value_reprs, - capture, - AotTempValueSlotKey::CallableArg( - output_id, - u16::try_from(index).map_err(|_| { - AotCompileError::Codegen( - "AOT callable capture index out of range".to_string(), - ) - })?, - ), - )?; - let offset = i32::try_from(index * std::mem::size_of::()).map_err(|_| { - AotCompileError::Codegen("AOT callable pointer offset out of range".to_string()) - })?; - b.ins().store(MemFlags::new(), capture_addr, ptr, offset); - } - ptr - }; - let prototype_id = b.ins().iconst(types::I64, i64::from(prototype_id)); - let capture_count = b.ins().iconst( - types::I64, - i64::try_from(captures.len()).map_err(|_| { - AotCompileError::Codegen("AOT callable capture count out of range".to_string()) - })?, - ); - call_status_helper( - b, - ctx.exit_block, - ctx.pointer_type, - ctx.helper_refs.make_callable_ref, - ctx.helper_addrs.make_callable, - &[ctx.vm_ptr, prototype_id, captures_ptr, capture_count, out], - )?; - Ok(out) -} - #[cfg(feature = "cranelift-jit")] #[allow(clippy::too_many_arguments)] fn lower_aot_ssa_terminator( @@ -1669,7 +1582,6 @@ fn aot_inst_requires_owned_value_slot(kind: &AotSsaInstKind) -> bool { matches!( kind, AotSsaInstKind::CloneTagged { .. } - | AotSsaInstKind::MakeCallable { .. } | AotSsaInstKind::StringSlice { .. } | AotSsaInstKind::BytesSlice { .. } | AotSsaInstKind::StringGet { .. } @@ -1691,7 +1603,7 @@ fn allocate_owned_value_temps( ) -> Result { let mut ordered = Vec::new(); let mut slots = HashMap::new(); - let mut callable_pointer_buffers = HashMap::new(); + for block in &ssa.blocks { for inst in &block.insts { if aot_inst_requires_owned_value_slot(&inst.kind) { @@ -1713,48 +1625,10 @@ fn allocate_owned_value_temps( ); } } - if let AotSsaInstKind::MakeCallable { captures, .. } = &inst.kind { - for (index, _) in captures.iter().enumerate() { - let arg_slot = aot_create_value_stack_slot(b, value_size)?; - ordered.push(arg_slot); - slots.insert( - AotTempValueSlotKey::CallableArg( - inst.output.id, - u16::try_from(index).map_err(|_| { - AotCompileError::Codegen( - "AOT callable capture index out of range".to_string(), - ) - })?, - ), - arg_slot, - ); - } - if !captures.is_empty() { - let pointer_bytes = captures - .len() - .checked_mul(std::mem::size_of::()) - .and_then(|bytes| u32::try_from(bytes).ok()) - .ok_or_else(|| { - AotCompileError::Codegen( - "AOT callable pointer buffer size out of range".to_string(), - ) - })?; - let pointer_slot = b.create_sized_stack_slot(StackSlotData::new( - StackSlotKind::ExplicitSlot, - pointer_bytes, - std::mem::align_of::().trailing_zeros() as u8, - )); - callable_pointer_buffers.insert(inst.output.id, pointer_slot); - } - } } } } - Ok(AotOwnedValueTemps { - ordered, - slots, - callable_pointer_buffers, - }) + Ok(AotOwnedValueTemps { ordered, slots }) } #[cfg(feature = "cranelift-jit")] diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index a987e33c..fcf67d69 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -48,9 +48,7 @@ pub(crate) enum AotBytesCodecKind { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AotInstruction { Nop, - Ldc { - const_index: u32, - }, + Ldc { const_index: u32 }, Add, IAdd, FAdd, @@ -92,19 +90,9 @@ pub(crate) enum AotInstruction { FCgt, Pop, Dup, - Ldloc { - index: u8, - }, - LdlocOwned { - index: u8, - }, - Stloc { - index: u8, - }, - MakeCallable { - prototype_id: u32, - capture_count: u8, - }, + Ldloc { index: u8 }, + LdlocOwned { index: u8 }, + Stloc { index: u8 }, Call(AotCall), } @@ -329,42 +317,7 @@ fn lower_block( } apply_call_provenance(&mut provenance, argc, index); } - OpCode::MakeCallable => { - let prototype_id = - read_u32(code, ip + 1).ok_or(AotLowerError::InvalidImmediate { - ip, - opcode, - kind: "callable prototype id", - })?; - let prototype = program - .callable_prototypes - .get(prototype_id as usize) - .ok_or(AotLowerError::InvalidImmediate { - ip, - opcode, - kind: "callable prototype id", - })?; - let capture_count = u8::try_from(prototype.capture_slots.len()).map_err(|_| { - AotLowerError::InvalidImmediate { - ip, - opcode, - kind: "callable capture count", - } - })?; - instructions.push(AotInstruction::MakeCallable { - prototype_id, - capture_count, - }); - if let Some(stack) = provenance.as_mut() { - let capture_count = capture_count as usize; - if stack.len() < capture_count { - provenance = None; - } else { - stack.truncate(stack.len() - capture_count); - stack.push(AotStackProvenance::Derived); - } - } - } + OpCode::CallValue => { return Err(AotLowerError::InvalidImmediate { ip, @@ -553,7 +506,7 @@ fn is_explicit_terminal_opcode( && ip == *call_ip && next_ip == *resume_ip), AotBlockTerminal::InterpreterExit { exit_ip } => { - Ok(matches!(opcode, OpCode::CallValue | OpCode::MakeCallable) && ip == *exit_ip) + Ok(opcode == OpCode::CallValue && ip == *exit_ip) } AotBlockTerminal::Stop => Ok(false), } @@ -967,7 +920,7 @@ mod tests { } #[test] - fn aot_ir_keeps_make_callable_and_call_value_explicit() { + fn aot_ir_uses_existing_call_opcode_for_callable_binding() { let compiled = crate::compile_source_for_repl( r#" let delta = 1; @@ -979,20 +932,19 @@ mod tests { let lowered = lower_program(&compiled.program).expect("lowering should succeed"); assert_eq!(lowered.regions.len(), 2); - assert!( - lowered - .blocks - .iter() - .any( - |block| block.instructions.iter().any(|instruction| matches!( - instruction, - AotInstruction::MakeCallable { - prototype_id: 0, - capture_count: 1 - } - )) + assert!(lowered.blocks.iter().any(|block| { + block.instructions.iter().any(|instruction| { + matches!( + instruction, + AotInstruction::Call(AotCall { + index, + argc: 2, + dispatch: AotCallDispatch::Builtin, + .. + }) if *index == BuiltinFunction::BindCallable.call_index() ) - ); + }) + })); assert!( lowered .blocks diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index e7b91070..7df34dee 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -113,10 +113,7 @@ pub(crate) enum AotSsaInstKind { CloneTagged { input: AotSsaValueId, }, - MakeCallable { - prototype_id: u32, - captures: Vec, - }, + StringLen { text: AotSsaValueId, }, @@ -305,7 +302,6 @@ impl AotSsaInstKind { | Self::BoolConst(_) | Self::ConstSlot { .. } => Vec::new(), Self::CloneTagged { input } => vec![*input], - Self::MakeCallable { captures, .. } => captures.clone(), Self::StringLen { text } => vec![*text], Self::BytesLen { bytes } => vec![*bytes], Self::StringSlice { @@ -2034,29 +2030,7 @@ fn apply_direct_instruction( _ => None, }) } - AotInstruction::MakeCallable { - prototype_id, - capture_count, - } => { - let capture_count = usize::from(*capture_count); - if frame.stack.len() < capture_count { - return Err(AotSsaBuildError::StackUnderflow { - ip, - instruction: "makecallable", - }); - } - let captures = frame.stack.split_off(frame.stack.len() - capture_count); - let value = emitter.emit( - ip, - AotSsaInstKind::MakeCallable { - prototype_id: *prototype_id, - captures: captures.iter().map(|capture| capture.value.id).collect(), - }, - AotSsaValueRepr::Tagged, - ); - frame.stack.push(FrameValue { value }); - Ok(true) - } + AotInstruction::Call(_) => Ok(false), } } @@ -2782,12 +2756,6 @@ mod tests { .expect("closure source should compile"); let ssa = build_aot_ssa(&compiled.program).expect("callable ssa should build"); - assert!(ssa.blocks.iter().any(|block| { - block - .insts - .iter() - .any(|inst| matches!(inst.kind, AotSsaInstKind::MakeCallable { .. })) - })); assert!( ssa.blocks.iter().any(|block| { matches!(block.terminator, Some(AotSsaTerminator::CallValue { .. })) diff --git a/src/vm/jit/ir.rs b/src/vm/jit/ir.rs index 498db899..a5341234 100644 --- a/src/vm/jit/ir.rs +++ b/src/vm/jit/ir.rs @@ -232,6 +232,7 @@ pub(crate) enum SsaInstKind { import: u16, args: Vec, }, + IntNeg { input: SsaValueId, }, @@ -376,6 +377,7 @@ impl SsaInstKind { match self { Self::Constant(_) => Vec::new(), Self::HostCall { args, .. } => args.clone(), + Self::UnboxInt { input } | Self::UnboxFloat { input } | Self::UnboxBool { input } @@ -520,6 +522,12 @@ pub(crate) enum SsaTerminator { Return { exit: SsaExitId, }, + CallValue { + argc: u8, + call_ip: usize, + resume_ip: usize, + exit: SsaExitId, + }, } #[derive(Clone, Debug, PartialEq)] @@ -907,7 +915,9 @@ fn verify_terminator( verify_branch_target(*condition, if_true, scope, block_param_reprs, exit_ids)?; verify_branch_target(*condition, if_false, scope, block_param_reprs, exit_ids) } - SsaTerminator::Exit { exit } | SsaTerminator::Return { exit } => { + SsaTerminator::Exit { exit } + | SsaTerminator::Return { exit } + | SsaTerminator::CallValue { exit, .. } => { if !exit_ids.contains(exit) { return Err(SsaVerifyError::UnknownExit(*exit)); } @@ -1071,6 +1081,7 @@ fn render_inst_kind(kind: &SsaInstKind) -> String { SsaInstKind::HostCall { import, args } => { format!("host_call {import}({})", render_value_list(args)) } + SsaInstKind::IntNeg { input } => format!("ineg {input}"), SsaInstKind::IntAdd { lhs, rhs } => format!("iadd {lhs}, {rhs}"), SsaInstKind::IntAddImm { lhs, imm } => format!("iadd_imm {lhs}, {imm}"), @@ -1127,6 +1138,12 @@ fn render_terminator(terminator: &SsaTerminator) -> String { ), SsaTerminator::Exit { exit } => format!("exit {exit}"), SsaTerminator::Return { exit } => format!("return {exit}"), + SsaTerminator::CallValue { + argc, + call_ip, + resume_ip, + exit, + } => format!("call_value 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 7ab8cc50..4dd4068e 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -15,22 +15,23 @@ use crate::vm::native::{ array_set_signature, box_heap_value_signature, checked_add_i32, 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, entry_signature, frame_state_entry_address, frame_state_signature, - free_buffer_signature, jump_with_status, leave_frame_entry_address, leave_frame_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, - non_yielding_host_call_entry_address, non_yielding_host_call_signature, pack_shared_signature, - regex_match_entry_address, regex_match_signature, regex_replace_entry_address, - regex_replace_signature, restore_active_sparse_exit_state_entry_address, - shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, - shared_string_from_buffer_entry_address, sparse_restore_exit_signature, - string_binary_transform_signature, string_contains_entry_address, string_contains_signature, - string_lower_ascii_entry_address, string_replace_literal_entry_address, - string_replace_signature, string_split_literal_entry_address, string_unary_transform_signature, - to_string_entry_address, type_of_entry_address, value_eq_entry_address, value_eq_signature, - value_len_entry_address, value_len_signature, value_slot_signature, - write_heap_value_to_slot_entry_address, zero_bytes_entry_address, + 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, + jump_with_status, leave_frame_entry_address, leave_frame_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, non_yielding_host_call_entry_address, + non_yielding_host_call_signature, pack_shared_signature, regex_match_entry_address, + regex_match_signature, regex_replace_entry_address, regex_replace_signature, + restore_active_sparse_exit_state_entry_address, shared_array_from_buffer_entry_address, + shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, + sparse_restore_exit_signature, string_binary_transform_signature, + string_contains_entry_address, string_contains_signature, string_lower_ascii_entry_address, + string_replace_literal_entry_address, string_replace_signature, + string_split_literal_entry_address, string_unary_transform_signature, to_string_entry_address, + type_of_entry_address, value_eq_entry_address, value_eq_signature, value_len_entry_address, + value_len_signature, value_slot_signature, write_heap_value_to_slot_entry_address, + zero_bytes_entry_address, }; use cranelift_codegen::ir::condcodes::{FloatCC, IntCC}; use cranelift_codegen::ir::immediates::Ieee64; @@ -121,6 +122,8 @@ fn try_compile_ssa_trace( let sparse_restore_exit_sig = sparse_restore_exit_signature(pointer_type, call_conv); let frame_state_sig = frame_state_signature(pointer_type, call_conv); let leave_frame_sig = leave_frame_signature(pointer_type, call_conv); + let enter_call_value_sig = enter_call_value_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); let regex_match_sig = regex_match_signature(pointer_type, call_conv); @@ -196,6 +199,8 @@ fn try_compile_ssa_trace( map_set_ref: b.import_signature(map_set_sig), sparse_restore_exit_ref: b.import_signature(sparse_restore_exit_sig), leave_frame_ref: b.import_signature(leave_frame_sig), + enter_call_value_ref: b.import_signature(enter_call_value_sig), + resume_linked_trace_ref: b.import_signature(resume_linked_trace_sig), }; let deopt_addrs = SsaDeoptHelperAddrs { @@ -215,6 +220,8 @@ fn try_compile_ssa_trace( map_set: map_set_entry_address(), sparse_restore_exit: restore_active_sparse_exit_state_entry_address(), leave_frame: leave_frame_entry_address(), + enter_call_value: enter_call_value_entry_address(), + resume_linked_trace: resume_linked_trace_entry_address(), }; // The outer native dispatch loop links trace exits directly. Re-entering it through the @@ -248,11 +255,27 @@ fn try_compile_ssa_trace( block_handles.insert(block.id, handle); } + let call_value_exits = ssa + .blocks + .iter() + .filter_map(|block| match block.terminator.as_ref() { + Some(SsaTerminator::CallValue { + argc, + call_ip, + resume_ip, + exit, + }) => Some((*exit, (*argc, *call_ip, *resume_ip))), + _ => None, + }) + .collect::>(); let mut exit_specs = HashMap::new(); for exit in &ssa.exits { let inputs = exit_inputs(exit); let trace_exit_block = b.create_block(); let halted_block = b.create_block(); + let call_value_block = call_value_exits + .contains_key(&exit.id) + .then(|| b.create_block()); for value in &inputs { let Some(repr) = value_reprs.get(value).copied() else { return Ok(None); @@ -262,12 +285,16 @@ fn try_compile_ssa_trace( }; b.append_block_param(trace_exit_block, ty); b.append_block_param(halted_block, ty); + if let Some(call_value_block) = call_value_block { + b.append_block_param(call_value_block, ty); + } } exit_specs.insert( exit.id, SsaExitLowering { trace_exit_block, halted_block, + call_value_block, inputs, }, ); @@ -391,10 +418,24 @@ fn try_compile_ssa_trace( lower_ctx, exit, spec, - false, - allow_exit_link_handoff, + SsaExitAction::TraceExit { + allow_link_handoff: allow_exit_link_handoff, + }, )?; - lower_ssa_exit_block(&mut b, lower_ctx, exit, spec, true, false)?; + 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, + }, + )?; + } } b.switch_to_block(exit_block); @@ -448,9 +489,23 @@ fn try_compile_ssa_trace( struct SsaExitLowering { trace_exit_block: Block, halted_block: Block, + call_value_block: Option, inputs: Vec, } +#[derive(Clone, Copy)] +enum SsaExitAction { + TraceExit { + allow_link_handoff: bool, + }, + Return, + CallValue { + argc: u8, + call_ip: usize, + resume_ip: usize, + }, +} + #[derive(Clone, Copy)] struct SsaDeoptHelperRefs { clone_value_ref: cranelift_codegen::ir::SigRef, @@ -469,6 +524,8 @@ struct SsaDeoptHelperRefs { map_set_ref: cranelift_codegen::ir::SigRef, sparse_restore_exit_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, + enter_call_value_ref: cranelift_codegen::ir::SigRef, + resume_linked_trace_ref: cranelift_codegen::ir::SigRef, } @@ -490,6 +547,8 @@ struct SsaDeoptHelperAddrs { map_set: usize, sparse_restore_exit: usize, leave_frame: usize, + enter_call_value: usize, + resume_linked_trace: usize, } @@ -734,6 +793,7 @@ fn allocate_owned_value_temps( ) -> VmResult { let mut ordered = Vec::new(); let mut slots = HashMap::new(); + for block in &ssa.blocks { for inst in &block.insts { let Some(output) = inst.output else { @@ -762,6 +822,7 @@ fn allocate_owned_value_temps( )); slots.insert(SsaTempValueSlotKey::HostArgs(output.id), args_slot); } + if matches!( inst.kind, SsaInstKind::MapGet { .. } | SsaInstKind::MapHas { .. } @@ -827,7 +888,9 @@ fn borrowed_array_get_outputs(ssa: &SsaTrace) -> BTreeSet { } } } - SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } => {} + SsaTerminator::Exit { .. } + | SsaTerminator::Return { .. } + | SsaTerminator::CallValue { .. } => {} } } for exit in &ssa.exits { @@ -1036,7 +1099,9 @@ fn ssa_backedge_targets( push_target(*target); } } - SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } => {} + SsaTerminator::Exit { .. } + | SsaTerminator::Return { .. } + | SsaTerminator::CallValue { .. } => {} } targets } @@ -3074,6 +3139,25 @@ fn lower_ssa_terminator( let args = ssa_block_args(args); b.ins().jump(spec.halted_block, &args); } + SsaTerminator::CallValue { exit, .. } => { + let spec = exit_specs.get(exit).ok_or_else(|| { + VmError::JitNative("SSA call-value exit lowering missing".to_string()) + })?; + let args = spec + .inputs + .iter() + .map(|value| { + values.get(value).copied().ok_or_else(|| { + VmError::JitNative("SSA call-value exit value missing".to_string()) + }) + }) + .collect::>>()?; + let args = ssa_block_args(args); + let call_value_block = spec + .call_value_block + .ok_or_else(|| VmError::JitNative("SSA call-value block missing".to_string()))?; + b.ins().jump(call_value_block, &args); + } } Ok(()) } @@ -3147,13 +3231,68 @@ fn ssa_leave_frame_status( Ok(b.inst_results(call)[0]) } +fn ssa_exit_action_status( + b: &mut FunctionBuilder, + pointer_type: cranelift_codegen::ir::Type, + helper_refs: SsaDeoptHelperRefs, + helper_addrs: SsaDeoptHelperAddrs, + vm_ptr: cranelift_codegen::ir::Value, + exit_ip: usize, + action: SsaExitAction, +) -> VmResult { + match action { + SsaExitAction::Return => { + ssa_leave_frame_status(b, pointer_type, helper_refs, helper_addrs, vm_ptr, exit_ip) + } + SsaExitAction::CallValue { + argc, + call_ip, + resume_ip, + } => { + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.enter_call_value)?; + 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-value 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-value resume ip out of range".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_value_ref, + helper_ptr, + &[vm_ptr, argc, call_ip, resume_ip], + ); + Ok(b.inst_results(call)[0]) + } + SsaExitAction::TraceExit { allow_link_handoff } => { + if allow_link_handoff { + let helper_ptr = + iconst_ptr_from_addr(b, pointer_type, helper_addrs.resume_linked_trace)?; + let call = b.ins().call_indirect( + helper_refs.resume_linked_trace_ref, + helper_ptr, + &[vm_ptr], + ); + Ok(b.inst_results(call)[0]) + } else { + Ok(b.ins().iconst(types::I32, STATUS_TRACE_EXIT as i64)) + } + } + } +} + fn lower_ssa_exit_block( b: &mut FunctionBuilder, ctx: SsaLowerCtx<'_>, exit: &crate::vm::jit::ir::SsaExit, spec: &SsaExitLowering, - halted: bool, - allow_link_handoff: bool, + action: SsaExitAction, ) -> VmResult<()> { let SsaLowerCtx { vm_ptr, @@ -3168,10 +3307,12 @@ fn lower_ssa_exit_block( helper_addrs: deopt_addrs, .. } = ctx; - let block = if halted { - spec.halted_block - } else { - spec.trace_exit_block + let block = match action { + SsaExitAction::TraceExit { .. } => spec.trace_exit_block, + SsaExitAction::Return => spec.halted_block, + SsaExitAction::CallValue { .. } => spec + .call_value_block + .ok_or_else(|| VmError::JitNative("SSA call-value exit block missing".to_string()))?, }; b.switch_to_block(block); let block_params = b.block_params(block).to_vec(); @@ -3258,25 +3399,15 @@ fn lower_ssa_exit_block( ); b.ins() .store(MemFlags::new(), ip_val, vm_ptr, offsets.vm_ip); - let status = if halted { - ssa_leave_frame_status( - b, - pointer_type, - deopt_refs, - deopt_addrs, - vm_ptr, - exit.exit_ip, - )? - } else if allow_link_handoff { - let helper_ptr = - iconst_ptr_from_addr(b, pointer_type, deopt_addrs.resume_linked_trace)?; - let call = - b.ins() - .call_indirect(deopt_refs.resume_linked_trace_ref, helper_ptr, &[vm_ptr]); - b.inst_results(call)[0] - } else { - b.ins().iconst(types::I32, STATUS_TRACE_EXIT as i64) - }; + let status = ssa_exit_action_status( + b, + pointer_type, + deopt_refs, + deopt_addrs, + vm_ptr, + exit.exit_ip, + action, + )?; jump_with_status(b, exit_block, status); return Ok(()); } @@ -3360,24 +3491,15 @@ fn lower_ssa_exit_block( ip_val, ], )?; - let status = if halted { - ssa_leave_frame_status( - b, - pointer_type, - deopt_refs, - deopt_addrs, - vm_ptr, - exit.exit_ip, - )? - } else if allow_link_handoff { - let helper_ptr = iconst_ptr_from_addr(b, pointer_type, deopt_addrs.resume_linked_trace)?; - let call = b - .ins() - .call_indirect(deopt_refs.resume_linked_trace_ref, helper_ptr, &[vm_ptr]); - b.inst_results(call)[0] - } else { - b.ins().iconst(types::I32, STATUS_TRACE_EXIT as i64) - }; + let status = ssa_exit_action_status( + b, + pointer_type, + deopt_refs, + deopt_addrs, + vm_ptr, + exit.exit_ip, + action, + )?; jump_with_status(b, exit_block, status); Ok(()) } diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index fc4ee312..23636367 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -197,11 +197,16 @@ struct AnalysisFrame { } impl AnalysisFrame { - fn new(program: &Program, entry_stack_depth: usize, local_count: usize) -> Self { + fn new( + program: &Program, + entry_stack_depth: usize, + local_count: usize, + entry_local_types: Option<&[ValueType]>, + ) -> Self { Self { stack: vec![ValueInfo::tagged(); entry_stack_depth], locals: (0..local_count) - .map(|local| entry_local_info(program, local)) + .map(|local| entry_local_info(program, local, entry_local_types)) .collect(), } } @@ -231,12 +236,21 @@ impl AnalysisFrame { } } -fn entry_local_info(program: &Program, local: usize) -> ValueInfo { - let known_type = program - .type_map - .as_ref() - .and_then(|type_map| type_map.local_types.get(local)) +fn entry_local_info( + program: &Program, + local: usize, + entry_local_types: Option<&[ValueType]>, +) -> ValueInfo { + let known_type = entry_local_types + .and_then(|types| types.get(local)) .copied() + .or_else(|| { + program + .type_map + .as_ref() + .and_then(|type_map| type_map.local_types.get(local)) + .copied() + }) .filter(|ty| *ty != ValueType::Unknown); known_type.map_or_else(ValueInfo::tagged, ValueInfo::tagged_typed) } @@ -372,8 +386,10 @@ enum DecodedOp { argc: u8, yields: bool, }, - InterpreterBoundary { + CallValue { ip: usize, + argc: u8, + resume_ip: usize, }, } @@ -388,7 +404,7 @@ impl DecodedOp { | Self::Dup { .. } | Self::Br { .. } | Self::Call { .. } - | Self::InterpreterBoundary { .. } => false, + | Self::CallValue { .. } => false, Self::Stloc { .. } | Self::Neg { .. } | Self::Not { .. } @@ -677,13 +693,14 @@ impl<'a> TraceCursor<'a> { } } } else if opcode == OpCode::CallValue as u8 { - let _argc = read_u8(&self.program.code, &mut self.ip) + self.recorded_ops += 1; + let argc = read_u8(&self.program.code, &mut self.ip) .ok_or(TraceRecordError::InvalidImmediate("callvalue argc"))?; - DecodedOp::InterpreterBoundary { ip: instr_ip } - } else if opcode == OpCode::MakeCallable as u8 { - let _prototype_id = read_u32(&self.program.code, &mut self.ip) - .ok_or(TraceRecordError::InvalidImmediate("makecallable prototype"))?; - DecodedOp::InterpreterBoundary { ip: instr_ip } + DecodedOp::CallValue { + ip: instr_ip, + argc, + resume_ip: self.ip, + } } else { return Err(TraceRecordError::UnsupportedOpcode(opcode)); }; @@ -705,6 +722,7 @@ pub(crate) fn record_trace( root_ip, entry_stack_depth, program.local_count, + None, max_trace_len, non_yielding_host_imports, ) @@ -715,6 +733,7 @@ pub(crate) fn record_trace_with_local_count( root_ip: usize, entry_stack_depth: usize, local_count: usize, + entry_local_types: Option<&[ValueType]>, max_trace_len: usize, non_yielding_host_imports: &[bool], ) -> Result { @@ -723,6 +742,7 @@ pub(crate) fn record_trace_with_local_count( root_ip, entry_stack_depth, local_count, + entry_local_types, max_trace_len, non_yielding_host_imports, )?; @@ -747,7 +767,7 @@ 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), + info: entry_local_info(program, local, entry_local_types), }) .map_err(|err| TraceRecordError::InvalidIr(err.to_string())) }) @@ -1166,8 +1186,16 @@ pub(crate) fn record_trace_with_local_count( } cursor.jump_to(target)?; } - DecodedOp::InterpreterBoundary { ip } => { - op_names.push("callable_boundary".to_string()); + + DecodedOp::CallValue { + ip, + argc, + resume_ip, + } => { + if frame.stack.len() < usize::from(argc) + 1 { + return Err(TraceRecordError::StackUnderflow); + } + op_names.push("call_value".to_string()); let exit = builder.add_exit( ip, materialize_stack(&frame.stack), @@ -1175,9 +1203,19 @@ pub(crate) fn record_trace_with_local_count( frame.dirty_locals.clone(), ); builder - .set_terminator(current_block, SsaTerminator::Exit { exit }) + .set_terminator( + current_block, + SsaTerminator::CallValue { + argc, + call_ip: ip, + resume_ip, + exit, + }, + ) .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; - terminal = Some(JitTraceTerminal::BranchExit); + has_call = true; + has_yielding_call = true; + terminal = Some(JitTraceTerminal::CallValue); break; } DecodedOp::Call { @@ -1302,11 +1340,12 @@ fn infer_loop_header_plan( root_ip: usize, entry_stack_depth: usize, local_count: usize, + entry_local_types: Option<&[ValueType]>, 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); + let mut frame = AnalysisFrame::new(program, entry_stack_depth, local_count, entry_local_types); let mut entry_use = vec![EntryUseState::Untouched; local_count]; let mut local_written = vec![false; local_count]; @@ -1471,8 +1510,7 @@ fn infer_loop_header_plan( .unwrap_or(ValueType::Unknown); frame.push(ValueInfo::tagged_typed(return_type)); } - DecodedOp::Call { .. } => return Ok(None), - DecodedOp::InterpreterBoundary { .. } => return Ok(None), + DecodedOp::Call { .. } | DecodedOp::CallValue { .. } => return Ok(None), DecodedOp::Brfalse { target, fallthrough_ip, @@ -1635,7 +1673,11 @@ fn select_numeric_binop( }; return Ok(NumericBinOpKind::Float(kind)); } - let operand_types = operand_types(program, ip); + let static_operand_types = operand_types(program, ip); + let operand_types = ( + lhs.known_type.unwrap_or(static_operand_types.0), + rhs.known_type.unwrap_or(static_operand_types.1), + ); let observed_concat = observed_concat_binop_kind(lhs, rhs); let int_like = matches!(lhs.repr, SsaValueRepr::I64 | SsaValueRepr::Tagged) && matches!(rhs.repr, SsaValueRepr::I64 | SsaValueRepr::Tagged); @@ -1689,9 +1731,10 @@ fn select_numeric_binop( { Ok(NumericBinOpKind::Float(FloatBinOpKind::Add)) } - _ => Err(TraceRecordError::UnsupportedTrace( - "SSA recorder requires int- or float-specializable add operands".to_string(), - )), + _ => Err(TraceRecordError::UnsupportedTrace(format!( + "SSA recorder requires int- or float-specializable add operands (types={operand_types:?}, lhs={:?}, rhs={:?})", + lhs.repr, rhs.repr + ))), }, x if x == OpCode::Sub as u8 => match operand_types { (ValueType::Int, ValueType::Int) => Ok(NumericBinOpKind::Int(IntBinOpKind::Sub)), @@ -4564,20 +4607,20 @@ mod tests { } #[test] - fn callable_opcodes_decode_as_explicit_interpreter_boundaries() { + fn callable_opcodes_decode_with_explicit_trace_semantics() { let mut bytecode = BytecodeBuilder::new(); bytecode.call_value(2); - bytecode.make_callable(7); let program = Program::new(Vec::new(), bytecode.finish()); let mut cursor = TraceCursor::new(&program, 0, 8); assert!(matches!( cursor.next().expect("callvalue decode"), - Some(DecodedOp::InterpreterBoundary { ip: 0 }) - )); - assert!(matches!( - cursor.next().expect("makecallable decode"), - Some(DecodedOp::InterpreterBoundary { ip: 2 }) + Some(DecodedOp::CallValue { + ip: 0, + argc: 2, + resume_ip: 2 + }) )); + assert!(cursor.next().expect("trace end").is_none()); } #[test] diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index 83887919..6bd3b8ca 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -9,6 +9,7 @@ use super::super::{ExecOutcome, Vm, VmError, VmResult}; ))] use super::JitTrace; use super::{JitMetrics, JitTraceTerminal, native}; +use crate::vm::native::ROOT_FRAME_KEY; use std::collections::HashMap; use std::sync::{Arc, Mutex}; #[cfg(any( @@ -237,10 +238,16 @@ impl Vm { .jit .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { + let entry_local_types = + (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let program = &self.program; - next_trace_id = self - .jit - .observe_exit_entry(frame_key, ip, stack_depth, program); + next_trace_id = self.jit.observe_exit_entry_with_local_types( + frame_key, + ip, + stack_depth, + entry_local_types.as_deref(), + program, + ); } let Some(next_trace_id) = next_trace_id else { return Ok(native::STATUS_LINKED_CONTINUE); @@ -320,10 +327,16 @@ impl Vm { self.jit .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { + let entry_local_types = + (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let program = &self.program; - next_trace_id = - self.jit - .observe_exit_entry(frame_key, ip, stack_depth, program); + next_trace_id = self.jit.observe_exit_entry_with_local_types( + frame_key, + ip, + stack_depth, + entry_local_types.as_deref(), + program, + ); } if let Some(next_trace_id) = next_trace_id && next_trace_id != current_trace_id @@ -590,9 +603,16 @@ impl Vm { .compiled_trace_for_entry(frame_key, ip, stack_depth); if next_trace_id.is_none() { next_trace_id = { + let entry_local_types = (frame_key != ROOT_FRAME_KEY) + .then(|| self.active_local_types()); let program = &self.program; - self.jit - .observe_exit_entry(frame_key, ip, stack_depth, program) + self.jit.observe_exit_entry_with_local_types( + frame_key, + ip, + stack_depth, + entry_local_types.as_deref(), + program, + ) }; } if let Some(next_trace_id) = next_trace_id @@ -626,7 +646,56 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_HALTED => return Ok(ExecOutcome::Halted), - native::STATUS_LINKED_CONTINUE => return Ok(ExecOutcome::Continue), + native::STATUS_LINKED_CONTINUE => { + let ip = self.ip; + let frame_key = self.active_frame_key(); + let stack_depth = self.active_operand_stack_len(); + let mut next_trace_id = + self.jit + .compiled_trace_for_entry(frame_key, ip, stack_depth); + if next_trace_id.is_none() { + next_trace_id = { + let entry_local_types = + (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); + let program = &self.program; + self.jit.observe_exit_entry_with_local_types( + frame_key, + ip, + stack_depth, + entry_local_types.as_deref(), + program, + ) + }; + } + if let Some(next_trace_id) = next_trace_id + && next_trace_id != current_trace_id + { + self.record_jit_link_handoff(); + current_trace_id = next_trace_id; + if let Some(state) = self.cached_native_trace_state( + current_trace_id, + native::NativeCompileProfile::Jit, + ) { + (entry, root_ip, terminal, _, has_yielding_call) = state; + } else { + if let Err(err) = self.ensure_native_trace( + current_trace_id, + native::NativeCompileProfile::Jit, + ) { + if should_fallback_to_interpreter(&err) { + self.record_jit_helper_fallback(); + self.jit.block_trace(current_trace_id); + return Ok(ExecOutcome::Continue); + } + return Err(err); + } + (entry, root_ip, terminal, _, has_yielding_call) = + self.native_trace_state(current_trace_id)?; + } + continue; + } + return Ok(ExecOutcome::Continue); + } native::STATUS_YIELDED => { self.last_yield_reason = Some(super::super::VmYieldReason::Host); return Ok(ExecOutcome::Yielded); diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index eccdb4da..b3c91f24 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -42,8 +42,9 @@ fn native_jit_supported() -> bool { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum JitTraceTerminal { LoopBack, - BranchExit, Halt, + BranchExit, + CallValue, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -249,6 +250,17 @@ impl TraceJitEngine { ip: usize, stack_depth: usize, program: &Program, + ) -> Option { + self.observe_hot_entry_with_local_types(frame_key, ip, stack_depth, None, program) + } + + pub(crate) fn observe_hot_entry_with_local_types( + &mut self, + frame_key: u64, + ip: usize, + stack_depth: usize, + entry_local_types: Option<&[crate::ValueType]>, + program: &Program, ) -> Option { if !self.config.enabled || !native_jit_supported() { return None; @@ -280,7 +292,7 @@ impl TraceJitEngine { let result = if self.config.hot_loop_threshold == 0 { Err(JitNyiReason::HotLoopThresholdZero) } else { - self.compile_trace(program, key) + self.compile_trace(program, key, entry_local_types) }; self.finish_attempt(key, line, result) } @@ -299,6 +311,17 @@ impl TraceJitEngine { ip: usize, stack_depth: usize, program: &Program, + ) -> Option { + self.observe_exit_entry_with_local_types(frame_key, ip, stack_depth, None, program) + } + + pub(crate) fn observe_exit_entry_with_local_types( + &mut self, + frame_key: u64, + ip: usize, + stack_depth: usize, + entry_local_types: Option<&[crate::ValueType]>, + program: &Program, ) -> Option { if !self.config.enabled || !native_jit_supported() { return None; @@ -328,7 +351,7 @@ impl TraceJitEngine { let result = if self.config.hot_loop_threshold == 0 { Err(JitNyiReason::HotLoopThresholdZero) } else { - self.compile_trace(program, key) + self.compile_trace(program, key, entry_local_types) }; self.finish_attempt(key, line, result) } @@ -514,6 +537,7 @@ impl TraceJitEngine { &mut self, program: &Program, key: TraceEntryKey, + entry_local_types: Option<&[crate::ValueType]>, ) -> Result { let local_count = if key.frame_key == ROOT_FRAME_KEY { program.local_count @@ -534,6 +558,7 @@ impl TraceJitEngine { key.root_ip, key.stack_depth, local_count, + entry_local_types, self.config.max_trace_len, &self.non_yielding_host_imports, ) diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 88e5b085..4fc3a766 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -997,6 +997,23 @@ impl Vm { .unwrap_or(0) } + pub(super) fn active_local_types(&self) -> Vec { + self.locals[self.active_local_base()..] + .iter() + .map(|value| match value { + Value::Null => ValueType::Null, + Value::Int(_) => ValueType::Int, + Value::Float(_) => ValueType::Float, + Value::Bool(_) => ValueType::Bool, + Value::String(_) => ValueType::String, + Value::Bytes(_) => ValueType::Bytes, + Value::Array(_) => ValueType::Array, + Value::Map(_) => ValueType::Map, + Value::Callable(_) => ValueType::Callable, + }) + .collect() + } + fn script_frame_depth(&self) -> usize { self.execution_frames .iter() @@ -1086,18 +1103,22 @@ impl Vm { self.stack.pop().ok_or(VmError::StackUnderflow) } - fn make_callable(&mut self, prototype_id: u32) -> VmResult<()> { + pub(crate) fn bind_callable_value( + &self, + prototype_id: u32, + captures: Vec, + ) -> VmResult { let prototype = self .program .callable_prototypes .get(prototype_id as usize) .cloned() .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; - let capture_count = prototype.capture_slots.len(); - if self.stack.len() < capture_count { - return Err(VmError::StackUnderflow); + if captures.len() != prototype.capture_slots.len() { + return Err(VmError::InvalidFrameState( + "callable capture layout mismatch", + )); } - let captures = self.stack.split_off(self.stack.len() - capture_count); let env = if prototype.kind == crate::CallableKind::Closure || !captures.is_empty() { Some(Arc::new(crate::CallableEnvironment { cells: Mutex::new(captures), @@ -1105,13 +1126,12 @@ impl Vm { } else { None }; - self.stack.push(Value::Callable(Arc::new(CallableValue { + Ok(Value::Callable(Arc::new(CallableValue { program_instance: self.program_instance, prototype_id, kind: prototype.kind, env, - }))); - Ok(()) + }))) } fn execute_call_value(&mut self, argc: u8) -> VmResult { @@ -1272,7 +1292,11 @@ impl Vm { match self.execute_host_call(import_index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(ExecOutcome::Continue), HostCallExecOutcome::Halted => Ok(ExecOutcome::Halted), - HostCallExecOutcome::Yielded => Ok(ExecOutcome::Yielded), + HostCallExecOutcome::Yielded => { + self.stack + .insert(operand_stack_base, Value::Callable(callable)); + Ok(ExecOutcome::Yielded) + } HostCallExecOutcome::Pending(op_id) => Ok(ExecOutcome::Waiting(op_id)), } } @@ -1986,10 +2010,17 @@ impl Vm { { let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); + let entry_local_types = (frame_key != crate::vm::native::ROOT_FRAME_KEY) + .then(|| self.active_local_types()); let trace_id = { let program = &self.program; - self.jit - .observe_hot_entry(frame_key, self.ip, stack_depth, program) + self.jit.observe_hot_entry_with_local_types( + frame_key, + self.ip, + stack_depth, + entry_local_types.as_deref(), + program, + ) }; if let Some(trace_id) = trace_id { let outcome = match self.execute_jit_entry(trace_id) { @@ -2382,10 +2413,7 @@ impl Vm { HostCallExecOutcome::Pending(op_id) => return Ok(ExecOutcome::Waiting(op_id)), } } - x if x == OpCode::MakeCallable as u8 => { - let prototype_id = self.read_u32()?; - self.make_callable(prototype_id)?; - } + x if x == OpCode::CallValue as u8 => { let argc = self.read_u8()?; return self.execute_call_value(argc); diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 043a2222..831927db 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -289,10 +289,6 @@ pub(crate) fn leave_frame_entry_address() -> usize { pd_vm_native_leave_frame as *const () as usize } -pub(crate) fn make_callable_entry_address() -> usize { - pd_vm_native_make_callable as *const () as usize -} - pub(crate) fn active_local_base_entry_address() -> usize { pd_vm_native_active_local_base as *const () as usize } @@ -843,55 +839,6 @@ pub(crate) extern "C" fn pd_vm_native_leave_frame(vm: *mut Vm, ret_ip: i64) -> i }) } -pub(crate) extern "C" fn pd_vm_native_make_callable( - vm: *mut Vm, - prototype_id: i64, - captures: *const *mut Value, - capture_count: i64, - out: *mut Value, -) -> i32 { - run_step(vm, "make_callable", |vm| { - if out.is_null() { - return Err(VmError::InvalidFrameState( - "native callable output buffer is null", - )); - } - let prototype_id = u32::try_from(prototype_id) - .map_err(|_| VmError::InvalidFrameState("native prototype id out of range"))?; - let capture_count = usize::try_from(capture_count) - .map_err(|_| VmError::InvalidFrameState("native capture count out of range"))?; - if capture_count > 0 && captures.is_null() { - return Err(VmError::InvalidFrameState( - "native callable capture buffer is null", - )); - } - let stack_base = vm.stack.len(); - for index in 0..capture_count { - let capture = unsafe { captures.add(index).read() }; - if capture.is_null() { - vm.stack.truncate(stack_base); - return Err(VmError::InvalidFrameState( - "native callable capture slot is null", - )); - } - vm.stack.push(unsafe { capture.read() }); - unsafe { - capture.write(Value::Null); - } - } - if let Err(err) = vm.make_callable(prototype_id) { - vm.stack.truncate(stack_base); - return Err(err); - } - let callable = vm.pop_value()?; - vm.stack.truncate(stack_base); - unsafe { - out.write(callable); - } - Ok(STATUS_CONTINUE) - }) -} - pub(crate) extern "C" fn pd_vm_native_active_stack_base(vm: *const Vm) -> usize { unsafe { vm.as_ref() } .map(Vm::active_operand_stack_base) @@ -1739,15 +1686,9 @@ mod tests { let ret_ip = function.end_ip as usize - 1; let mut vm = Vm::new(compiled.program); - let mut capture = Value::Int(1); - let captures = [&mut capture as *mut Value]; - let mut callable = MaybeUninit::::uninit(); - assert_eq!( - pd_vm_native_make_callable(&mut vm, 0, captures.as_ptr(), 1, callable.as_mut_ptr(),), - STATUS_CONTINUE - ); - assert_eq!(capture, Value::Null); - let callable = unsafe { callable.assume_init() }; + let callable = vm + .bind_callable_value(0, vec![Value::Int(1)]) + .expect("bind callable"); assert!(matches!(callable, Value::Callable(_))); vm.stack.extend([callable, Value::Int(41)]); diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 56b324a8..fe96bad4 100644 --- a/src/vm/native/codegen.rs +++ b/src/vm/native/codegen.rs @@ -62,21 +62,6 @@ pub(crate) fn leave_frame_signature( sig } -#[cfg(feature = "cranelift-jit")] -pub(crate) fn make_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(types::I64)); - sig.params.push(AbiParam::new(pointer_type)); - sig.params.push(AbiParam::new(types::I64)); - sig.params.push(AbiParam::new(pointer_type)); - sig.returns.push(AbiParam::new(types::I32)); - sig -} - #[cfg(feature = "cranelift-jit")] pub(crate) fn frame_state_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index cbfc4709..fad480d7 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -16,18 +16,18 @@ pub(crate) use bridge::{ collection_set_entry_address, copy_bytes_entry_address, enter_call_value_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, make_callable_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, regex_match_entry_address, regex_replace_entry_address, + interrupt_helper_entry_offset, leave_frame_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, + regex_match_entry_address, regex_replace_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, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, store_bridge_error, string_contains_entry_address, string_lower_ascii_entry_address, string_replace_literal_entry_address, string_split_literal_entry_address, take_bridge_error, to_string_entry_address, - type_of_entry_address, value_eq_entry_address, - value_len_entry_address, write_heap_value_to_slot_entry_address, zero_bytes_entry_address, + type_of_entry_address, value_eq_entry_address, value_len_entry_address, + write_heap_value_to_slot_entry_address, zero_bytes_entry_address, }; #[cfg(feature = "cranelift-jit")] pub(crate) use codegen::{ @@ -35,7 +35,7 @@ pub(crate) use codegen::{ collection_get_signature, collection_mutation_signature, collection_predicate_signature, copy_bytes_signature, enter_call_value_signature, entry_signature, frame_state_signature, free_buffer_signature, helper_signature, jump_with_status, leave_frame_signature, - make_callable_signature, map_iter_next_signature, map_iter_take_signature, map_set_signature, + map_iter_next_signature, map_iter_take_signature, map_set_signature, non_yielding_host_call_signature, pack_shared_signature, regex_match_signature, regex_replace_signature, restore_exit_signature, sparse_restore_exit_signature, string_binary_transform_signature, string_contains_signature, string_replace_signature, diff --git a/src/vmbc.rs b/src/vmbc.rs index e1d60a83..09062a57 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -448,14 +448,7 @@ pub fn disassemble_program_with_options(program: &Program, options: DisassembleO truncated = true; } } - x if x == OpCode::MakeCallable as u8 => { - if let Some(prototype_id) = read_u32(code, &mut ip) { - instruction.push_str(&format!("makecallable {prototype_id}")); - } else { - instruction.push_str("makecallable "); - truncated = true; - } - } + x if x == OpCode::Shl as u8 => instruction.push_str("shl"), x if x == OpCode::Shr as u8 => instruction.push_str("shr"), x if x == OpCode::Lshr as u8 => instruction.push_str("lshr"), @@ -714,19 +707,7 @@ fn analyze_program( expected_bytes: 1, })?; } - x if x == OpCode::MakeCallable as u8 => { - let prototype_id = - read_u32(code, &mut ip).ok_or(ValidationError::TruncatedOperand { - offset: start, - opcode, - expected_bytes: 4, - })?; - if prototype_id as usize >= program.callable_prototypes.len() { - return Err(ValidationError::InvalidCallableMetadata( - "makecallable references an invalid prototype", - )); - } - } + other => { return Err(ValidationError::InvalidOpcode { offset: start, diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index 856700bb..a2325fca 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -4668,7 +4668,7 @@ fn trace_jit_specializes_loop_carried_string_builtins() { } #[test] -fn trace_jit_preserves_dynamic_concat_type_guards() { +fn trace_jit_links_dynamic_concat_callable_graph() { if !native_jit_supported() { return; } @@ -4711,14 +4711,13 @@ fn trace_jit_preserves_dynamic_concat_type_guards() { let snapshot = vm.jit_snapshot(); assert!( snapshot.traces.iter().any(|trace| { - !trace.has_call - && ["type_of", "to_string_identity", "string_concat"] - .iter() - .all(|required| trace.op_names().iter().any(|op| op == required)) + trace.terminal == JitTraceTerminal::CallValue && trace.executions >= 8 }), - "dynamic concat inner trace should specialize type dispatch without calls:\n{}", + "dynamic concat root trace should link into its callable graph:\n{}", vm.dump_jit_info(), ); + assert!(vm.jit_native_link_handoff_count() > 0); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); } #[test] @@ -4839,3 +4838,242 @@ fn trace_jit_executes_hot_loop_inside_script_callable_frame() { vm.dump_jit_info() ); } + +#[test] +fn trace_jit_executes_call_value_natively_inside_loop() { + if !native_jit_supported() { + return; + } + let source = r#" + let mut i = 0; + let mut total = 0; + let delta = 3; + let add: fn(int) -> int = |value| value + delta; + while i < 16 { + total = add(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("callable 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("callable loop trace should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(48)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| { + trace.terminal == JitTraceTerminal::CallValue + && trace.op_names.iter().any(|name| name == "call_value") + && trace.executions > 0 + }), + "expected native callable trace: {}", + vm.dump_jit_info() + ); + assert!( + vm.jit_native_link_handoff_count() > 0, + "expected callable native handoff: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn trace_jit_call_value_waits_and_resumes_host_callable_without_replay() { + if !native_jit_supported() { + return; + } + + struct PendingCallableHost; + + impl HostFunction for PendingCallableHost { + fn call(&mut self, _vm: &mut Vm, args: &[Value]) -> vm::VmResult { + let value = match args { + [Value::Int(value)] => *value, + _ => { + return Err(vm::VmError::HostError( + "pending callable expected int".to_string(), + )); + } + }; + Ok(CallOutcome::Pending(900 + value as u64)) + } + } + + let source = r#" + fn action(value: int) -> int; + let function: fn(int) -> int = action; + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + function(i); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("pending callable loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.register_function(Box::new(PendingCallableHost)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("first callable host call should wait"), + VmStatus::Waiting(900) + ); + vm.complete_host_op(900, CallReturn::one(Value::Int(10))) + .expect("first pending call should complete"); + assert_eq!( + vm.resume().expect("second callable host call should wait"), + VmStatus::Waiting(901) + ); + vm.complete_host_op(901, CallReturn::one(Value::Int(20))) + .expect("second pending call should complete"); + assert_eq!( + vm.resume().expect("callable host loop should finish"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(30)]); + assert!( + vm.jit_snapshot().traces.iter().any(|trace| { + trace.terminal == JitTraceTerminal::CallValue && trace.executions >= 2 + }) + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn trace_jit_call_value_yields_and_resumes_host_callable_without_losing_frame_state() { + if !native_jit_supported() { + return; + } + let source = r#" + fn action() -> int; + let function: fn() -> int = action; + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + function(); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("yielding callable loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.register_function(Box::new(YieldOnce { yielded: false })); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("callable host should yield"), + VmStatus::Yielded + ); + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Host)); + assert_eq!( + vm.resume().expect("callable host loop should resume"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(84)]); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn trace_jit_links_nested_dynamic_script_callables_without_interpreter_handoff() { + if !native_jit_supported() { + return; + } + let source = r#" + fn add_one(value: int) -> int { value + 1 } + fn add_two(value: int) -> int { value + 2 } + fn apply(function: fn(int) -> int, value: int) -> int { function(value) } + let mut i = 0; + let mut total = 0; + while i < 16 { + let selected = if i < 8 => { add_one } else => { add_two }; + total = apply(selected, total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("dynamic callable graph 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("dynamic callable graph should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(24)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot + .traces + .iter() + .any(|trace| trace.frame_key != u64::MAX) + ); + assert!( + vm.jit_native_link_handoff_count() > 0, + "expected linked callable graph: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn trace_jit_links_finite_mutual_recursion_without_interpreter_handoff() { + if !native_jit_supported() { + return; + } + let source = r#" + fn even(value: int) -> int { + if value == 0 => { 1 } else => { odd(value - 1) } + } + fn odd(value: int) -> int { + if value == 0 => { 0 } else => { even(value - 1) } + } + let mut i = 0; + let mut total = 0; + while i < 8 { + total = total + even(8); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("mutual 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("mutual recursion should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(8)]); + assert!( + vm.jit_native_link_handoff_count() > 0, + "expected recursive native handoffs: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} From 62f2f5027c2f8dc52a08111ef42a21dc3555284f Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 21:31:26 +0800 Subject: [PATCH 09/18] fix(vm): classify captured callable values at runtime --- src/compiler/codegen.rs | 6 ++- tests/compiler/compiler_rustscript_tests.rs | 46 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 2fbaf9fd..03282da5 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -254,7 +254,11 @@ impl Compiler { self.function_prototype_ids .insert(function_index, prototype_id); self.callable_prototypes.push(CallablePrototype { - kind: CallableKind::FunctionItem, + kind: if function_impl.capture_copies.is_empty() { + CallableKind::FunctionItem + } else { + CallableKind::Closure + }, target: CallableTarget::ScriptFunction(script_function_id), arity: function_impl.param_slots.len() as u8, frame_local_count: self.frame_local_count, diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 444d041e..021acde8 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -2247,6 +2247,52 @@ fn escaping_closure_retains_its_environment() { run_runtime_case(&case); } +#[test] +fn closure_aliases_share_state_and_factory_evaluations_are_independent() { + let case = rustscript_runtime_case( + "closure aliases share one environment while factory calls allocate distinct environments", + r#" + fn make_counter() { + let mut count = 0; + fn next() { + count = count + 1; + count + } + next + } + let first = make_counter(); + let alias = first; + let second = make_counter(); + first(); + alias(); + second(); + "#, + vec![Value::Int(1), Value::Int(2), Value::Int(1)], + ); + + run_runtime_case(&case); +} + +#[test] +fn capturing_named_functions_use_closure_runtime_kind() { + let compiled = vm::compile_source_for_repl( + r#" + let captured = 42; + fn read_captured() { captured } + read_captured; + "#, + ) + .expect("capturing named function should compile"); + let prototype = compiled + .program + .callable_prototypes + .iter() + .find(|prototype| !prototype.capture_slots.is_empty()) + .expect("capturing named function should have an environment layout"); + + assert_eq!(prototype.kind, vm::CallableKind::Closure); +} + #[test] fn callable_equality_distinguishes_items_aliases_and_closure_instances() { let case = RuntimeCase { From c838a7017df5febcf4628df7a225b789e35817ee Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 21:47:36 +0800 Subject: [PATCH 10/18] feat(vm): make callable values Program-owned --- README.md | 2 +- docs/callable-runtime.md | 8 ++-- pd-vm-nostd/src/error.rs | 2 - pd-vm-nostd/src/lib.rs | 2 +- pd-vm-nostd/src/value.rs | 6 +-- pd-vm-nostd/src/vm.rs | 8 ---- plans/function_as_value_plan.md | 8 ++-- plans/real_script_call_frames_plan.md | 12 +++--- src/builtins/runtime/core.rs | 1 - src/bytecode.rs | 9 +---- src/debugger/recording.rs | 14 +++---- src/lib.rs | 4 +- src/vm/mod.rs | 57 ++++----------------------- src/vm/store.rs | 19 +-------- src/vm/tests.rs | 22 ++++------- 15 files changed, 41 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index b68f5a23..1143c0f2 100644 --- a/README.md +++ b/README.md @@ -674,7 +674,7 @@ The compiler keeps direct host dispatch separate from script callable dispatch. - `ret` completes the active typed continuation: root halt, caller resume, or host return. At runtime, direct `call` uses `Vm::execute_host_call`, while `callvalue` validates the callable's -Program instance, prototype, schema, arity, and frame layout before dispatch. VMBC v9 carries the +prototype, schema, arity, and frame layout before dispatch. VMBC v9 carries the script-function, prototype, function-region, and root-binding tables. See [`docs/callable-runtime.md`](docs/callable-runtime.md) for the bytecode, lifecycle, callback, and optimized-backend contracts. diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 8229eb37..6b518131 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -9,7 +9,7 @@ RustScript bytecode format version 9 introduces runtime script call frames and f - 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 v9 is a hard format boundary. Decoders reject older versions. The stream includes script-function entry ranges, callable prototypes, function regions, and root callable bindings. PDRC recordings and AOT artifacts use their corresponding bumped format/ABI versions and include callable metadata in cache identity. +VMBC v9 is a hard format boundary. Decoders reject older versions. The stream includes script-function entry ranges, callable prototypes, function regions, and root callable bindings. PDRC v4 recordings and AOT artifacts use their corresponding bumped format/ABI versions and include callable metadata in cache identity. ## Runtime model @@ -26,11 +26,11 @@ Branches are restricted to the active function region. Validation rejects cross- ## Callable identity and lifetime -A callable contains the owning program-instance ID, prototype ID, kind, and optional environment. Capture-free function items compare by program/prototype identity. Closures compare by runtime identity. Callable constants are forbidden; functions are initialized from program metadata and closures are materialized at their declaration site. +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. -Resetting or replacing the VM program assigns a new program-instance ID. Calling a value from an older instance reports `StaleCallable`. +Reset clears Program runtime values and rebinds root function items from Program metadata. Program replacement cancels and releases the old Store callback registry before dropping the old Program. Raw callable values are Program-local and are not portable across Program/Store boundaries. -`Vm::invoke_callable` is the synchronous host-entry API for a callable retained after the root program halts. For resumable work, `Vm::start_callable` returns `VmStatus`; after `Yielded` or `Waiting`, continue with `Vm::resume` and read the completed value with `Vm::take_callable_result`. `ScriptCallback::start` and `Store::take_callback_result` expose the same flow for typed callbacks. `Store::script_callback` validates the Store identity, Program instance, arity, and copied callable schema and returns a typed `ScriptCallback`. A callback can invoke directly, create a `Send` queued invocation on another thread, unsubscribe all aliases, or enter the Store FIFO through `enqueue_callback`; queue errors propagate and no implicit coalescing occurs. `Vm::shutdown` clears queued work, runtime values and host resources before invalidating every exported callable. +`Vm::invoke_callable` is the synchronous host-entry API for a callable retained while the current Program is active. For resumable work, `Vm::start_callable` returns `VmStatus`; after `Yielded` or `Waiting`, continue with `Vm::resume` and read the completed value with `Vm::take_callable_result`. `ScriptCallback::start` and `Store::take_callback_result` expose the same flow for typed callbacks. `Store::script_callback` validates Store ownership, arity, and the copied callable schema and returns a typed `ScriptCallback` with no stale identity field. A callback can invoke directly, create a `Send` queued invocation on another thread, unsubscribe all aliases, or enter the Store FIFO through `enqueue_callback`; queue errors propagate and no implicit coalescing occurs. `Vm::shutdown` clears queued work, runtime values and host resources before invalidating every exported callback through the Store registry. PDRC recordings preserve full execution-frame metadata. Callable environments use identity-table encoding, so aliases still share one environment after decode. diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index 528d4bab..0e743af6 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -13,7 +13,6 @@ pub enum VmError { InvalidLocal(u8), InvalidCall(u16), InvalidCallable, - StaleCallable, InvalidCallablePrototype(u32), CallStackOverflow, InvalidCallArity { @@ -49,7 +48,6 @@ impl fmt::Display for VmError { Self::InvalidLocal(index) => write!(f, "invalid local index: {index}"), Self::InvalidCall(index) => write!(f, "invalid call index: {index}"), Self::InvalidCallable => f.write_str("callvalue operand is not callable"), - Self::StaleCallable => f.write_str("callable belongs to another program instance"), Self::InvalidCallablePrototype(index) => { write!(f, "invalid callable prototype: {index}") } diff --git a/pd-vm-nostd/src/lib.rs b/pd-vm-nostd/src/lib.rs index 948a66ca..b312f78b 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -20,7 +20,7 @@ pub use program::{ CallablePrototype, CallableTarget, FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, ValueType, }; -pub use value::{CallableEnvironment, CallableKind, CallableValue, ProgramInstanceId, Value}; +pub use value::{CallableEnvironment, CallableKind, CallableValue, Value}; pub use vm::{Vm, VmResult, VmStatus}; pub use vmbc::decode_program; diff --git a/pd-vm-nostd/src/value.rs b/pd-vm-nostd/src/value.rs index 8a74265f..ea8a6bff 100644 --- a/pd-vm-nostd/src/value.rs +++ b/pd-vm-nostd/src/value.rs @@ -7,7 +7,6 @@ pub type SharedString = Rc; pub type SharedBytes = Rc>; pub type SharedArray = Rc>; pub type SharedMap = Rc>; -pub type ProgramInstanceId = u64; pub type SharedCallable = Rc; pub type CallableEnvironment = Rc>>; @@ -20,7 +19,6 @@ pub enum CallableKind { #[derive(Clone, Debug)] pub struct CallableValue { - pub program_instance: ProgramInstanceId, pub prototype_id: u32, pub kind: CallableKind, pub env: Option, @@ -52,9 +50,7 @@ impl PartialEq for Value { (Self::Map(lhs), Self::Map(rhs)) => lhs == rhs, (Self::Callable(lhs), Self::Callable(rhs)) => { if lhs.env.is_none() && rhs.env.is_none() { - lhs.program_instance == rhs.program_instance - && lhs.prototype_id == rhs.prototype_id - && lhs.kind == rhs.kind + lhs.prototype_id == rhs.prototype_id && lhs.kind == rhs.kind } else { Rc::ptr_eq(lhs, rhs) } diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 4d3fed30..01324f9e 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -42,7 +42,6 @@ pub struct Vm { host_dispatcher: Option>, context: C, fuel: Option, - program_instance: u64, frames: Vec, } @@ -86,7 +85,6 @@ impl Vm { host_dispatcher, context, fuel: None, - program_instance: 1, frames: Vec::new(), }; vm.initialize_root_callables(); @@ -105,7 +103,6 @@ impl Vm { let slot = binding.local_slot as usize; if let Some(local) = self.locals.get_mut(slot) { *local = Value::Callable(Rc::new(CallableValue { - program_instance: self.program_instance, prototype_id: binding.prototype_id, kind: prototype.kind, env: None, @@ -136,7 +133,6 @@ impl Vm { } let env: CallableEnvironment = Rc::new(RefCell::new(captures)); Ok(Value::Callable(Rc::new(CallableValue { - program_instance: self.program_instance, prototype_id, kind: prototype.kind, env: Some(env), @@ -157,9 +153,6 @@ impl Vm { Value::Callable(callable) => callable, _ => return Err(VmError::InvalidCallable), }; - if callable.program_instance != self.program_instance { - return Err(VmError::StaleCallable); - } let prototype = self .program .callable_prototypes() @@ -215,7 +208,6 @@ impl Vm { if slot < prototype.frame_local_count { self.locals[local_base + slot] = Value::Callable(Rc::new(CallableValue { - program_instance: self.program_instance, prototype_id: binding.prototype_id, kind: binding_prototype.kind, env: None, diff --git a/plans/function_as_value_plan.md b/plans/function_as_value_plan.md index 8ed3ff4b..a12bc1b7 100644 --- a/plans/function_as_value_plan.md +++ b/plans/function_as_value_plan.md @@ -23,14 +23,14 @@ **Complete:** - Add Program-owned callable prototypes, script entry points, parameter/local layouts, capture layouts, source metadata, and instantiated generic signatures. -- Add `Value::Callable { program_instance, prototype_id, kind, env }`; it must not own `Arc`. +- Add Program-owned `Value::Callable { prototype_id, kind, env }`. Callable values follow their Program/Store lifetime and never retain an old `Program`. - Add frame-relative locals, a real execution-frame stack, and one `Ret` rule: complete the active frame and follow its typed continuation. Root execution uses an explicit `Halt` continuation; nested RSS calls use `ResumeBytecode`; Rust invocation uses `ReturnToHost`. Never infer return meaning from an empty call stack. - Add `CallValue(argc)` as the common RSS function-item/closure invocation path; retain existing `Call` for direct host/builtin imports. - Initialize capture-free named bindings from Program metadata. - Add one shared environment-binding path for every capture-bearing callable. Introduce a dedicated runtime operation only if existing VM operations cannot bind captures. -- Validate `ProgramInstanceId` before prototype lookup; replaced/reset Program handles return `StaleCallable`. +- Keep callable values inside their owning Program/Store. Program replacement and reset invalidate the old callable registry as a lifecycle operation; callable dispatch does not compare stale identities. -**Acceptance:** Named calls, recursion, closure calls, arity failures, stale handles, return values, yield/pending, and resume all use real frames. +**Acceptance:** Named calls, recursion, closure calls, arity failures, return values, yield/pending, and resume all use real frames. ## 3. Integrate captures, types, and generics @@ -59,7 +59,7 @@ - Release environments correctly on clone, overwrite, `Drop`, unwind, collection removal, return, cancellation, and reset; reject ownership cycles until cycle collection exists. - On Program replacement, REPL installation, or reuse reset: cancel callback work, remove listeners, clear callable roots/persisted callables, drop the old Program, and invalidate all external old handles. - Add Rust APIs to resolve exported RSS functions and invoke function items or closures through one resumable path. -- Add typed `ScriptCallback` with Store identity, Program instance ID, and copied callable schema. +- Add typed `ScriptCallback` with Store ownership and copied callable schema. It follows the Program registry and stores no stale Program identity. - Serialize callbacks through the Store queue; define FIFO/coalescing, cross-thread enqueue, unsubscribe, teardown, error, and return-value policies. - Verify that the final Rust-held callback releases captures exactly once. diff --git a/plans/real_script_call_frames_plan.md b/plans/real_script_call_frames_plan.md index 227b735b..4492e8b7 100644 --- a/plans/real_script_call_frames_plan.md +++ b/plans/real_script_call_frames_plan.md @@ -29,7 +29,7 @@ First-class value typing, generic schemas, lifecycle, and retained callback APIs - Apply a hard internal format break; no backward decoder, migration, opcode reinterpretation, or compatibility branch is required: - bump VMBC `ENCODE_VERSION` from 8 to 9 and accept only version 9; - bump AOT artifact `VERSION` from 2 to 3 and native `ABI_VERSION` from 1 to 2 because Program/VM/native layouts and return control flow change; - - bump debugger recording `PDRC` from 2 to 3, accept only version 3, and remove legacy version-1 recording acceptance; + - bump debugger recording `PDRC` from 2 to 4, accept only version 4, and reject legacy versions 1 through 3; - add the bytecode ABI revision to Program/native trace cache keys together with callable tables, function regions, and slot layouts so old in-memory entries cannot match; - regenerate internal fixtures/artifacts and make every old format fail with its existing unsupported-version error. @@ -42,8 +42,8 @@ First-class value typing, generic schemas, lifecycle, and retained callback APIs **Complete:** - Add `Program.script_functions` and `Program.callable_prototypes` containing entry IP, arity, frame-local count, parameter slots, slot-location layout, capture layout, source metadata, and instantiated generic signature. -- Add `Value::Callable { program_instance, prototype_id, kind, env }`; it stores no `Arc`. -- Assign a fresh `ProgramInstanceId` on Program installation/replacement/reset. `CallValue` validates it before prototype lookup and returns `StaleCallable` for old handles. +- Add Program-owned `Value::Callable { prototype_id, kind, env }`; it stores no `Arc` and never outlives the owning Program/Store registry. +- On Program installation/replacement/reset, clear runtime callable values and invalidate external callbacks through the owning Store registry. Callable dispatch does not compare stale identities. - Add an execution-frame stack. Each frame records: - typed continuation (`Halt`, `ResumeBytecode`, or `ReturnToHost`); - prototype/function identity; @@ -52,7 +52,7 @@ First-class value typing, generic schemas, lifecycle, and retained callback APIs - active callable/environment root; - recursion, fuel/epoch, suspension, and debug state required for exact resume. - Resolve logical locals through metadata such as `SlotLocation::Frame(offset)` or `SlotLocation::Capture(cell)`. A logical slot must map to exactly one storage class in each prototype. -- On `CallValue`, validate value kind, Program instance, arity, and recursion limit; preserve caller state; allocate callee locals; bind arguments/self/environment; then enter the target. +- On `CallValue`, validate value kind, prototype, arity, and recursion limit; preserve caller state; allocate callee locals; bind arguments/self/environment; then enter the target. - On `Ret`, normalize the result, release callee roots/locals, restore the typed continuation, and either resume bytecode, return to Rust, or report halt. - Emit root code and every RSS function body once in one code blob, with explicit root/function regions. - Validate `Br` and `Brfalse` targets remain inside their current root/function region; cross-function control flow is valid only through `CallValue` and `Ret`. @@ -93,7 +93,7 @@ First-class value typing, generic schemas, lifecycle, and retained callback APIs - `Br`/`Brfalse` cannot cross root/function regions; - `Ldc` cannot deserialize Program-bound callable constants; - `Pop` and `Dup` apply normal callable/environment clone/drop ownership; - - `Ceq` compares function items by Program instance/prototype identity and closure aliases by callable/environment identity; separate closure evaluations compare unequal; + - `Ceq` compares function items by prototype identity inside their owning Program and closure aliases by callable/environment identity; separate closure evaluations compare unequal; - arithmetic, bitwise, ordering, and shift opcodes reject callable operands through existing type-error paths. - Remove or redesign the current interpreter `Call + Ret` fusion. It may run only when typed continuation/frame semantics, fuel ticks, epoch checks, result normalization, and lifecycle behavior remain identical to unfused execution. - Change the current interpreter mapping from unconditional `Ret -> Halted` to frame completion. @@ -112,7 +112,7 @@ First-class value typing, generic schemas, lifecycle, and retained callback APIs - Update assembler/disassembler, operand decoding, VMBC validation, no-std execution, AOT bundles, REPL replacement, recording/replay, formatter output, and source diagnostics. - Add function-region/prototype/frame data to debug info, stack traces, current-frame local inspection, and Rust invocation status. -- Add focused tests for root `Ret`, nested `Ret`, Rust-root `Ret`, `CallValue` stack shape, stale handles, arity/type errors, region-confined branches, slot-location validation, callable equality, `Pop`/`Dup` lifecycle, and rewritten `Call + Ret` optimization. +- Add focused tests for root `Ret`, nested `Ret`, Rust-root `Ret`, `CallValue` stack shape, Program-owned callback invalidation, arity/type errors, region-confined branches, slot-location validation, callable equality, `Pop`/`Dup` lifecycle, and rewritten `Call + Ret` optimization. - Add interpreter/JIT/AOT parity tests for direct/dynamic calls, recursion, captures, host calls, errors, yield/pending/resume, cancellation, reset, and exact drop counts. - Instrument native tests to assert zero callable-induced side exits, trace breaks, interpreter handoffs, and rejected valid AOT Programs. - Run focused suites during implementation, then formatting, workspace tests, Clippy with `-D warnings`, and release builds. diff --git a/src/builtins/runtime/core.rs b/src/builtins/runtime/core.rs index 04f76f5c..5ea5c337 100644 --- a/src/builtins/runtime/core.rs +++ b/src/builtins/runtime/core.rs @@ -912,7 +912,6 @@ mod tests { #[test] fn callable_map_keys_are_rejected() { let callable = Value::Callable(Arc::new(crate::CallableValue { - program_instance: 1, prototype_id: 0, kind: crate::CallableKind::FunctionItem, env: None, diff --git a/src/bytecode.rs b/src/bytecode.rs index fad20ae9..922d4a07 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -12,7 +12,6 @@ pub type SharedBytes = Arc>; pub type SharedArray = Arc>; pub type SharedMap = Arc; pub type SharedCallable = Arc; -pub type ProgramInstanceId = u64; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CallableKind { @@ -65,7 +64,6 @@ pub struct CallableEnvironment { #[derive(Clone, Debug)] pub struct CallableValue { - pub program_instance: ProgramInstanceId, pub prototype_id: u32, pub kind: CallableKind, pub env: Option>, @@ -296,7 +294,6 @@ fn hash_map_key(value: &Value, state: &mut impl Hasher) { } Value::Callable(callable) => { 7u8.hash(state); - callable.program_instance.hash(state); callable.prototype_id.hash(state); callable.kind.hash(state); callable.env.as_ref().map(Arc::as_ptr).hash(state); @@ -378,7 +375,6 @@ pub(crate) fn hash_value(value: &Value, state: &mut impl Hasher) { } Value::Callable(callable) => { 7u8.hash(state); - callable.program_instance.hash(state); callable.prototype_id.hash(state); callable.kind.hash(state); callable.env.as_ref().map(Arc::as_ptr).hash(state); @@ -503,10 +499,7 @@ impl PartialEq for Value { } fn callable_value_eq(lhs: &CallableValue, rhs: &CallableValue) -> bool { - if lhs.program_instance != rhs.program_instance - || lhs.prototype_id != rhs.prototype_id - || lhs.kind != rhs.kind - { + if lhs.prototype_id != rhs.prototype_id || lhs.kind != rhs.kind { return false; } match (&lhs.env, &rhs.env) { diff --git a/src/debugger/recording.rs b/src/debugger/recording.rs index 1adf70a7..be90d867 100644 --- a/src/debugger/recording.rs +++ b/src/debugger/recording.rs @@ -102,7 +102,7 @@ impl VmRecording { pub fn encode(&self) -> Result, VmRecordingError> { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 3; + const VERSION: u16 = 4; let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -167,7 +167,7 @@ impl VmRecording { pub fn decode(bytes: &[u8]) -> Result { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 3; + const VERSION: u16 = 4; let mut cursor = RecordingCursor::new(bytes); @@ -361,7 +361,6 @@ fn encode_value( } Value::Callable(callable) => { out.push(9); - out.extend_from_slice(&callable.program_instance.to_le_bytes()); out.extend_from_slice(&callable.prototype_id.to_le_bytes()); out.push(match callable.kind { crate::CallableKind::FunctionItem => 0, @@ -440,7 +439,6 @@ fn decode_value( Ok(Value::map(entries)) } 9 => { - let program_instance = cursor.read_u64()?; let prototype_id = cursor.read_u32()?; let kind = match cursor.read_u8()? { 0 => crate::CallableKind::FunctionItem, @@ -482,7 +480,6 @@ fn decode_value( _ => return Err(VmRecordingError::InvalidFormat("invalid callable env flag")), }; Ok(Value::Callable(Arc::new(crate::CallableValue { - program_instance, prototype_id, kind, env, @@ -563,7 +560,6 @@ mod tests { }); let callable = |prototype_id| { Value::Callable(Arc::new(crate::CallableValue { - program_instance: 11, prototype_id, kind: crate::CallableKind::Closure, env: Some(environment.clone()), @@ -595,16 +591,16 @@ mod tests { } #[test] - fn recording_v3_rejects_legacy_versions() { + fn recording_v4_rejects_legacy_versions() { let recording = VmRecording { program: Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]), frames: Vec::new(), terminal_status: Some(VmStatus::Halted), }; let bytes = recording.encode().expect("recording should encode"); - assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 3); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 4); - for legacy in [1u16, 2u16] { + for legacy in [1u16, 2u16, 3u16] { let mut legacy_bytes = bytes.clone(); legacy_bytes[4..6].copy_from_slice(&legacy.to_le_bytes()); assert!(matches!( diff --git a/src/lib.rs b/src/lib.rs index 2fc95134..44060d4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,8 +31,8 @@ pub use builtins::{ }; pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, - FunctionRegion, HostImport, OpCode, Program, ProgramInstanceId, RootCallableBinding, - ScriptFunction, TypeMap, Value, ValueType, + FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, + Value, ValueType, }; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 4fc3a766..c9d6b94c 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,6 +1,5 @@ use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; pub(crate) mod aot; @@ -25,7 +24,7 @@ pub use self::host::{ }; use self::host::{HostCallExecOutcome, VmHostFunction, WaitingHostOp}; pub use crate::bytecode::{ - CallableTarget, CallableValue, HostImport, OpCode, Program, ProgramInstanceId, Value, ValueType, + CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, }; use crate::bytecode::{StableHasher, hash_value}; pub use store::{ @@ -79,10 +78,7 @@ pub enum VmError { }, InvalidFrameState(&'static str), InvalidCallable, - StaleCallable { - expected: ProgramInstanceId, - found: ProgramInstanceId, - }, + InvalidCallablePrototype(u32), InvalidBranchTarget { target: usize, @@ -144,10 +140,7 @@ impl std::fmt::Display for VmError { write!(f, "invalid execution frame state: {message}") } VmError::InvalidCallable => write!(f, "callvalue operand is not callable"), - VmError::StaleCallable { expected, found } => write!( - f, - "stale callable program instance: expected {expected}, found {found}" - ), + VmError::InvalidCallablePrototype(id) => { write!(f, "invalid callable prototype {id}") } @@ -200,13 +193,8 @@ impl std::error::Error for VmError {} pub type VmResult = Result; -static NEXT_PROGRAM_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); const MAX_SCRIPT_CALL_DEPTH: usize = 1024; -fn next_program_instance_id() -> ProgramInstanceId { - NEXT_PROGRAM_INSTANCE_ID.fetch_add(1, Ordering::Relaxed) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VmStatus { Halted, @@ -348,7 +336,6 @@ pub struct Vm { resolved_calls: Vec, resolved_calls_dirty: bool, call_depth: usize, - program_instance: ProgramInstanceId, execution_frames: Vec, host_return: Option, queued_callables: VecDeque, @@ -660,7 +647,6 @@ impl Vm { resolved_calls: Vec::new(), resolved_calls_dirty: true, call_depth: 0, - program_instance: next_program_instance_id(), execution_frames: vec![ExecutionFrame::root(local_count)], host_return: None, queued_callables: VecDeque::new(), @@ -724,7 +710,6 @@ impl Vm { continue; }; *slot = Value::Callable(Arc::new(CallableValue { - program_instance: self.program_instance, prototype_id: binding.prototype_id, kind: prototype.kind, env: None, @@ -863,7 +848,6 @@ impl Vm { self.clear_stack_with_drop_contract(); self.clear_locals_with_drop_contract(); self.locals.resize(self.program.local_count, Value::Null); - self.program_instance = next_program_instance_id(); self.initialize_root_callable_bindings(); crate::builtins::runtime::close_all_handles(self); self.call_depth = 0; @@ -1127,7 +1111,6 @@ impl Vm { None }; Ok(Value::Callable(Arc::new(CallableValue { - program_instance: self.program_instance, prototype_id, kind: prototype.kind, env, @@ -1145,12 +1128,6 @@ impl Vm { let Value::Callable(callable) = callee else { return Err(VmError::InvalidCallable); }; - if callable.program_instance != self.program_instance { - return Err(VmError::StaleCallable { - expected: self.program_instance, - found: callable.program_instance, - }); - } let prototype = self .program .callable_prototypes @@ -1223,7 +1200,6 @@ impl Vm { .get(binding.prototype_id as usize) .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; self.locals[local_base + relative] = Value::Callable(Arc::new(CallableValue { - program_instance: self.program_instance, prototype_id: binding.prototype_id, kind: bound_prototype.kind, env: None, @@ -2469,23 +2445,12 @@ impl Vm { self.call_depth } - pub fn program_instance_id(&self) -> ProgramInstanceId { - self.program_instance - } - pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { if self.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } - match &callable { - Value::Callable(value) if value.program_instance == self.program_instance => {} - Value::Callable(value) => { - return Err(VmError::StaleCallable { - expected: self.program_instance, - found: value.program_instance, - }); - } - _ => return Err(VmError::InvalidCallable), + if !matches!(&callable, Value::Callable(_)) { + return Err(VmError::InvalidCallable); } self.queued_callables .push_back(QueuedCallable { callable, args }); @@ -2532,7 +2497,6 @@ impl Vm { self.host_return = None; self.waiting_host_op = None; crate::builtins::runtime::close_all_handles(self); - self.program_instance = next_program_instance_id(); self.shutdown = true; } @@ -2540,15 +2504,8 @@ impl Vm { if self.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } - match &callable { - Value::Callable(value) if value.program_instance != self.program_instance => { - return Err(VmError::StaleCallable { - expected: self.program_instance, - found: value.program_instance, - }); - } - Value::Callable(_) => {} - _ => return Err(VmError::InvalidCallable), + if !matches!(&callable, Value::Callable(_)) { + return Err(VmError::InvalidCallable); } if !self.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( diff --git a/src/vm/store.rs b/src/vm/store.rs index 13169f8e..229fb4c1 100644 --- a/src/vm/store.rs +++ b/src/vm/store.rs @@ -2,8 +2,8 @@ use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use crate::Value; use crate::compiler::TypeSchema; -use crate::{ProgramInstanceId, Value}; use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmError, VmResult, VmStatus}; @@ -32,7 +32,6 @@ pub trait ScriptResult: Sized { pub struct ScriptCallback, Ret = Value> { store_id: u64, - program_instance: ProgramInstanceId, callable: Value, schema: Option, subscribed: Arc, @@ -41,7 +40,6 @@ pub struct ScriptCallback, Ret = Value> { pub struct QueuedScriptInvocation { store_id: u64, - program_instance: ProgramInstanceId, callable: Value, args: Vec, } @@ -50,7 +48,6 @@ impl Clone for ScriptCallback { fn clone(&self) -> Self { Self { store_id: self.store_id, - program_instance: self.program_instance, callable: self.callable.clone(), schema: self.schema.clone(), subscribed: Arc::clone(&self.subscribed), @@ -102,7 +99,6 @@ where } Ok(QueuedScriptInvocation { store_id: self.store_id, - program_instance: self.program_instance, callable: self.callable.clone(), args: args.into_values(), }) @@ -263,12 +259,6 @@ impl Store { let Value::Callable(value) = &callable else { return Err(VmError::InvalidCallable); }; - if value.program_instance != self.vm.program_instance_id() { - return Err(VmError::StaleCallable { - expected: self.vm.program_instance_id(), - found: value.program_instance, - }); - } let prototype = self .vm .program() @@ -286,7 +276,6 @@ impl Store { } Ok(ScriptCallback { store_id: self.id, - program_instance: value.program_instance, callable, schema: prototype.schema.clone(), subscribed: Arc::new(AtomicBool::new(true)), @@ -316,12 +305,6 @@ impl Store { "script callback belongs to another store", )); } - if invocation.program_instance != self.vm.program_instance_id() { - return Err(VmError::StaleCallable { - expected: self.vm.program_instance_id(), - found: invocation.program_instance, - }); - } Ok(()) } diff --git a/src/vm/tests.rs b/src/vm/tests.rs index c1c49180..30ac6e19 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -100,7 +100,7 @@ fn callvalue_enters_script_frame_and_resumes_caller() { } #[test] -fn host_can_invoke_exported_callable_and_reset_makes_it_stale() { +fn host_can_invoke_exported_callable_and_reset_rebinds_program_owned_value() { let mut bc = crate::BytecodeBuilder::new(); bc.ret(); let entry = bc.position(); @@ -170,10 +170,13 @@ fn host_can_invoke_exported_callable_and_reset_makes_it_stale() { )); vm.reset_for_reuse(); - assert!(matches!( - vm.invoke_callable(callable, &[Value::Int(1)]), - Err(VmError::StaleCallable { .. }) - )); + assert_eq!(vm.run().expect("reset root should halt"), VmStatus::Halted); + let rebound = vm.locals()[0].clone(); + assert_eq!( + vm.invoke_callable(rebound, &[Value::Int(1)]) + .expect("reset should rebind the Program-owned function item"), + Value::Int(2) + ); } #[cfg(feature = "cranelift-jit")] @@ -356,9 +359,6 @@ fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); let callable = vm.stack().last().cloned().expect("callable result"); let mut store = crate::Store::from_vm(vm); - let stale_callback: crate::ScriptCallback<(i64,), i64> = store - .script_callback(callable.clone()) - .expect("second callback should bind"); let callback: crate::ScriptCallback<(i64,), i64> = store .script_callback(callable.clone()) .expect("typed callback should bind"); @@ -393,12 +393,6 @@ fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { "script callback is unsubscribed" )) )); - - store.vm_mut().reset_for_reuse(); - assert!(matches!( - stale_callback.call(&mut store, (1,)), - Err(VmError::StaleCallable { .. }) - )); } #[test] From 5289e3677ef6fcf882905f328379325985fa8c75 Mon Sep 17 00:00:00 2001 From: fffonion Date: Fri, 17 Jul 2026 23:33:03 +0800 Subject: [PATCH 11/18] feat(vm): implement shared callable capture semantics --- build.rs | 19 +- pd-vm-nostd/src/error.rs | 4 + pd-vm-nostd/src/lib.rs | 4 +- pd-vm-nostd/src/program.rs | 11 + pd-vm-nostd/src/value.rs | 3 +- pd-vm-nostd/src/vm.rs | 117 ++++++--- pd-vm-nostd/src/vmbc.rs | 32 ++- src/builtins/runtime/core.rs | 6 + src/builtins/runtime/mod.rs | 9 + src/bytecode.rs | 14 +- src/cli.rs | 23 +- src/compiler/codegen.rs | 56 +++- src/compiler/lifetime/availability.rs | 25 +- .../lifetime/availability/captures.rs | 48 +++- src/compiler/lifetime/liveness.rs | 175 ++++++++++++- src/compiler/lifetime/mod.rs | 2 + src/compiler/parser/expressions.rs | 240 +++++++++++++++++- src/compiler/parser/statements.rs | 5 +- src/compiler/typing/helpers.rs | 27 +- src/compiler/typing/validate.rs | 126 +++++++++ src/debugger/recording.rs | 9 +- src/lib.rs | 4 +- src/vm/aot/ir.rs | 2 +- src/vm/aot/ssa.rs | 2 +- src/vm/jit/trace.rs | 2 + src/vm/mod.rs | 138 +++++++--- src/vm/native/bridge.rs | 1 - src/vm/tests.rs | 25 ++ src/vmbc.rs | 34 ++- tests/common/mod.rs | 12 +- tests/compiler/compiler_rustscript_tests.rs | 120 +++++++++ tests/wire/wire_tests.rs | 2 + 32 files changed, 1186 insertions(+), 111 deletions(-) diff --git a/build.rs b/build.rs index 245bb434..00b550e9 100644 --- a/build.rs +++ b/build.rs @@ -672,6 +672,11 @@ fn render_builtin_catalog( " (BUILTIN_CALL_BASE - 14, BuiltinFunction::BindCallable)," ) .unwrap(); + writeln!( + &mut out, + " (BUILTIN_CALL_BASE - 15, BuiltinFunction::DetachLocal)," + ) + .unwrap(); writeln!(&mut out, "];").unwrap(); writeln!(&mut out).unwrap(); @@ -870,6 +875,11 @@ fn render_builtin_catalog( " BuiltinFunction::BindCallable => BUILTIN_CALL_BASE - 14," ) .unwrap(); + writeln!( + &mut out, + " BuiltinFunction::DetachLocal => BUILTIN_CALL_BASE - 15," + ) + .unwrap(); writeln!( &mut out, " _ => BUILTIN_CALL_BASE + self as u16," @@ -1503,6 +1513,7 @@ fn appended_builtin_order() -> &'static [&'static str] { "__map_iter_take_value", "__map_iter_close", "__bind_callable", + "__detach_local", ] } @@ -1529,7 +1540,12 @@ fn required_language_builtin_stubs() -> &'static [&'static str] { } fn required_internal_builtin_stubs() -> &'static [&'static str] { - &["__format_template", "__to_string", "__bind_callable"] + &[ + "__format_template", + "__to_string", + "__bind_callable", + "__detach_local", + ] } fn is_language_builtin_stub_name(name: &str) -> bool { @@ -1674,6 +1690,7 @@ fn main_range_builtin_variants(builtin_variant_order: &[String]) -> Vec | "MapIterTakeValue" | "MapIterClose" | "BindCallable" + | "DetachLocal" ) }) .cloned() diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index 0e743af6..bb748abd 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -88,6 +88,7 @@ pub enum WireError { InvalidTypeMapFlag(u8), InvalidDebugFlag(u8), InvalidValueType(u8), + InvalidCaptureBindingMode(u8), InvalidUtf8, LengthTooLarge(&'static str, usize), SchemaTooDeep, @@ -108,6 +109,9 @@ impl fmt::Display for WireError { Self::InvalidTypeMapFlag(value) => write!(f, "invalid type-map flag: {value}"), Self::InvalidDebugFlag(value) => write!(f, "invalid debug flag: {value}"), Self::InvalidValueType(value) => write!(f, "invalid value type: {value}"), + Self::InvalidCaptureBindingMode(value) => { + write!(f, "invalid capture binding mode: {value}") + } 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/lib.rs b/pd-vm-nostd/src/lib.rs index b312f78b..f569af46 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -17,8 +17,8 @@ mod vmbc; pub use error::{VmError, WireError}; pub use host::{HostBinding, HostDispatcher, HostError, HostFunction}; pub use program::{ - CallablePrototype, CallableTarget, FunctionRegion, HostImport, OpCode, Program, - RootCallableBinding, ScriptFunction, ValueType, + CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, HostImport, OpCode, + Program, RootCallableBinding, ScriptFunction, ValueType, }; pub use value::{CallableEnvironment, CallableKind, CallableValue, Value}; pub use vm::{Vm, VmResult, VmStatus}; diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index b434a2dc..b7c51307 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -44,6 +44,15 @@ pub enum CallableTarget { HostImport(u16), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum CaptureBindingMode { + Copy = 0, + Borrow = 1, + BorrowMut = 2, + Move = 3, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScriptFunction { pub entry_ip: u32, @@ -57,7 +66,9 @@ pub struct CallablePrototype { pub arity: u8, pub frame_local_count: usize, pub parameter_slots: Vec, + pub capture_source_slots: Vec, pub capture_slots: Vec, + pub capture_modes: Vec, pub self_slot: Option, } diff --git a/pd-vm-nostd/src/value.rs b/pd-vm-nostd/src/value.rs index ea8a6bff..006bbd39 100644 --- a/pd-vm-nostd/src/value.rs +++ b/pd-vm-nostd/src/value.rs @@ -8,7 +8,8 @@ pub type SharedBytes = Rc>; pub type SharedArray = Rc>; pub type SharedMap = Rc>; pub type SharedCallable = Rc; -pub type CallableEnvironment = Rc>>; +pub type SharedCaptureCell = Rc>; +pub type CallableEnvironment = Rc>; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CallableKind { diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 01324f9e..659b4d92 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -1,5 +1,6 @@ use core::cell::RefCell; +use alloc::collections::BTreeMap; use alloc::rc::Rc; use alloc::string::String; use alloc::vec; @@ -30,7 +31,6 @@ struct ExecutionFrame { local_base: usize, local_count: usize, prototype_id: u32, - active_callable: Rc, } pub struct Vm { @@ -38,6 +38,7 @@ pub struct Vm { ip: usize, stack: Vec, locals: Vec, + capture_cells: BTreeMap>>, host_functions: Vec>, host_dispatcher: Option>, context: C, @@ -81,6 +82,7 @@ impl Vm { ip: 0, stack: Vec::new(), locals: vec![Value::Null; local_count], + capture_cells: BTreeMap::new(), host_functions, host_dispatcher, context, @@ -122,16 +124,50 @@ impl Vm { .ok_or(VmError::InvalidLocal(index)) } - fn bind_callable_value(&self, prototype_id: u32, captures: Vec) -> VmResult { + fn bind_callable_value(&mut self, prototype_id: u32, captures: Vec) -> VmResult { let prototype = self .program .callable_prototypes() .get(prototype_id as usize) + .cloned() .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; - if captures.len() != prototype.capture_slots.len() { + if captures.len() != prototype.capture_slots.len() + || captures.len() != prototype.capture_source_slots.len() + || captures.len() != prototype.capture_modes.len() + { return Err(VmError::InvalidCallablePrototype(prototype_id)); } - let env: CallableEnvironment = Rc::new(RefCell::new(captures)); + let active_base = self.active_local_base(); + let mut cells = Vec::with_capacity(captures.len()); + for (((value, source), target), mode) in captures + .into_iter() + .zip(&prototype.capture_source_slots) + .zip(&prototype.capture_slots) + .zip(&prototype.capture_modes) + { + let self_capture = prototype.self_slot == Some(*target); + let cell = if !self_capture + && matches!( + mode, + super::CaptureBindingMode::Borrow | super::CaptureBindingMode::BorrowMut + ) { + let absolute = active_base.saturating_add(usize::from(*source)); + if absolute >= self.locals.len() { + return Err(VmError::InvalidCallablePrototype(prototype_id)); + } + let cell = self + .capture_cells + .entry(absolute) + .or_insert_with(|| Rc::new(RefCell::new(value))) + .clone(); + self.locals[absolute] = cell.borrow().clone(); + cell + } else { + Rc::new(RefCell::new(value)) + }; + cells.push(cell); + } + let env: CallableEnvironment = Rc::new(cells); Ok(Value::Callable(Rc::new(CallableValue { prototype_id, kind: prototype.kind, @@ -228,16 +264,16 @@ impl Vm { self.locals[local_base + slot] = argument; } if let Some(env) = &callable.env { - for (slot, value) in prototype - .capture_slots - .iter() - .zip(env.borrow().iter().cloned()) - { + for (slot, cell) in prototype.capture_slots.iter().zip(env.iter()) { let slot = *slot as usize; if slot >= prototype.frame_local_count { return Err(VmError::InvalidCallablePrototype(callable.prototype_id)); } - self.locals[local_base + slot] = value; + let absolute = local_base + slot; + self.locals[absolute] = cell.borrow().clone(); + if prototype.self_slot != Some(slot as u16) { + self.capture_cells.insert(absolute, cell.clone()); + } } } if let Some(slot) = prototype.self_slot { @@ -253,7 +289,6 @@ impl Vm { local_base, local_count: prototype.frame_local_count, prototype_id: callable.prototype_id, - active_callable: callable, }); self.ip = function.entry_ip as usize; Ok(()) @@ -274,26 +309,9 @@ impl Vm { Value::Null }; self.stack.truncate(frame.operand_stack_base); - if let Some(environment) = frame.active_callable.env.as_ref() - && let Some(prototype) = self - .program - .callable_prototypes() - .get(frame.prototype_id as usize) - { - let mut cells = environment.borrow_mut(); - for (cell, slot) in cells.iter_mut().zip(&prototype.capture_slots) { - if prototype.self_slot == Some(*slot) { - continue; - } - let absolute = frame.local_base.saturating_add(*slot as usize); - let value = self - .locals - .get(absolute) - .cloned() - .ok_or(VmError::InvalidCallablePrototype(frame.prototype_id))?; - *cell = value; - } - } + let frame_end = frame.local_base.saturating_add(frame.local_count); + self.capture_cells + .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); self.locals.truncate(frame.local_base); self.ip = frame.return_ip; self.stack.push(result); @@ -358,12 +376,19 @@ impl Vm { OpCode::Ldloc => { let index = self.read_u8()?; let absolute = self.absolute_local(index)?; - self.stack.push(self.locals[absolute].clone()); + let value = self.capture_cells.get(&absolute).map_or_else( + || self.locals[absolute].clone(), + |cell| cell.borrow().clone(), + ); + self.stack.push(value); } OpCode::Stloc => { let index = self.read_u8()?; let absolute = self.absolute_local(index)?; let value = self.pop()?; + if let Some(cell) = self.capture_cells.get(&absolute) { + *cell.borrow_mut() = value.clone(); + } self.locals[absolute] = value; } @@ -520,6 +545,7 @@ impl Vm { const MAP_NEW: u16 = BUILTIN_BASE + 5; const SET: u16 = BUILTIN_BASE + 8; const BIND_CALLABLE: u16 = BUILTIN_BASE - 14; + const DETACH_LOCAL: u16 = BUILTIN_BASE - 15; Some(match index { ARRAY_NEW => { @@ -577,6 +603,30 @@ impl Vm { self.stack.push(Value::Map(entries)); Ok(()) } + DETACH_LOCAL => { + if let Err(error) = self.require_builtin_arity("__detach_local", arity, 1) { + return Some(Err(error)); + } + let start = match self.stack.len().checked_sub(1) { + Some(start) => start, + None => return Some(Err(VmError::StackUnderflow)), + }; + let slot = match self.stack.get(start) { + Some(Value::Int(value)) => match u8::try_from(*value) { + Ok(value) => value, + Err(_) => return Some(Err(VmError::TypeMismatch("local slot"))), + }, + _ => return Some(Err(VmError::TypeMismatch("local slot"))), + }; + let absolute = match self.absolute_local(slot) { + Ok(value) => value, + Err(error) => return Some(Err(error)), + }; + self.capture_cells.remove(&absolute); + self.locals[absolute] = Value::Null; + self.stack.truncate(start); + Ok(()) + } BIND_CALLABLE => { if let Err(error) = self.require_builtin_arity("__bind_callable", arity, 2) { return Some(Err(error)); @@ -743,6 +793,9 @@ impl Vm { fn store_local(&mut self, index: u8, value: Value) -> VmResult<()> { let absolute = self.absolute_local(index)?; + if let Some(cell) = self.capture_cells.get(&absolute) { + *cell.borrow_mut() = value.clone(); + } self.locals[absolute] = value; Ok(()) } diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 3d4512c2..7a144ee2 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -2,8 +2,8 @@ use alloc::string::String; use alloc::vec::Vec; use super::{ - CallableKind, CallablePrototype, CallableTarget, FunctionRegion, HostImport, Program, - RootCallableBinding, ScriptFunction, Value, ValueType, WireError, + CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, + HostImport, Program, RootCallableBinding, ScriptFunction, Value, ValueType, WireError, }; const MAGIC: [u8; 4] = *b"VMBC"; @@ -223,12 +223,38 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result CaptureBindingMode::Copy, + 1 => CaptureBindingMode::Borrow, + 2 => CaptureBindingMode::BorrowMut, + 3 => CaptureBindingMode::Move, + other => return Err(WireError::InvalidCaptureBindingMode(other)), + }); + } let self_slot = cursor.read_bool()?.then(|| cursor.read_u16()).transpose()?; if cursor.read_bool()? { skip_schema(cursor, 0)?; @@ -239,7 +265,9 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result) captures.to_vec() } +/// Detach a moved local slot from any shared capture cell. +#[allow(dead_code)] +#[pd_host_function(name = "__detach_local")] +fn builtin_detach_local_metadata(_slot: i64) {} + /// Return the length of a string, array, or map. #[pd_host_function(name = "len")] pub(super) fn builtin_len_string_impl(text: VmStringRef<'_>) -> i64 { @@ -898,6 +903,7 @@ mod tests { (BuiltinFunction::MapIterTakeValue, BUILTIN_CALL_BASE - 12), (BuiltinFunction::MapIterClose, BUILTIN_CALL_BASE - 13), (BuiltinFunction::BindCallable, BUILTIN_CALL_BASE - 14), + (BuiltinFunction::DetachLocal, BUILTIN_CALL_BASE - 15), ]; for (builtin, index) in reserved { assert_eq!(builtin.call_index(), index); diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 214806ef..8e1d8f4a 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -90,6 +90,15 @@ pub(crate) fn execute_builtin_call( vm.bind_callable_value(prototype_id, captures) .map(|value| BuiltinCallOutcome::Return(return_one(value))) } + BuiltinFunction::DetachLocal => { + let slot = match args.first() { + Some(Value::Int(value)) => u8::try_from(*value) + .map_err(|_| crate::vm::VmError::TypeMismatch("local slot"))?, + _ => return Err(crate::vm::VmError::TypeMismatch("local slot")), + }; + vm.detach_local_with_drop_contract(slot)?; + Ok(BuiltinCallOutcome::Return(return_none())) + } BuiltinFunction::StringContains => core::builtin_string_contains(args) .map(IntoBuiltinCallOutcome::into_builtin_call_outcome), BuiltinFunction::StringReplaceLiteral => core::builtin_string_replace_literal(args) diff --git a/src/bytecode.rs b/src/bytecode.rs index 922d4a07..d161685a 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -12,6 +12,7 @@ pub type SharedBytes = Arc>; pub type SharedArray = Arc>; pub type SharedMap = Arc; pub type SharedCallable = Arc; +pub type SharedCaptureCell = Arc>; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CallableKind { @@ -20,6 +21,15 @@ pub enum CallableKind { HostFunction, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum CaptureBindingMode { + Copy = 0, + Borrow = 1, + BorrowMut = 2, + Move = 3, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CallableTarget { ScriptFunction(u32), @@ -39,7 +49,9 @@ pub struct CallablePrototype { pub arity: u8, pub frame_local_count: usize, pub parameter_slots: Vec, + pub capture_source_slots: Vec, pub capture_slots: Vec, + pub capture_modes: Vec, pub self_slot: Option, pub schema: Option, } @@ -59,7 +71,7 @@ pub struct RootCallableBinding { #[derive(Debug)] pub struct CallableEnvironment { - pub(crate) cells: std::sync::Mutex>, + pub(crate) cells: std::sync::Mutex>, } #[derive(Clone, Debug)] diff --git a/src/cli.rs b/src/cli.rs index 0eba9b3a..e6c741f0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1100,8 +1100,29 @@ fn repl_locals_moved_by_rebinding( .is_some_and(|value| value == &Value::Null) && program.code.get(ip + 7).copied() == Some(OpCode::Stloc as u8) && program.code.get(ip + 8).copied() == Some(source); - if null_store { + let detach_local = program + .code + .get(ip + 2) + .copied() + .and_then(|byte| OpCode::try_from(byte).ok()) + .filter(|opcode| *opcode == OpCode::Ldc) + .and_then(|_| program.code.get(ip + 3..ip + 7)) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .and_then(|index| program.constants.get(index as usize)) + .is_some_and(|value| value == &Value::Int(i64::from(source))) + && program.code.get(ip + 7).copied() == Some(OpCode::Call as u8) + && program + .code + .get(ip + 8..ip + 10) + .and_then(|bytes| bytes.try_into().ok()) + .map(u16::from_le_bytes) + == Some(crate::builtins::BuiltinFunction::DetachLocal.call_index()) + && program.code.get(ip + 10).copied() == Some(1); + if null_store || detach_local { moved.insert(name.clone()); + } + if null_store { move_store_offsets.insert(ip + 7); } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 03282da5..79e40754 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -263,11 +263,23 @@ impl Compiler { arity: function_impl.param_slots.len() as u8, frame_local_count: self.frame_local_count, parameter_slots: function_impl.param_slots.clone(), + capture_source_slots: function_impl + .capture_copies + .iter() + .map(|(source, _)| *source) + .collect(), capture_slots: function_impl .capture_copies .iter() .map(|(_, target)| *target) .collect(), + capture_modes: function_impl + .capture_copies + .iter() + .map(|(_, target)| { + super::lifetime::function_capture_binding_mode(function_impl, *target) + }) + .collect(), self_slot: Some(hidden_slot), schema: decl.map(|decl| TypeSchema::Callable { params: decl @@ -1254,6 +1266,25 @@ impl Compiler { index: u16, type_args: &[TypeSchema], ) -> Result { + if type_args.is_empty() + && self + .function_decls + .get(&index) + .is_some_and(|decl| !decl.type_params.is_empty()) + { + let name = self + .function_decls + .get(&index) + .map(|decl| decl.name.as_str()) + .unwrap_or(""); + return Err(CompileError::CallableArgumentTypeMismatch { + line: None, + source_name: None, + detail: format!( + "generic function value '{name}' requires explicit type arguments or an unambiguous callable context" + ), + }); + } if !type_args.is_empty() && let Some((_, _, slot)) = self .specialized_function_slots @@ -1289,7 +1320,9 @@ impl Compiler { arity, frame_local_count: self.frame_local_count, parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), capture_slots: Vec::new(), + capture_modes: Vec::new(), self_slot: None, schema: self.instantiated_callable_schema(index, type_args), }); @@ -1457,18 +1490,34 @@ impl Compiler { .map_err(|_| CompileError::CallArityOverflow)?, frame_local_count: self.frame_local_count, parameter_slots: closure.param_slots.clone(), + capture_source_slots: closure + .capture_copies + .iter() + .map(|(source, _)| *source) + .collect(), capture_slots: closure .capture_copies .iter() .map(|(_, target)| *target) .collect(), + capture_modes: closure + .capture_copies + .iter() + .map(|(_, target)| super::lifetime::closure_capture_binding_mode(closure, *target)) + .collect(), self_slot: binding_slot.and_then(|binding_slot| { closure .capture_copies .iter() .find_map(|(source, target)| (*source == binding_slot).then_some(*target)) }), - schema: None, + schema: binding_slot.and_then(|slot| { + self.type_map + .local_schemas + .get(slot as usize) + .cloned() + .flatten() + }), }); self.pending_closures.push((prototype_id, closure.clone())); self.emit_bind_callable( @@ -1743,8 +1792,9 @@ impl Compiler { fn emit_move_ldloc(&mut self, slot: LocalSlot) -> Result<(), CompileError> { let operand = local_slot_operand(slot)?; self.assembler.ldloc(operand); - self.assembler.push_const(Value::Null); - self.assembler.stloc(operand); + self.assembler.push_const(Value::Int(i64::from(operand))); + self.assembler + .call(BuiltinFunction::DetachLocal.call_index(), 1); Ok(()) } diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index 3c8dc76a..e9f977bf 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -2,6 +2,7 @@ use std::cell::Cell; use std::collections::{HashMap, HashSet}; use crate::builtins::BuiltinFunction; +use crate::bytecode::CaptureBindingMode; use super::super::ParseError; use super::super::ir::{ClosureExpr, Expr, FrontendIr, FunctionImpl, LocalSlot, Stmt}; @@ -60,14 +61,6 @@ enum MovedFieldKey { Slice, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum CaptureBindingMode { - Copy, - Borrow, - BorrowMut, - Move, -} - impl FlowState { fn reachable(local_count: usize) -> Self { Self { @@ -158,6 +151,22 @@ pub(super) fn enforce_local_availability( Ok(ir) } +pub(crate) fn function_capture_binding_mode( + function_impl: &FunctionImpl, + captured_slot: LocalSlot, +) -> CaptureBindingMode { + AvailabilityAnalyzer::new(0, &[], &HashMap::new(), false) + .runtime_function_capture_mode_for_slot(function_impl, captured_slot) +} + +pub(crate) fn closure_capture_binding_mode( + closure: &ClosureExpr, + captured_slot: LocalSlot, +) -> CaptureBindingMode { + AvailabilityAnalyzer::new(0, &[], &HashMap::new(), false) + .runtime_closure_capture_mode_for_slot(closure, captured_slot) +} + struct AvailabilityAnalyzer { local_count: usize, local_names: HashMap, diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index 410ec9b0..f234fc17 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -181,6 +181,47 @@ impl AvailabilityAnalyzer { if seen { mode } else { CaptureBindingMode::Move } } + pub(super) fn runtime_function_capture_mode_for_slot( + &self, + 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 } + } + + pub(super) fn runtime_closure_capture_mode_for_slot( + &self, + 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 } + } + pub(super) fn capture_mode_for_stmts( &self, stmts: &[Stmt], @@ -216,7 +257,12 @@ impl AvailabilityAnalyzer { Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { if *index == captured_slot { *seen = true; - *mode = (*mode).max(context); + let assignment_mode = if context == CaptureBindingMode::Move { + CaptureBindingMode::Move + } else { + CaptureBindingMode::BorrowMut + }; + *mode = (*mode).max(assignment_mode); } self.capture_mode_for_expr(expr, captured_slot, context, mode, seen); } diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 6222e739..51ff66e4 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -1623,6 +1623,11 @@ pub(super) fn persistent_capture_slots( function_impls: &HashMap, ) -> Vec { let mut slots = BTreeSet::new(); + collect_persistent_closure_sources_from_stmts(stmts, &mut slots); + for function_impl in function_impls.values() { + collect_persistent_closure_sources_from_stmts(&function_impl.body_stmts, &mut slots); + collect_persistent_closure_sources_from_expr(&function_impl.body_expr, &mut slots); + } for stmt in stmts { let Stmt::FuncDecl { index, has_impl, .. @@ -1636,13 +1641,181 @@ pub(super) fn persistent_capture_slots( let Some(function_impl) = function_impls.get(index) else { continue; }; - for (_, captured_slot) in &function_impl.capture_copies { + for (source_slot, captured_slot) in &function_impl.capture_copies { + slots.insert(*captured_slot); + if matches!( + super::availability::function_capture_binding_mode(function_impl, *captured_slot), + crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut + ) { + slots.insert(*source_slot); + } + } + } + for function_impl in function_impls.values() { + for (source_slot, captured_slot) in &function_impl.capture_copies { slots.insert(*captured_slot); + if matches!( + super::availability::function_capture_binding_mode(function_impl, *captured_slot), + crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut + ) { + slots.insert(*source_slot); + } } } slots.into_iter().collect() } +fn collect_persistent_closure_sources( + closure: &super::super::ir::ClosureExpr, + slots: &mut BTreeSet, +) { + for (source_slot, captured_slot) in &closure.capture_copies { + slots.insert(*captured_slot); + if matches!( + super::availability::closure_capture_binding_mode(closure, *captured_slot), + crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut + ) { + slots.insert(*source_slot); + } + } + collect_persistent_closure_sources_from_expr(&closure.body, slots); +} + +fn collect_persistent_closure_sources_from_stmts(stmts: &[Stmt], slots: &mut BTreeSet) { + for stmt in stmts { + match stmt { + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => {} + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + collect_persistent_closure_sources_from_expr(expr, slots); + } + Stmt::ClosureLet { closure, .. } => { + collect_persistent_closure_sources(closure, slots); + } + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + collect_persistent_closure_sources_from_expr(condition, slots); + collect_persistent_closure_sources_from_stmts(then_branch, slots); + collect_persistent_closure_sources_from_stmts(else_branch, slots); + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + collect_persistent_closure_sources_from_stmts( + core::slice::from_ref(init.as_ref()), + slots, + ); + collect_persistent_closure_sources_from_expr(condition, slots); + collect_persistent_closure_sources_from_stmts( + core::slice::from_ref(post.as_ref()), + slots, + ); + collect_persistent_closure_sources_from_stmts(body, slots); + } + Stmt::While { + condition, body, .. + } => { + collect_persistent_closure_sources_from_expr(condition, slots); + collect_persistent_closure_sources_from_stmts(body, slots); + } + } + } +} + +fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSet) { + match expr { + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(..) + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + Expr::OptionalGet { container, key, .. } => { + collect_persistent_closure_sources_from_expr(container, slots); + collect_persistent_closure_sources_from_expr(key, slots); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + collect_persistent_closure_sources_from_expr(value, slots); + collect_persistent_closure_sources_from_expr(fallback, slots); + } + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + for arg in args { + collect_persistent_closure_sources_from_expr(arg, slots); + } + } + Expr::Closure(closure) => collect_persistent_closure_sources(closure, slots), + Expr::ClosureCall(closure, args) => { + collect_persistent_closure_sources(closure, slots); + for arg in args { + collect_persistent_closure_sources_from_expr(arg, slots); + } + } + 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) => { + collect_persistent_closure_sources_from_expr(lhs, slots); + collect_persistent_closure_sources_from_expr(rhs, slots); + } + Expr::Neg(value) + | Expr::Not(value) + | Expr::ToOwned(value) + | Expr::Borrow(value) + | Expr::BorrowMut(value) => { + collect_persistent_closure_sources_from_expr(value, slots); + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + collect_persistent_closure_sources_from_expr(condition, slots); + collect_persistent_closure_sources_from_expr(then_expr, slots); + collect_persistent_closure_sources_from_expr(else_expr, slots); + } + Expr::Match { + value, + arms, + default, + .. + } => { + collect_persistent_closure_sources_from_expr(value, slots); + for (_, arm) in arms { + collect_persistent_closure_sources_from_expr(arm, slots); + } + collect_persistent_closure_sources_from_expr(default, slots); + } + Expr::Block { stmts, expr } => { + collect_persistent_closure_sources_from_stmts(stmts, slots); + collect_persistent_closure_sources_from_expr(expr, slots); + } + } +} + fn remap_slot(index: LocalSlot, mapping: &[LocalSlot]) -> Result { let slot = index as usize; mapping.get(slot).copied().ok_or(ParseError { diff --git a/src/compiler/lifetime/mod.rs b/src/compiler/lifetime/mod.rs index 30e5c8aa..b4e57dd1 100644 --- a/src/compiler/lifetime/mod.rs +++ b/src/compiler/lifetime/mod.rs @@ -4,6 +4,8 @@ mod liveness; use super::ParseError; use super::ir::{FrontendIr, LocalSlot}; +pub(crate) use availability::{closure_capture_binding_mode, function_capture_binding_mode}; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct EntryLocalAvailability { pub slot: LocalSlot, diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index fc294e36..451b1d57 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -525,7 +525,9 @@ impl Parser { let index = self.get_local(&name)?; Expr::Var(index) } else if let Some(decl) = self.functions.get(&name).cloned() { - self.validate_named_call_type_args(&decl, &type_args)?; + if !type_args.is_empty() { + self.validate_named_call_type_args(&decl, &type_args)?; + } Expr::FunctionRef(decl.index, type_args) } else if let Some(index) = crate::builtin_call_index(&name) { if !type_args.is_empty() { @@ -549,6 +551,7 @@ impl Parser { } } }; + self.contextualize_function_call_args(&mut expr)?; expr = self.parse_postfix_access(expr)?; return Ok(expr); } @@ -1618,6 +1621,241 @@ impl Parser { Ok(Expr::Call(decl.index, type_args, args)) } + pub(super) fn contextualize_function_value( + &self, + expr: &mut Expr, + expected: &TypeSchema, + ) -> Result<(), ParseError> { + match expr { + Expr::FunctionRef(index, type_args) if type_args.is_empty() => { + let Some(decl) = self + .functions + .values() + .find(|decl| decl.index == *index) + .cloned() + else { + return Ok(()); + }; + if decl.type_params.is_empty() { + return Ok(()); + } + let Some(inferred) = Self::infer_contextual_function_type_args(&decl, expected) + else { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!( + "generic function value '{}' cannot resolve type arguments from the expected callable schema", + decl.name + ), + }); + }; + *type_args = inferred; + } + Expr::IfElse { + then_expr, + else_expr, + .. + } => { + self.contextualize_function_value(then_expr, expected)?; + self.contextualize_function_value(else_expr, expected)?; + } + Expr::Match { arms, default, .. } => { + for (_, arm) in arms { + self.contextualize_function_value(arm, expected)?; + } + self.contextualize_function_value(default, expected)?; + } + Expr::Block { expr, .. } => { + self.contextualize_function_value(expr, expected)?; + } + _ => {} + } + Ok(()) + } + + fn contextualize_function_call_args(&self, expr: &mut Expr) -> Result<(), ParseError> { + let Expr::Call(index, type_args, args) = expr else { + return Ok(()); + }; + let Some(decl) = self + .functions + .values() + .find(|decl| decl.index == *index) + .cloned() + else { + return Ok(()); + }; + if decl.type_params.len() != type_args.len() { + return Ok(()); + } + let bindings = decl + .type_params + .iter() + .cloned() + .zip(type_args.iter().cloned()) + .collect::>(); + for (arg, expected) in args.iter_mut().zip(&decl.arg_schemas) { + let Some(expected) = expected else { + continue; + }; + let expected = Self::substitute_contextual_schema(expected, &bindings); + self.contextualize_function_value(arg, &expected)?; + } + Ok(()) + } + + fn infer_contextual_function_type_args( + decl: &FunctionDecl, + expected: &TypeSchema, + ) -> Option> { + let TypeSchema::Callable { params, result } = expected.clone_inner_if_optional() else { + return None; + }; + if params.len() != decl.arg_schemas.len() { + return None; + } + let mut bindings = HashMap::new(); + for (template, actual) in decl.arg_schemas.iter().zip(¶ms) { + let template = template.as_ref().unwrap_or(&TypeSchema::Unknown); + if !Self::unify_contextual_schema(template, actual, &decl.type_params, &mut bindings) { + return None; + } + } + let template_result = decl.return_schema.as_ref().unwrap_or(&TypeSchema::Unknown); + if !Self::unify_contextual_schema( + template_result, + result.as_ref(), + &decl.type_params, + &mut bindings, + ) { + return None; + } + decl.type_params + .iter() + .map(|name| bindings.get(name).cloned()) + .collect() + } + + fn unify_contextual_schema( + template: &TypeSchema, + actual: &TypeSchema, + type_params: &[String], + bindings: &mut HashMap, + ) -> bool { + if let TypeSchema::GenericParam(name) = template + && type_params.contains(name) + { + if matches!(actual, TypeSchema::Unknown | TypeSchema::GenericParam(_)) { + return false; + } + return bindings.get(name).is_none_or(|bound| bound == actual) && { + bindings + .entry(name.clone()) + .or_insert_with(|| actual.clone()); + true + }; + } + match (template, actual) { + (TypeSchema::Unknown, _) | (_, TypeSchema::Unknown) => true, + (TypeSchema::Optional(lhs), TypeSchema::Optional(rhs)) + | (TypeSchema::Array(lhs), TypeSchema::Array(rhs)) + | (TypeSchema::Map(lhs), TypeSchema::Map(rhs)) => { + Self::unify_contextual_schema(lhs, rhs, type_params, bindings) + } + (TypeSchema::Named(lhs_name, lhs_args), TypeSchema::Named(rhs_name, rhs_args)) => { + lhs_name == rhs_name + && lhs_args.len() == rhs_args.len() + && lhs_args.iter().zip(rhs_args).all(|(lhs, rhs)| { + Self::unify_contextual_schema(lhs, rhs, type_params, bindings) + }) + } + (TypeSchema::ArrayTuple(lhs), TypeSchema::ArrayTuple(rhs)) => { + lhs.len() == rhs.len() + && lhs.iter().zip(rhs).all(|(lhs, rhs)| { + Self::unify_contextual_schema(lhs, rhs, type_params, bindings) + }) + } + ( + TypeSchema::Callable { + params: lhs_params, + result: lhs_result, + }, + TypeSchema::Callable { + params: rhs_params, + result: rhs_result, + }, + ) => { + lhs_params.len() == rhs_params.len() + && lhs_params.iter().zip(rhs_params).all(|(lhs, rhs)| { + Self::unify_contextual_schema(lhs, rhs, type_params, bindings) + }) + && Self::unify_contextual_schema(lhs_result, rhs_result, type_params, bindings) + } + _ => template == actual, + } + } + + fn substitute_contextual_schema( + schema: &TypeSchema, + bindings: &HashMap, + ) -> TypeSchema { + match schema { + TypeSchema::GenericParam(name) => bindings + .get(name) + .cloned() + .unwrap_or_else(|| schema.clone()), + TypeSchema::Optional(inner) => TypeSchema::Optional(Box::new( + Self::substitute_contextual_schema(inner, bindings), + )), + TypeSchema::Named(name, args) => TypeSchema::Named( + name.clone(), + args.iter() + .map(|arg| Self::substitute_contextual_schema(arg, bindings)) + .collect(), + ), + TypeSchema::Array(inner) => TypeSchema::Array(Box::new( + Self::substitute_contextual_schema(inner, bindings), + )), + TypeSchema::ArrayTuple(items) => TypeSchema::ArrayTuple( + items + .iter() + .map(|item| Self::substitute_contextual_schema(item, bindings)) + .collect(), + ), + TypeSchema::ArrayTupleRest { prefix, rest } => TypeSchema::ArrayTupleRest { + prefix: prefix + .iter() + .map(|item| Self::substitute_contextual_schema(item, bindings)) + .collect(), + rest: Box::new(Self::substitute_contextual_schema(rest, bindings)), + }, + TypeSchema::Map(inner) => TypeSchema::Map(Box::new( + Self::substitute_contextual_schema(inner, bindings), + )), + TypeSchema::Object(fields) => TypeSchema::Object( + fields + .iter() + .map(|(name, field)| { + ( + name.clone(), + Self::substitute_contextual_schema(field, bindings), + ) + }) + .collect(), + ), + TypeSchema::Callable { params, result } => TypeSchema::Callable { + params: params + .iter() + .map(|param| Self::substitute_contextual_schema(param, bindings)) + .collect(), + result: Box::new(Self::substitute_contextual_schema(result, bindings)), + }, + _ => schema.clone(), + } + } + fn validate_named_call_type_args( &self, decl: &FunctionDecl, diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 9191b21c..53811c91 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -929,7 +929,10 @@ impl Parser { } else { None }; - let expr = self.parse_expr()?; + let mut expr = self.parse_expr()?; + if let Some(expected) = declared_schema.as_ref() { + self.contextualize_function_value(&mut expr, expected)?; + } if expect_terminator { self.consume_stmt_terminator("expected ';' after let")?; } diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index c4484c09..d8ea130c 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -488,6 +488,7 @@ pub(super) fn validate_stmts( context, strict_function_add_types, )?; + let loop_entry = state.clone(); try_stabilize_loop_state(state, |iterated| { let _ = validate_expr( condition, @@ -512,6 +513,14 @@ pub(super) fn validate_stmts( source_name, context, strict_function_add_types, + )?; + validate_branch_state_merge( + Some(*line), + source_name, + &loop_entry, + &loop_entry, + iterated, + context.is_strict(), ) })?; } @@ -520,6 +529,7 @@ pub(super) fn validate_stmts( body, line, } => { + let loop_entry = state.clone(); try_stabilize_loop_state(state, |iterated| { let _ = validate_expr( condition, @@ -536,6 +546,14 @@ pub(super) fn validate_stmts( source_name, context, strict_function_add_types, + )?; + validate_branch_state_merge( + Some(*line), + source_name, + &loop_entry, + &loop_entry, + iterated, + context.is_strict(), ) })?; } @@ -1107,12 +1125,9 @@ pub(super) fn bind_expr_result_to_slot( .map(|(_, optional)| *optional) .unwrap_or(false); let optional = context.expr_is_optional(expr, expr_state) || declared_optional; - let schema = slot_declared_schema.clone().or_else(|| { - expr_state - .callable_schema(slot) - .cloned() - .or_else(|| context.infer_expr_schema(expr, expr_state)) - }); + let schema = slot_declared_schema + .clone() + .or_else(|| context.infer_expr_schema(expr, expr_state)); let from_declared_schema = slot_declared_schema.is_some() || expr_state.has_declared_schema(slot); state.bind_callable_with_schema(slot, callable, schema, from_declared_schema, optional); diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 31d84d89..fab96ea6 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -721,6 +721,13 @@ pub(super) fn validate_expr( else_ty, context.is_strict(), )?; + ensure_compatible_callable_schemas( + line_context, + source_name, + "if/else expression result", + context.infer_expr_schema(then_expr, &then_state), + context.infer_expr_schema(else_expr, &else_state), + )?; if then_ty == else_ty || matches!(static_condition, Some(true)) { then_ty } else if matches!(static_condition, Some(false)) { @@ -755,6 +762,7 @@ pub(super) fn validate_expr( context, ); let mut arm_type = None; + let mut arm_schema = None; for (pattern, arm_expr) in arms { validate_match_pattern(pattern, *value_slot, &nested, line_context, source_name)?; let arm_state = refine_state_for_match_pattern(&nested, pattern, *value_slot); @@ -766,6 +774,15 @@ pub(super) fn validate_expr( context, strict_function_add_types, )?; + let schema = context.infer_expr_schema(arm_expr, &arm_state); + ensure_compatible_callable_schemas( + line_context, + source_name, + "match arm result", + arm_schema.clone(), + schema.clone(), + )?; + arm_schema = arm_schema.or(schema); arm_type = Some(match arm_type { None => ty, Some(current) => { @@ -789,6 +806,7 @@ pub(super) fn validate_expr( context, strict_function_add_types, )?; + let default_schema = context.infer_expr_schema(default, &nested); if arms.is_empty() { default_ty } else { @@ -801,6 +819,13 @@ pub(super) fn validate_expr( default_ty, context.is_strict(), )?; + ensure_compatible_callable_schemas( + line_context, + source_name, + "match result", + arm_schema, + default_schema, + )?; merge_bound_types(arm_type, default_ty) } } @@ -1226,6 +1251,100 @@ fn ensure_compatible_if_else_types( }) } +fn ensure_compatible_callable_schemas( + line: Option, + source_name: Option<&str>, + context: &str, + lhs: Option, + rhs: Option, +) -> Result<(), CompileError> { + let (Some(lhs @ TypeSchema::Callable { .. }), Some(rhs @ TypeSchema::Callable { .. })) = + (lhs, rhs) + else { + return Ok(()); + }; + if are_compatible_schemas(&lhs, &rhs) { + return Ok(()); + } + Err(CompileError::IfElseBranchTypeMismatch { + line, + source_name: owned_source_name(source_name), + detail: format!( + "{context} has incompatible callable schemas: {} vs {}", + render_schema_label(&lhs), + render_schema_label(&rhs) + ), + }) +} + +fn are_compatible_schemas(lhs: &TypeSchema, rhs: &TypeSchema) -> bool { + match (lhs, rhs) { + (TypeSchema::Unknown, _) | (_, TypeSchema::Unknown) => true, + (TypeSchema::Number, TypeSchema::Int | TypeSchema::Float) + | (TypeSchema::Int | TypeSchema::Float, TypeSchema::Number) => true, + (TypeSchema::Optional(lhs), TypeSchema::Optional(rhs)) + | (TypeSchema::Array(lhs), TypeSchema::Array(rhs)) + | (TypeSchema::Map(lhs), TypeSchema::Map(rhs)) => are_compatible_schemas(lhs, rhs), + (TypeSchema::Named(lhs_name, lhs_args), TypeSchema::Named(rhs_name, rhs_args)) => { + lhs_name == rhs_name + && lhs_args.len() == rhs_args.len() + && lhs_args + .iter() + .zip(rhs_args) + .all(|(lhs, rhs)| are_compatible_schemas(lhs, rhs)) + } + (TypeSchema::ArrayTuple(lhs), TypeSchema::ArrayTuple(rhs)) => { + lhs.len() == rhs.len() + && lhs + .iter() + .zip(rhs) + .all(|(lhs, rhs)| are_compatible_schemas(lhs, rhs)) + } + ( + TypeSchema::ArrayTupleRest { + prefix: lhs_prefix, + rest: lhs_rest, + }, + TypeSchema::ArrayTupleRest { + prefix: rhs_prefix, + rest: rhs_rest, + }, + ) => { + lhs_prefix.len() == rhs_prefix.len() + && lhs_prefix + .iter() + .zip(rhs_prefix) + .all(|(lhs, rhs)| are_compatible_schemas(lhs, rhs)) + && are_compatible_schemas(lhs_rest, rhs_rest) + } + (TypeSchema::Object(lhs), TypeSchema::Object(rhs)) => { + lhs.len() == rhs.len() + && lhs.iter().all(|(name, lhs)| { + rhs.get(name) + .is_some_and(|rhs| are_compatible_schemas(lhs, rhs)) + }) + } + ( + TypeSchema::Callable { + params: lhs_params, + result: lhs_result, + }, + TypeSchema::Callable { + params: rhs_params, + result: rhs_result, + }, + ) => { + lhs_params.len() == rhs_params.len() + && lhs_params + .iter() + .zip(rhs_params) + .all(|(lhs, rhs)| are_compatible_schemas(lhs, rhs)) + && are_compatible_schemas(lhs_result, rhs_result) + } + _ => lhs == rhs, + } +} + pub(super) fn validate_branch_state_merge( line: Option, source_name: Option<&str>, @@ -1242,6 +1361,13 @@ pub(super) fn validate_branch_state_merge( } let left = lhs.get(slot); let right = rhs.get(slot); + ensure_compatible_callable_schemas( + line, + source_name, + "control-flow local", + lhs.schema(slot).cloned(), + rhs.schema(slot).cloned(), + )?; if are_compatible_bound_types_in_mode(left, right, strict) { continue; } diff --git a/src/debugger/recording.rs b/src/debugger/recording.rs index be90d867..54cbd344 100644 --- a/src/debugger/recording.rs +++ b/src/debugger/recording.rs @@ -388,7 +388,10 @@ fn encode_value( })?; write_u32_len(cells.len(), out)?; for cell in cells.iter() { - encode_value(cell, out, context)?; + let value = cell.lock().map_err(|_| { + VmRecordingError::InvalidFormat("poisoned callable capture cell") + })?; + encode_value(&value, out, context)?; } } } else { @@ -464,7 +467,7 @@ fn decode_value( let len = cursor.read_u32()? as usize; let mut cells = Vec::with_capacity(len); for _ in 0..len { - cells.push(decode_value(cursor, context)?); + cells.push(Arc::new(Mutex::new(decode_value(cursor, context)?))); } *environment.cells.lock().map_err(|_| { VmRecordingError::InvalidFormat("poisoned callable environment") @@ -556,7 +559,7 @@ mod tests { #[test] fn recording_preserves_callable_environment_aliases() { let environment = Arc::new(crate::CallableEnvironment { - cells: Mutex::new(vec![Value::Int(7)]), + cells: Mutex::new(vec![Arc::new(Mutex::new(Value::Int(7)))]), }); let callable = |prototype_id| { Value::Callable(Arc::new(crate::CallableValue { diff --git a/src/lib.rs b/src/lib.rs index 44060d4f..bb375387 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,8 +31,8 @@ pub use builtins::{ }; pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, - FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, - Value, ValueType, + CaptureBindingMode, FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, + ScriptFunction, TypeMap, Value, ValueType, }; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index fcf67d69..da1921ae 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -430,7 +430,7 @@ fn apply_call_provenance( stack.truncate(stack.len() - argc as usize); if !matches!( BuiltinFunction::from_call_index(call_index), - Some(BuiltinFunction::Assert) + Some(BuiltinFunction::Assert | BuiltinFunction::DetachLocal) ) { stack.push(AotStackProvenance::Derived); } diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 7df34dee..f81b921f 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -2654,7 +2654,7 @@ fn builtin_runtime_result_count(call_index: u16) -> Option { Some(match builtin { // `assert` is typed like a `null`-producing expression in the frontend, but the runtime // consumes its arguments and pushes no value on success. - BuiltinFunction::Assert => 0, + BuiltinFunction::Assert | BuiltinFunction::DetachLocal => 0, _ => 1, }) } diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index b3c91f24..334e301f 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -802,7 +802,9 @@ mod tests { 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, }); diff --git a/src/vm/mod.rs b/src/vm/mod.rs index c9d6b94c..0949efe8 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -291,7 +291,6 @@ pub(crate) struct ExecutionFrame { pub(crate) local_base: usize, pub(crate) local_count: usize, pub(crate) prototype_id: Option, - pub(crate) active_callable: Option>, } impl ExecutionFrame { @@ -302,7 +301,6 @@ impl ExecutionFrame { local_base: 0, local_count, prototype_id: None, - active_callable: None, } } } @@ -328,6 +326,7 @@ pub struct Vm { ip: usize, stack: Vec, locals: Vec, + capture_cells: HashMap, operand_type_hints: Option>, decoded_instruction_data: Arc, host_functions: Vec, @@ -463,7 +462,9 @@ fn compute_program_cache_key(program: &Program) -> u64 { prototype.arity.hash(&mut hasher); prototype.frame_local_count.hash(&mut hasher); prototype.parameter_slots.hash(&mut hasher); + prototype.capture_source_slots.hash(&mut hasher); prototype.capture_slots.hash(&mut hasher); + prototype.capture_modes.hash(&mut hasher); prototype.self_slot.hash(&mut hasher); match &prototype.schema { Some(schema) => { @@ -639,6 +640,7 @@ impl Vm { ip: 0, stack: Vec::new(), locals: vec![Value::Null; local_count], + capture_cells: HashMap::new(), operand_type_hints, decoded_instruction_data, host_functions: Vec::new(), @@ -846,6 +848,7 @@ impl Vm { self.clear_fuel(); self.clear_epoch_deadline(); self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); self.clear_locals_with_drop_contract(); self.locals.resize(self.program.local_count, Value::Null); self.initialize_root_callable_bindings(); @@ -1020,13 +1023,23 @@ impl Vm { #[inline(always)] fn load_local_value(&self, index: u8) -> VmResult { let absolute = self.absolute_local_index(index)?; + if let Some(cell) = self.capture_cells.get(&absolute) { + return cell + .lock() + .map(|value| value.clone()) + .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned")); + } Ok(self.locals[absolute].clone()) } #[inline(always)] pub(super) fn local_numeric_value(&self, index: u8) -> Option { let absolute = self.absolute_local_index(index).ok()?; - match self.locals.get(absolute)? { + let captured = self + .capture_cells + .get(&absolute) + .and_then(|cell| cell.lock().ok().map(|value| value.clone())); + match captured.as_ref().or_else(|| self.locals.get(absolute))? { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1077,6 +1090,7 @@ impl Vm { impl Drop for Vm { fn drop(&mut self) { self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); self.clear_locals_with_drop_contract(); crate::builtins::runtime::close_all_handles(self); } @@ -1088,7 +1102,7 @@ impl Vm { } pub(crate) fn bind_callable_value( - &self, + &mut self, prototype_id: u32, captures: Vec, ) -> VmResult { @@ -1098,14 +1112,54 @@ impl Vm { .get(prototype_id as usize) .cloned() .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; - if captures.len() != prototype.capture_slots.len() { + if captures.len() != prototype.capture_slots.len() + || captures.len() != prototype.capture_modes.len() + || captures.len() != prototype.capture_source_slots.len() + { return Err(VmError::InvalidFrameState( "callable capture layout mismatch", )); } - let env = if prototype.kind == crate::CallableKind::Closure || !captures.is_empty() { + let active_base = self.active_local_base(); + let mut cells = Vec::with_capacity(captures.len()); + for (((value, source), target), mode) in captures + .into_iter() + .zip(&prototype.capture_source_slots) + .zip(&prototype.capture_slots) + .zip(&prototype.capture_modes) + { + let self_capture = prototype.self_slot == Some(*target); + let cell = if !self_capture + && matches!( + mode, + crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut + ) { + let absolute = active_base + .checked_add(usize::from(*source)) + .ok_or(VmError::InvalidFrameState("capture source slot overflow"))?; + if absolute >= self.locals.len() { + return Err(VmError::InvalidFrameState( + "capture source exceeds active frame locals", + )); + } + let cell = self + .capture_cells + .entry(absolute) + .or_insert_with(|| Arc::new(Mutex::new(value))) + .clone(); + self.locals[absolute] = cell + .lock() + .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))? + .clone(); + cell + } else { + Arc::new(Mutex::new(value)) + }; + cells.push(cell); + } + let env = if prototype.kind == crate::CallableKind::Closure || !cells.is_empty() { Some(Arc::new(crate::CallableEnvironment { - cells: Mutex::new(captures), + cells: Mutex::new(cells), })) } else { None @@ -1236,7 +1290,16 @@ impl Vm { "capture slot is outside the script frame", )); } - self.locals[local_base + relative] = cell.clone(); + let absolute = local_base + relative; + self.locals[absolute] = cell + .lock() + .map_err(|_| { + VmError::InvalidFrameState("capture cell lock is poisoned") + })? + .clone(); + if prototype.self_slot != Some(*slot) { + self.capture_cells.insert(absolute, cell.clone()); + } } } if let Some(slot) = prototype.self_slot { @@ -1255,7 +1318,6 @@ impl Vm { local_base, local_count, prototype_id: Some(callable.prototype_id), - active_callable: Some(callable), }); self.call_depth = self.script_frame_depth(); self.ip = function.entry_ip as usize; @@ -1305,36 +1367,10 @@ impl Vm { } self.call_depth = self.script_frame_depth(); - let mut replaced_capture_values = Vec::new(); - if let (Some(prototype_id), Some(callable)) = - (frame.prototype_id, frame.active_callable.as_ref()) - && let Some(environment) = callable.env.as_ref() - && let Some(prototype) = self.program.callable_prototypes.get(prototype_id as usize) - { - let mut cells = environment - .cells - .lock() - .map_err(|_| VmError::InvalidFrameState("callable environment lock is poisoned"))?; - for (cell, slot) in cells.iter_mut().zip(&prototype.capture_slots) { - if prototype.self_slot == Some(*slot) { - continue; - } - let absolute = frame - .local_base - .checked_add(usize::from(*slot)) - .ok_or(VmError::InvalidFrameState("capture slot overflow"))?; - let value = - self.locals - .get(absolute) - .cloned() - .ok_or(VmError::InvalidFrameState( - "capture slot exceeds active frame locals", - ))?; - replaced_capture_values.push(std::mem::replace(cell, value)); - } - } - for value in replaced_capture_values { - self.drop_value_with_contract(value); + if frame.prototype_id.is_some() { + let frame_end = frame.local_base.saturating_add(frame.local_count); + self.capture_cells + .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); } if !matches!(frame.continuation, FrameContinuation::Halt) { @@ -1787,6 +1823,17 @@ impl Vm { value: Value, ) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; + if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + let previous = { + let mut captured = cell + .lock() + .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; + std::mem::replace(&mut *captured, value.clone()) + }; + self.locals[absolute] = value; + self.drop_value_with_contract(previous); + return Ok(()); + } let slot = self .locals .get_mut(absolute) @@ -1796,6 +1843,18 @@ impl Vm { Ok(()) } + pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { + let absolute = self.absolute_local_index(index)?; + self.capture_cells.remove(&absolute); + let slot = self + .locals + .get_mut(absolute) + .ok_or(VmError::InvalidLocal(index))?; + let previous = std::mem::replace(slot, Value::Null); + self.drop_value_with_contract(previous); + Ok(()) + } + pub(super) fn read_u8(&mut self) -> VmResult { if self.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); @@ -2491,6 +2550,7 @@ impl Vm { self.queued_callables.clear(); self.draining_queued_callables = false; self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); self.clear_locals_with_drop_contract(); self.execution_frames.clear(); self.call_depth = 0; diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 831927db..e34dd2c9 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -1576,7 +1576,6 @@ mod tests { local_base: 2, local_count: 3, prototype_id: Some(7), - active_callable: None, }); vm.call_depth = 1; diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 30ac6e19..79840f81 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -71,7 +71,9 @@ fn callvalue_enters_script_frame_and_resumes_caller() { 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, }], @@ -122,7 +124,9 @@ fn host_can_invoke_exported_callable_and_reset_rebinds_program_owned_value() { 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, }], @@ -179,6 +183,27 @@ fn host_can_invoke_exported_callable_and_reset_rebinds_program_owned_value() { ); } +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_executes_move_detach_without_stack_contract_mismatch() { + let compiled = crate::compile_source_for_repl( + r#" + let source = "x"; + let moved = source; + moved; + "#, + ) + .expect("move source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compilation should succeed"); + assert_eq!( + vm.run().expect("aot execution should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::String(Arc::new("x".to_string()))]); + assert!(!vm.aot_interpreter_boundary_hit); +} + #[cfg(feature = "cranelift-jit")] #[test] fn aot_executes_script_callable_frames_without_interpreter_boundary() { diff --git a/src/vmbc.rs b/src/vmbc.rs index 09062a57..6935fde1 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -3,8 +3,8 @@ use std::fmt::Write; use crate::builtins::BuiltinFunction; use crate::bytecode::{ - CallableKind, CallablePrototype, CallableTarget, FunctionRegion, RootCallableBinding, - ScriptFunction, TypeMap, ValueType, + CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, + RootCallableBinding, ScriptFunction, TypeMap, ValueType, }; use crate::compiler::ir::TypeSchema; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; @@ -31,6 +31,7 @@ pub enum WireError { InvalidTypeMapFlag(u8), InvalidDebugFlag(u8), InvalidValueType(u8), + InvalidCaptureBindingMode(u8), InvalidUtf8, StringTooLong(usize), CodeTooLong(usize), @@ -53,6 +54,9 @@ impl std::fmt::Display for WireError { WireError::InvalidTypeMapFlag(value) => write!(f, "invalid type-map flag: {value}"), WireError::InvalidDebugFlag(value) => write!(f, "invalid debug flag: {value}"), WireError::InvalidValueType(value) => write!(f, "invalid value type: {value}"), + WireError::InvalidCaptureBindingMode(value) => { + write!(f, "invalid capture binding mode: {value}") + } WireError::InvalidUtf8 => write!(f, "invalid utf-8 string"), WireError::StringTooLong(len) => write!(f, "string too long: {len}"), WireError::CodeTooLong(len) => write!(f, "code too long: {len}"), @@ -544,9 +548,12 @@ fn validate_callable_metadata(program: &Program) -> Result<(), ValidationError> for prototype in &program.callable_prototypes { if matches!(prototype.target, CallableTarget::ScriptFunction(_)) && prototype.parameter_slots.len() != prototype.arity as usize + || prototype.capture_source_slots.len() != prototype.capture_slots.len() + || prototype.capture_modes.len() != prototype.capture_slots.len() || prototype .parameter_slots .iter() + .chain(prototype.capture_source_slots.iter()) .chain(prototype.capture_slots.iter()) .any(|slot| *slot as usize >= prototype.frame_local_count) || prototype @@ -783,7 +790,16 @@ fn write_callable_metadata(out: &mut Vec, program: &Program) -> Result<(), W out.push(prototype.arity); write_u32_count("callable frame locals", prototype.frame_local_count, out)?; write_u16_list("callable parameters", &prototype.parameter_slots, out)?; + write_u16_list( + "callable capture sources", + &prototype.capture_source_slots, + out, + )?; write_u16_list("callable captures", &prototype.capture_slots, out)?; + write_u32_count("callable capture modes", prototype.capture_modes.len(), out)?; + for mode in &prototype.capture_modes { + out.push(*mode as u8); + } match prototype.self_slot { Some(slot) => { out.push(1); @@ -871,7 +887,19 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result CaptureBindingMode::Copy, + 1 => CaptureBindingMode::Borrow, + 2 => CaptureBindingMode::BorrowMut, + 3 => CaptureBindingMode::Move, + other => return Err(WireError::InvalidCaptureBindingMode(other)), + }); + } let self_slot = match cursor.read_u8()? { 0 => None, 1 => Some(cursor.read_u16()?), @@ -888,7 +916,9 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result, bindings: &[HostBi for binding in bindings { vm.bind_function(binding.name, (binding.factory)()); } - let status = vm.run().expect("vm should run"); + let status = vm.run().unwrap_or_else(|error| { + panic!( + "vm should run for case '{}': {error:?}; ip={}; prototypes={:#?}; frames={:?}; locals={:?}; stack={:?}", + case.name, + vm.ip(), + vm.program().callable_prototypes, + vm.execution_frames(), + vm.locals(), + vm.stack() + ) + }); assert_eq!( status, VmStatus::Halted, diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 021acde8..7e00924d 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -2192,6 +2192,86 @@ fn compatible_callable_signatures_merge_across_branches() { run_runtime_case(&case); } +#[test] +fn incompatible_callable_signatures_are_rejected_across_control_flow() { + let cases = [ + SourceErrorCase { + name: "if expression rejects incompatible callable signatures", + source: r#" + fn map_int(value: int) -> int { value + 1 } + fn map_string(value: string) -> string { value + "!" } + let choose_int = true; + let selected = if choose_int => { map_int } else => { map_string }; + selected(41); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::IfElseBranchTypeMismatch), + expected_contains_all: &["callable"], + }, + SourceErrorCase { + name: "match expression rejects incompatible callable signatures", + source: r#" + fn map_int(value: int) -> int { value + 1 } + fn map_string(value: string) -> string { value + "!" } + let selected = match 0 { + 0 => map_int, + _ => map_string, + }; + selected(41); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::IfElseBranchTypeMismatch), + expected_contains_all: &["callable"], + }, + SourceErrorCase { + name: "loop merge rejects incompatible callable signatures", + source: r#" + fn map_int(value: int) -> int { value + 1 } + fn map_string(value: string) -> string { value + "!" } + let mut selected = map_int; + let keep_running = false; + while keep_running { + selected = map_string; + } + selected(41); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::IfElseBranchTypeMismatch), + expected_contains_all: &["callable"], + }, + ]; + + for case in &cases { + expect_source_error_case(case); + } +} + +#[test] +fn bare_generic_function_values_resolve_from_callable_context() { + let cases = [ + rustscript_runtime_case( + "bare generic function value resolves from local annotation", + r#" + fn identity(value: T) -> T { value } + let int_identity: fn(int) -> int = identity; + int_identity(42); + "#, + vec![Value::Int(42)], + ), + rustscript_runtime_case( + "bare generic function value resolves from higher order parameter", + r#" + fn identity(value: T) -> T { value } + fn apply(mapper: fn(T) -> T, value: T) -> T { mapper(value) } + apply::(identity, 42); + "#, + vec![Value::Int(42)], + ), + ]; + + run_runtime_cases(&cases); +} + #[test] fn explicit_generic_function_values_use_substituted_callable_schemas() { let case = rustscript_runtime_case( @@ -2273,6 +2353,46 @@ fn closure_aliases_share_state_and_factory_evaluations_are_independent() { run_runtime_case(&case); } +#[test] +fn borrowed_capture_shares_outer_mutation_cell() { + let case = rustscript_runtime_case( + "borrowed capture observes outer writes", + r#" + let mut base = 1; + let read = || &base; + base = 2; + read(); + base; + "#, + vec![Value::Int(2), Value::Int(2)], + ); + + run_runtime_case(&case); +} + +#[test] +fn recursive_mutable_capture_updates_one_shared_cell() { + let case = rustscript_runtime_case( + "recursive mutable capture does not restore stale snapshots", + r#" + let mut count = 0; + let recurse = |depth| if depth == 0 => { + count = count + 1; + count + } else => { + count = count + 1; + recurse(depth - 1); + count + }; + recurse(2); + count; + "#, + vec![Value::Int(3), Value::Int(3)], + ); + + run_runtime_case(&case); +} + #[test] fn capturing_named_functions_use_closure_runtime_kind() { let compiled = vm::compile_source_for_repl( diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 820c3cd9..227a7fd3 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -213,7 +213,9 @@ fn validation_rejects_cross_region_branches() { 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, }], From 14420ae5f8514b5df057d449cf2fd1dd9eb3866e Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 01:03:19 +0800 Subject: [PATCH 12/18] feat(vm): implement production script callback boundary --- pd-vm-nostd/src/lib.rs | 4 +- pd-vm-nostd/src/program.rs | 30 +++ pd-vm-nostd/src/vm.rs | 58 +++++- pd-vm-nostd/src/vmbc.rs | 37 +++- pd-vm-nostd/tests/embedded_vmbc.rs | 35 +++- src/builtins/runtime/io.rs | 4 + src/builtins/runtime/mod.rs | 4 + src/bytecode.rs | 17 +- src/cli.rs | 55 +++++- src/compiler/codegen.rs | 20 +- src/lib.rs | 4 +- src/vm/host.rs | 20 ++ src/vm/mod.rs | 282 +++++++++++++++++++++++--- src/vm/store.rs | 184 +++++++++++++++-- src/vm/tests.rs | 294 +++++++++++++++++++++++++++- src/vmbc.rs | 46 ++++- tests/vm/functional_parity_tests.rs | 4 +- tests/vm/vm_async_runtime_tests.rs | 33 ++++ tests/wire/wire_tests.rs | 8 +- 19 files changed, 1046 insertions(+), 93 deletions(-) diff --git a/pd-vm-nostd/src/lib.rs b/pd-vm-nostd/src/lib.rs index f569af46..3557a34e 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -17,8 +17,8 @@ mod vmbc; pub use error::{VmError, WireError}; pub use host::{HostBinding, HostDispatcher, HostError, HostFunction}; pub use program::{ - CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, HostImport, OpCode, - Program, RootCallableBinding, ScriptFunction, ValueType, + CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, FunctionRegion, + HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, ValueType, }; pub use value::{CallableEnvironment, CallableKind, CallableValue, Value}; pub use vm::{Vm, VmResult, VmStatus}; diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index b7c51307..5c511b0a 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -85,6 +85,12 @@ pub struct RootCallableBinding { pub prototype_id: u32, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportedCallable { + pub name: String, + pub local_slot: u16, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostImport { pub name: String, @@ -102,6 +108,7 @@ pub struct Program { callable_prototypes: Vec, function_regions: Vec, root_callable_bindings: Vec, + exported_callables: Vec, } impl Program { @@ -116,6 +123,7 @@ impl Program { callable_prototypes: Vec::new(), function_regions: Vec::new(), root_callable_bindings: Vec::new(), + exported_callables: Vec::new(), } } @@ -130,11 +138,29 @@ impl Program { callable_prototypes: Vec, function_regions: Vec, root_callable_bindings: Vec, + exported_callables: Vec, ) -> Self { self.script_functions = script_functions; self.callable_prototypes = callable_prototypes; self.function_regions = function_regions; + self.local_count = self + .local_count + .max( + root_callable_bindings + .iter() + .map(|binding| binding.local_slot as usize + 1) + .max() + .unwrap_or(0), + ) + .max( + exported_callables + .iter() + .map(|exported| exported.local_slot as usize + 1) + .max() + .unwrap_or(0), + ); self.root_callable_bindings = root_callable_bindings; + self.exported_callables = exported_callables; self } @@ -154,6 +180,10 @@ impl Program { &self.root_callable_bindings } + pub fn exported_callables(&self) -> &[ExportedCallable] { + &self.exported_callables + } + pub fn constants(&self) -> &[Value] { &self.constants } diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 659b4d92..77a77990 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -386,7 +386,12 @@ impl Vm { let index = self.read_u8()?; let absolute = self.absolute_local(index)?; let value = self.pop()?; - if let Some(cell) = self.capture_cells.get(&absolute) { + if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + if Self::value_references_capture_cell(&value, &cell, &mut Vec::new()) { + return Err(VmError::HostError( + "callable capture ownership cycle is unsupported", + )); + } *cell.borrow_mut() = value.clone(); } self.locals[absolute] = value; @@ -436,10 +441,61 @@ impl Vm { } } + fn value_references_capture_cell( + value: &Value, + target: &Rc>, + visited_cells: &mut Vec, + ) -> bool { + match value { + Value::Callable(callable) => { + let Some(environment) = callable.env.as_ref() else { + return false; + }; + for cell in environment.iter() { + if Rc::ptr_eq(cell, target) { + return true; + } + let identity = Rc::as_ptr(cell) as usize; + if !visited_cells.contains(&identity) { + visited_cells.push(identity); + if Self::value_references_capture_cell( + &cell.borrow(), + target, + visited_cells, + ) { + return true; + } + } + } + false + } + Value::Array(values) => values + .iter() + .any(|value| Self::value_references_capture_cell(value, target, visited_cells)), + Value::Map(values) => values.iter().any(|(key, value)| { + Self::value_references_capture_cell(key, target, visited_cells) + || Self::value_references_capture_cell(value, target, visited_cells) + }), + _ => false, + } + } + pub fn stack(&self) -> &[Value] { &self.stack } + pub fn resolve_exported_callable(&self, name: &str) -> Option { + let exported = self + .program + .exported_callables() + .iter() + .find(|exported| exported.name == name)?; + self.locals + .get(exported.local_slot as usize) + .filter(|value| matches!(value, Value::Callable(_))) + .cloned() + } + pub fn locals(&self) -> &[Value] { &self.locals } diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 7a144ee2..37acd5cd 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -2,12 +2,13 @@ use alloc::string::String; use alloc::vec::Vec; use super::{ - CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, - HostImport, Program, RootCallableBinding, ScriptFunction, Value, ValueType, WireError, + CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, + FunctionRegion, HostImport, Program, RootCallableBinding, ScriptFunction, Value, ValueType, + WireError, }; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V9: u16 = 9; +const VERSION_V10: u16 = 10; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; @@ -19,7 +20,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V9 { + if version != VERSION_V10 { return Err(WireError::UnsupportedVersion(version)); } let flags = cursor.read_u16()?; @@ -57,8 +58,13 @@ pub fn decode_program(bytes: &[u8]) -> Result { let encoded_local_count = skip_type_map(&mut cursor)?; skip_debug_info(&mut cursor)?; - let (script_functions, callable_prototypes, function_regions, root_callable_bindings) = - read_callable_metadata(&mut cursor)?; + let ( + script_functions, + callable_prototypes, + function_regions, + root_callable_bindings, + exported_callables, + ) = read_callable_metadata(&mut cursor)?; if !cursor.is_empty() { return Err(WireError::TrailingBytes); } @@ -73,6 +79,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { callable_prototypes, function_regions, root_callable_bindings, + exported_callables, )) } @@ -183,6 +190,7 @@ type CallableMetadata = ( Vec, Vec, Vec, + Vec, ); fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result { @@ -295,7 +303,22 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result) -> Result<(), WireError> { diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 8737080e..9fe52968 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_v9() { +fn embedded_decoder_reads_host_generated_v10() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v9"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v10"); assert_eq!( program.code(), @@ -64,6 +64,37 @@ fn embedded_decoder_accepts_compiler_type_and_debug_metadata() { assert_eq!(program.local_count(), compiled.locals); } +#[test] +fn embedded_decoder_preserves_exported_callable_names() { + let compiled = compile_source_for_repl("pub fn answer() -> int { 42 }") + .expect("exported function should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("exported program should encode"); + let program = decode_program(&bytes).expect("embedded decoder should preserve exports"); + assert_eq!(program.exported_callables().len(), 1); + assert_eq!(program.exported_callables()[0].name, "answer"); + assert!( + program + .root_callable_bindings() + .iter() + .any(|binding| binding.local_slot == program.exported_callables()[0].local_slot) + ); + let mut vm = EmbeddedVm::new(program); + assert!(matches!( + vm.resolve_exported_callable("answer"), + Some(EmbeddedValue::Callable(_)) + )); + assert_eq!( + vm.run().expect("embedded root should halt"), + EmbeddedVmStatus::Halted + ); + assert!(matches!( + vm.resolve_exported_callable("answer"), + Some(EmbeddedValue::Callable(_)) + )); + assert_eq!(vm.resolve_exported_callable("missing"), None); +} + #[test] fn embedded_decoder_preserves_metadata_only_repl_locals() { let compiled = compile_source_for_repl_with_locals( diff --git a/src/builtins/runtime/io.rs b/src/builtins/runtime/io.rs index 12ae8e10..1d9bfe47 100644 --- a/src/builtins/runtime/io.rs +++ b/src/builtins/runtime/io.rs @@ -39,6 +39,10 @@ struct IoAsyncCompletion { result: VmResult, } +pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { + vm.io_state.pending_ops.remove(&op_id); +} + pub(super) fn poll_builtin_io_op( vm: &mut Vm, op_id: HostOpId, diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 8e1d8f4a..0740220e 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -123,6 +123,10 @@ pub(crate) fn execute_builtin_call( } } +pub(crate) fn cancel_builtin_io_op(vm: &mut Vm, op_id: HostOpId) { + io::cancel_pending_op(vm, op_id); +} + pub(crate) fn poll_builtin_io_op( vm: &mut Vm, op_id: HostOpId, diff --git a/src/bytecode.rs b/src/bytecode.rs index d161685a..75dc2183 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, OnceLock}; use crate::compiler::TypeSchema; -pub const BYTECODE_ABI_VERSION: u16 = 9; +pub const BYTECODE_ABI_VERSION: u16 = 10; pub type SharedString = Arc; pub type SharedBytes = Arc>; @@ -69,6 +69,12 @@ pub struct RootCallableBinding { pub prototype_id: u32, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ExportedCallable { + pub name: String, + pub local_slot: u16, +} + #[derive(Debug)] pub struct CallableEnvironment { pub(crate) cells: std::sync::Mutex>, @@ -589,6 +595,7 @@ pub struct Program { pub callable_prototypes: Vec, pub function_regions: Vec, pub root_callable_bindings: Vec, + pub exported_callables: Vec, #[allow(dead_code)] decoded_instruction_data_cache: Arc>>, operand_type_hints_cache: Arc>>>, @@ -608,6 +615,7 @@ impl Program { callable_prototypes: Vec::new(), function_regions: Vec::new(), root_callable_bindings: Vec::new(), + exported_callables: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -630,6 +638,7 @@ impl Program { callable_prototypes: Vec::new(), function_regions: Vec::new(), root_callable_bindings: Vec::new(), + exported_callables: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -653,6 +662,7 @@ impl Program { callable_prototypes: Vec::new(), function_regions: Vec::new(), root_callable_bindings: Vec::new(), + exported_callables: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -683,6 +693,11 @@ impl Program { self } + pub fn with_exported_callables(mut self, exported_callables: Vec) -> Self { + self.exported_callables = exported_callables; + self + } + #[allow(dead_code)] pub(crate) fn shared_decoded_instruction_data(&self) -> Arc { Arc::clone( diff --git a/src/cli.rs b/src/cli.rs index e6c741f0..6c31fa36 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -909,6 +909,7 @@ fn run_repl() -> Result<(), Box> { println!("history: up/down arrows, commands: .help, .quit, .cancel"); let mut editor = DefaultEditor::new()?; let mut session = ReplSession::default(); + let mut active_store: Option> = None; let mut pending_input = String::new(); loop { let prompt = if pending_input.is_empty() { @@ -977,11 +978,20 @@ fn run_repl() -> Result<(), Box> { println!("{}", render_vm_error(&vm, &err)); continue; } + if let Some(store) = active_store.as_mut() { + store.replace_vm(vm); + } else { + active_store = Some(vm::Store::from_vm(vm)); + } + let vm = active_store + .as_mut() + .expect("REPL store must be installed") + .vm_mut(); loop { match vm.run() { Ok(VmStatus::Halted) => { sync_repl_session( - &vm, + vm, &compiled.bindings, &moved_by_rebinding, &mut session, @@ -997,24 +1007,19 @@ fn run_repl() -> Result<(), Box> { Ok(VmStatus::Waiting(_op_id)) => { if let Err(err) = vm.wait_for_host_op_blocking() { sync_repl_session( - &vm, + vm, &compiled.bindings, &no_repl_moves, &mut session, ); - println!("{}", render_vm_error(&vm, &err)); + println!("{}", render_vm_error(vm, &err)); break; } continue; } Err(err) => { - sync_repl_session( - &vm, - &compiled.bindings, - &no_repl_moves, - &mut session, - ); - println!("{}", render_vm_error(&vm, &err)); + sync_repl_session(vm, &compiled.bindings, &no_repl_moves, &mut session); + println!("{}", render_vm_error(vm, &err)); break; } } @@ -1138,6 +1143,17 @@ fn repl_locals_moved_by_rebinding( moved } +fn repl_value_contains_callable(value: &Value) -> bool { + match value { + Value::Callable(_) => true, + Value::Array(values) => values.iter().any(repl_value_contains_callable), + Value::Map(values) => values.iter().any(|(key, value)| { + repl_value_contains_callable(key) || repl_value_contains_callable(value) + }), + _ => false, + } +} + fn sync_repl_session( vm: &Vm, bindings: &[ReplLocalBinding], @@ -1160,6 +1176,9 @@ fn sync_repl_session( let Some(value) = vm.locals().get(index as usize) else { continue; }; + if repl_value_contains_callable(value) { + continue; + } let (schema, optional) = repl_local_schema_from_vm(vm, index as usize, value); let moved = moved_by_rebinding.contains(&binding.name) || (!optional @@ -1253,6 +1272,11 @@ fn seed_repl_vm_locals( return Ok(()); } for (name, local) in locals { + if repl_value_contains_callable(&local.value) { + return Err(VmError::InvalidFrameState( + "repl callable value was invalidated by program replacement", + )); + } let index = { let Some(debug) = vm.debug_info() else { return Err(VmError::HostError( @@ -2088,6 +2112,17 @@ mod tests { assert_eq!(vm.stack().last(), Some(&Value::Int(42))); } + #[test] + fn repl_session_invalidates_program_owned_callable_locals() { + let mut session = super::ReplSession::default(); + let _ = run_repl_snippet_and_sync( + &mut session, + "fn answer() -> int { 42 } let callback = answer; let callbacks = [callback];", + ); + assert!(!session.locals.contains_key("callback")); + assert!(!session.locals.contains_key("callbacks")); + } + #[test] fn repl_session_preserves_move_state_between_entries() { let mut session = super::ReplSession::default(); diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 79e40754..a5ab2240 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use crate::assembler::Assembler; use crate::builtins::BuiltinFunction; use crate::{ - CallableKind, CallablePrototype, CallableTarget, FunctionRegion, Program, RootCallableBinding, - ScriptFunction, TypeMap, Value, ValueType, + CallableKind, CallablePrototype, CallableTarget, ExportedCallable, FunctionRegion, Program, + RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, }; use super::ir::{ @@ -192,6 +192,21 @@ impl Compiler { for prototype in &mut self.callable_prototypes { prototype.frame_local_count = self.frame_local_count; } + let mut exported_callables = self + .function_decls + .values() + .filter(|decl| decl.exported) + .filter_map(|decl| { + self.function_slots + .get(&decl.index) + .copied() + .map(|local_slot| ExportedCallable { + name: decl.name.clone(), + local_slot, + }) + }) + .collect::>(); + exported_callables.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name)); let mut program = self .assembler @@ -204,6 +219,7 @@ impl Compiler { program.callable_prototypes = self.callable_prototypes; program.function_regions = self.function_regions; program.root_callable_bindings = self.root_callable_bindings; + program.exported_callables = exported_callables; Ok(program) } diff --git a/src/lib.rs b/src/lib.rs index bb375387..ec0a4b9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,8 +31,8 @@ pub use builtins::{ }; pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, - CaptureBindingMode, FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, - ScriptFunction, TypeMap, Value, ValueType, + CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, OpCode, Program, + RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, }; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; diff --git a/src/vm/host.rs b/src/vm/host.rs index 72036909..ef3381ff 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -87,6 +87,8 @@ pub trait HostArgsFunction: Send { pub trait HostAsyncBridge: Send { fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; + + fn cancel_op(&mut self, _op_id: HostOpId) {} } pub type StaticHostFunction = fn(&mut Vm, &[Value]) -> VmResult; @@ -789,10 +791,12 @@ impl Vm { } pub fn set_async_bridge(&mut self, bridge: Box) { + self.cancel_waiting_host_op(); self.async_bridge = Some(bridge); } pub fn clear_async_bridge(&mut self) { + self.cancel_waiting_host_op(); self.async_bridge = None; } @@ -827,6 +831,22 @@ impl Vm { self.waiting_host_op.map(|op| op.op_id) } + pub(super) fn cancel_waiting_host_op(&mut self) { + let Some(waiting) = self.waiting_host_op.take() else { + return; + }; + match waiting.source { + WaitingHostOpSource::HostBridge => { + if let Some(bridge) = self.async_bridge.as_mut() { + bridge.cancel_op(waiting.op_id); + } + } + WaitingHostOpSource::BuiltinIo => { + crate::builtins::runtime::cancel_builtin_io_op(self, waiting.op_id); + } + } + } + pub fn complete_host_op( &mut self, op_id: HostOpId, diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 0949efe8..d30eb744 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,6 +1,7 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::hash::{Hash, Hasher}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Weak}; pub(crate) mod aot; pub mod diagnostics; @@ -309,6 +310,7 @@ impl ExecutionFrame { struct QueuedCallable { callable: Value, args: Vec, + subscription: Option>, } pub struct Vm { @@ -338,6 +340,9 @@ pub struct Vm { execution_frames: Vec, host_return: Option, queued_callables: VecDeque, + completed_callable_results: VecDeque, + owned_callables: Vec>, + callback_registry_flags: Vec>, draining_queued_callables: bool, shutdown: bool, aot_program: Option, @@ -455,6 +460,7 @@ fn compute_program_cache_key(program: &Program) -> u64 { program.script_functions.hash(&mut hasher); program.function_regions.hash(&mut hasher); program.root_callable_bindings.hash(&mut hasher); + program.exported_callables.hash(&mut hasher); program.callable_prototypes.len().hash(&mut hasher); for prototype in &program.callable_prototypes { prototype.kind.hash(&mut hasher); @@ -652,6 +658,9 @@ impl Vm { execution_frames: vec![ExecutionFrame::root(local_count)], host_return: None, queued_callables: VecDeque::new(), + completed_callable_results: VecDeque::new(), + owned_callables: Vec::new(), + callback_registry_flags: Vec::new(), draining_queued_callables: false, shutdown: false, aot_program: None, @@ -701,21 +710,24 @@ impl Vm { fn initialize_root_callable_bindings(&mut self) { let bindings = self.program.root_callable_bindings.clone(); for binding in bindings { - let Some(prototype) = self + let Some(kind) = self .program .callable_prototypes .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) else { continue; }; - let Some(slot) = self.locals.get_mut(binding.local_slot as usize) else { + if binding.local_slot as usize >= self.locals.len() { continue; - }; - *slot = Value::Callable(Arc::new(CallableValue { + } + let callable = Arc::new(CallableValue { prototype_id: binding.prototype_id, - kind: prototype.kind, + kind, env: None, - })); + }); + self.owned_callables.push(Arc::downgrade(&callable)); + self.locals[binding.local_slot as usize] = Value::Callable(callable); } } @@ -841,6 +853,8 @@ impl Vm { /// Locals are reset to `Null`, stack is cleared, and instruction pointer is /// rewound to the program entry. pub fn reset_for_reuse(&mut self) { + self.invalidate_callback_registries(); + self.cancel_waiting_host_op(); self.ip = 0; self.drop_contract_events = 0; self.last_yield_reason = None; @@ -850,6 +864,7 @@ impl Vm { self.clear_stack_with_drop_contract(); self.capture_cells.clear(); self.clear_locals_with_drop_contract(); + self.owned_callables.clear(); self.locals.resize(self.program.local_count, Value::Null); self.initialize_root_callable_bindings(); crate::builtins::runtime::close_all_handles(self); @@ -859,11 +874,12 @@ impl Vm { .push(ExecutionFrame::root(self.program.local_count)); self.host_return = None; self.queued_callables.clear(); + self.completed_callable_results.clear(); + self.owned_callables.clear(); self.draining_queued_callables = false; self.shutdown = false; self.aot_interpreter_boundary_hit = false; self.waiting_host_op = None; - self.next_host_op_id = 1; self.io_state = crate::builtins::runtime::IoState::default(); self.map_iterators.clear(); self.clear_interpreter_metrics(); @@ -1089,6 +1105,7 @@ impl Vm { impl Drop for Vm { fn drop(&mut self) { + self.cancel_waiting_host_op(); self.clear_stack_with_drop_contract(); self.capture_cells.clear(); self.clear_locals_with_drop_contract(); @@ -1164,11 +1181,13 @@ impl Vm { } else { None }; - Ok(Value::Callable(Arc::new(CallableValue { + let callable = Arc::new(CallableValue { prototype_id, kind: prototype.kind, env, - }))) + }); + self.owned_callables.push(Arc::downgrade(&callable)); + Ok(Value::Callable(callable)) } fn execute_call_value(&mut self, argc: u8) -> VmResult { @@ -1248,16 +1267,19 @@ impl Vm { "root callable binding is outside the script frame", )); } - let bound_prototype = self + let kind = self .program .callable_prototypes .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; - self.locals[local_base + relative] = Value::Callable(Arc::new(CallableValue { + let callable = Arc::new(CallableValue { prototype_id: binding.prototype_id, - kind: bound_prototype.kind, + kind, env: None, - })); + }); + self.owned_callables.push(Arc::downgrade(&callable)); + self.locals[local_base + relative] = Value::Callable(callable); } for (slot, value) in inherited_callables { if slot < local_count { @@ -1824,6 +1846,11 @@ impl Vm { ) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + if Self::value_references_capture_cell(&value, &cell, &mut HashSet::new())? { + return Err(VmError::InvalidFrameState( + "callable capture ownership cycle is unsupported", + )); + } let previous = { let mut captured = cell .lock() @@ -1843,6 +1870,57 @@ impl Vm { Ok(()) } + fn value_references_capture_cell( + value: &Value, + target: &crate::bytecode::SharedCaptureCell, + visited_cells: &mut HashSet, + ) -> VmResult { + match value { + Value::Callable(callable) => { + let Some(environment) = callable.env.as_ref() else { + return Ok(false); + }; + let cells = environment.cells.lock().map_err(|_| { + VmError::InvalidFrameState("capture environment lock is poisoned") + })?; + for cell in cells.iter() { + if Arc::ptr_eq(cell, target) { + return Ok(true); + } + let identity = Arc::as_ptr(cell) as usize; + if visited_cells.insert(identity) { + let captured = cell.lock().map_err(|_| { + VmError::InvalidFrameState("capture cell lock is poisoned") + })?; + if Self::value_references_capture_cell(&captured, target, visited_cells)? { + return Ok(true); + } + } + } + Ok(false) + } + Value::Array(values) => { + for value in values.iter() { + if Self::value_references_capture_cell(value, target, visited_cells)? { + return Ok(true); + } + } + Ok(false) + } + Value::Map(values) => { + for (key, value) in values.iter() { + if Self::value_references_capture_cell(key, target, visited_cells)? + || Self::value_references_capture_cell(value, target, visited_cells)? + { + return Ok(true); + } + } + Ok(false) + } + _ => Ok(false), + } + } + pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; self.capture_cells.remove(&absolute); @@ -2496,6 +2574,42 @@ impl Vm { self.ip } + pub(super) fn owns_callable(&self, value: &Value) -> bool { + let Value::Callable(target) = value else { + return false; + }; + self.owned_callables.iter().any(|owned| { + owned + .upgrade() + .is_some_and(|owned| Arc::ptr_eq(&owned, target)) + }) + } + + pub fn resolve_exported_callable(&self, name: &str) -> VmResult { + let exported = self + .program + .exported_callables + .iter() + .find(|exported| exported.name == name) + .ok_or_else(|| { + VmError::HostError(format!("unknown exported script function '{name}'")) + })?; + let value = self + .locals + .get(exported.local_slot as usize) + .cloned() + .ok_or(VmError::InvalidLocal( + u8::try_from(exported.local_slot).unwrap_or(u8::MAX), + ))?; + if matches!(value, Value::Callable(_)) { + Ok(value) + } else { + Err(VmError::InvalidFrameState( + "exported script function is not initialized", + )) + } + } + pub fn debug_info(&self) -> Option<&crate::debug_info::DebugInfo> { self.program.debug.as_ref() } @@ -2505,14 +2619,26 @@ impl Vm { } pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { + self.queue_callable_with_subscription(callable, args, None) + } + + pub(super) fn queue_callable_with_subscription( + &mut self, + callable: Value, + args: Vec, + subscription: Option>, + ) -> VmResult<()> { if self.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - self.queued_callables - .push_back(QueuedCallable { callable, args }); + self.queued_callables.push_back(QueuedCallable { + callable, + args, + subscription, + }); Ok(()) } @@ -2534,9 +2660,40 @@ impl Vm { self.draining_queued_callables = true; let mut results = Vec::with_capacity(self.queued_callables.len()); while let Some(queued) = self.queued_callables.pop_front() { - match self.invoke_callable(queued.callable, &queued.args) { - Ok(result) => results.push(result), + if queued + .subscription + .as_ref() + .is_some_and(|active| !active.load(Ordering::Acquire)) + { + continue; + } + match self.start_callable(queued.callable, &queued.args) { + Ok(VmStatus::Halted) => { + let Some(result) = self.host_return.take() else { + self.completed_callable_results.extend(results); + self.draining_queued_callables = false; + return Err(VmError::InvalidFrameState( + "queued invocation completed without a result", + )); + }; + results.push(result); + } + Ok(VmStatus::Yielded) => { + self.completed_callable_results.extend(results); + self.draining_queued_callables = false; + return Err(VmError::InvalidFrameState( + "queued invocation yielded; resume it before draining again", + )); + } + Ok(VmStatus::Waiting(_)) => { + self.completed_callable_results.extend(results); + self.draining_queued_callables = false; + return Err(VmError::InvalidFrameState( + "queued invocation is waiting; resume it before draining again", + )); + } Err(err) => { + self.completed_callable_results.extend(results); self.draining_queued_callables = false; return Err(err); } @@ -2547,7 +2704,11 @@ impl Vm { } pub fn shutdown(&mut self) { + self.invalidate_callback_registries(); + self.cancel_waiting_host_op(); self.queued_callables.clear(); + self.completed_callable_results.clear(); + self.owned_callables.clear(); self.draining_queued_callables = false; self.clear_stack_with_drop_contract(); self.capture_cells.clear(); @@ -2560,6 +2721,20 @@ impl Vm { self.shutdown = true; } + pub(super) fn register_callback_registry(&mut self, active: &Arc) { + self.callback_registry_flags.push(Arc::downgrade(active)); + } + + fn invalidate_callback_registries(&mut self) { + for active in self + .callback_registry_flags + .drain(..) + .filter_map(|flag| flag.upgrade()) + { + active.store(false, Ordering::Release); + } + } + pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { if self.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); @@ -2572,26 +2747,33 @@ impl Vm { "host invocation requires a halted VM", )); } + let argc = u8::try_from(args.len()) + .map_err(|_| VmError::InvalidFrameState("too many arguments"))?; let stack_base = self.stack.len(); + let frame_count = self.execution_frames.len(); self.stack.push(callable); self.stack.extend_from_slice(args); self.host_return = None; - let frame_count = self.execution_frames.len(); - let outcome = self.execute_call_value( - u8::try_from(args.len()) - .map_err(|_| VmError::InvalidFrameState("too many arguments"))?, - )?; + let outcome = match self.execute_call_value(argc) { + Ok(outcome) => outcome, + Err(error) => { + self.abort_host_invocation(stack_base, frame_count); + return Err(error); + } + }; if self.execution_frames.len() == frame_count { let result = match outcome { ExecOutcome::Continue | ExecOutcome::Halted => { self.stack.pop().unwrap_or(Value::Null) } ExecOutcome::Yielded => { + self.abort_host_invocation(stack_base, frame_count); return Err(VmError::InvalidFrameState( "direct host callable invocation yielded", )); } ExecOutcome::Waiting(_) => { + self.abort_host_invocation(stack_base, frame_count); return Err(VmError::InvalidFrameState( "direct host callable invocation is waiting", )); @@ -2604,21 +2786,65 @@ impl Vm { if let Some(frame) = self.execution_frames.last_mut() { frame.continuation = FrameContinuation::ReturnToHost; } - self.run_internal(None, false) + match self.run_internal(None, false) { + Ok(status) => Ok(status), + Err(error) => { + self.abort_host_invocation(stack_base, frame_count); + Err(error) + } + } } pub fn invoke_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { + let stack_base = self.stack.len(); + let frame_count = self.execution_frames.len(); match self.start_callable(callable, args)? { VmStatus::Halted => self.host_return.take().ok_or(VmError::InvalidFrameState( "host invocation completed without a result", )), - VmStatus::Yielded => Err(VmError::InvalidFrameState("host invocation yielded")), - VmStatus::Waiting(_) => Err(VmError::InvalidFrameState("host invocation is waiting")), + VmStatus::Yielded => { + self.abort_host_invocation(stack_base, frame_count); + Err(VmError::InvalidFrameState("host invocation yielded")) + } + VmStatus::Waiting(_) => { + self.abort_host_invocation(stack_base, frame_count); + Err(VmError::InvalidFrameState("host invocation is waiting")) + } } } + fn abort_host_invocation(&mut self, stack_base: usize, frame_count: usize) { + while self.execution_frames.len() > frame_count { + let Some(frame) = self.execution_frames.pop() else { + break; + }; + let frame_end = frame.local_base.saturating_add(frame.local_count); + self.capture_cells + .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); + if frame.local_base <= self.locals.len() { + let drained = self.locals.drain(frame.local_base..).collect::>(); + for value in drained { + self.drop_value_with_contract(value); + } + } + } + while self.stack.len() > stack_base { + if let Some(value) = self.stack.pop() { + self.drop_value_with_contract(value); + } + } + self.call_depth = self.script_frame_depth(); + self.host_return = None; + self.cancel_waiting_host_op(); + self.last_yield_reason = None; + self.map_iterators + .truncate(self.call_depth.saturating_add(1)); + } + pub fn take_callable_result(&mut self) -> Option { - self.host_return.take() + self.completed_callable_results + .pop_front() + .or_else(|| self.host_return.take()) } pub fn execution_frames(&self) -> Vec { diff --git a/src/vm/store.rs b/src/vm/store.rs index 229fb4c1..89a4b76e 100644 --- a/src/vm/store.rs +++ b/src/vm/store.rs @@ -1,45 +1,64 @@ use std::marker::PhantomData; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::Value; use crate::compiler::TypeSchema; use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmError, VmResult, VmStatus}; -static NEXT_STORE_ID: AtomicU64 = AtomicU64::new(1); - /// Lightweight Wasmtime-style store wrapper for VM state and host context data. pub struct Store { vm: Vm, data: T, - id: u64, + callback_registry: CallbackRegistryOwner, +} + +struct CallbackRegistryOwner(Arc); + +impl Drop for CallbackRegistryOwner { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } } pub trait ScriptArgs { const ARITY: Option; fn into_values(self) -> Vec; + + fn schemas() -> Option> { + None + } } pub trait IntoScriptValue { fn into_script_value(self) -> Value; + + fn schema() -> Option { + None + } } pub trait ScriptResult: Sized { fn from_value(value: Value) -> VmResult; + + fn schema() -> Option { + None + } } pub struct ScriptCallback, Ret = Value> { - store_id: u64, + registry: Arc, callable: Value, schema: Option, - subscribed: Arc, + subscription: Arc, marker: PhantomData Ret>, } pub struct QueuedScriptInvocation { - store_id: u64, + registry: Arc, + subscription: Arc, callable: Value, args: Vec, } @@ -47,10 +66,10 @@ pub struct QueuedScriptInvocation { impl Clone for ScriptCallback { fn clone(&self) -> Self { Self { - store_id: self.store_id, + registry: Arc::clone(&self.registry), callable: self.callable.clone(), schema: self.schema.clone(), - subscribed: Arc::clone(&self.subscribed), + subscription: Arc::clone(&self.subscription), marker: PhantomData, } } @@ -66,11 +85,11 @@ where } pub fn is_subscribed(&self) -> bool { - self.subscribed.load(Ordering::Acquire) + self.registry.load(Ordering::Acquire) && self.subscription.load(Ordering::Acquire) } pub fn unsubscribe(&self) { - self.subscribed.store(false, Ordering::Release); + self.subscription.store(false, Ordering::Release); } pub fn call(&self, store: &mut Store, args: Args) -> VmResult { @@ -92,13 +111,19 @@ where } pub fn prepare(&self, args: Args) -> VmResult { - if !self.is_subscribed() { + if !self.registry.load(Ordering::Acquire) { + return Err(VmError::InvalidFrameState( + "script callback registry is invalidated", + )); + } + if !self.subscription.load(Ordering::Acquire) { return Err(VmError::InvalidFrameState( "script callback is unsubscribed", )); } Ok(QueuedScriptInvocation { - store_id: self.store_id, + registry: Arc::clone(&self.registry), + subscription: Arc::clone(&self.subscription), callable: self.callable.clone(), args: args.into_values(), }) @@ -111,6 +136,10 @@ impl ScriptArgs for () { fn into_values(self) -> Vec { Vec::new() } + + fn schemas() -> Option> { + Some(Vec::new()) + } } impl ScriptArgs for Vec { @@ -142,6 +171,10 @@ macro_rules! impl_script_args_tuple { let ($($name,)+) = self; vec![$($name.into_script_value(),)+] } + + fn schemas() -> Option> { + Some(vec![$($name::schema()?),+]) + } } }; } @@ -161,24 +194,40 @@ impl IntoScriptValue for i64 { fn into_script_value(self) -> Value { Value::Int(self) } + + fn schema() -> Option { + Some(TypeSchema::Int) + } } impl IntoScriptValue for bool { fn into_script_value(self) -> Value { Value::Bool(self) } + + fn schema() -> Option { + Some(TypeSchema::Bool) + } } impl IntoScriptValue for String { fn into_script_value(self) -> Value { Value::string(self) } + + fn schema() -> Option { + Some(TypeSchema::String) + } } impl IntoScriptValue for &str { fn into_script_value(self) -> Value { Value::string(self) } + + fn schema() -> Option { + Some(TypeSchema::String) + } } impl ScriptResult for Value { @@ -194,6 +243,10 @@ impl ScriptResult for () { _ => Err(VmError::TypeMismatch("null callback result")), } } + + fn schema() -> Option { + Some(TypeSchema::Null) + } } impl ScriptResult for i64 { @@ -203,6 +256,10 @@ impl ScriptResult for i64 { _ => Err(VmError::TypeMismatch("int callback result")), } } + + fn schema() -> Option { + Some(TypeSchema::Int) + } } impl ScriptResult for bool { @@ -212,14 +269,20 @@ impl ScriptResult for bool { _ => Err(VmError::TypeMismatch("bool callback result")), } } + + fn schema() -> Option { + Some(TypeSchema::Bool) + } } impl Store { - pub fn new(vm: Vm, data: T) -> Self { + pub fn new(mut vm: Vm, data: T) -> Self { + let callback_registry = Arc::new(AtomicBool::new(true)); + vm.register_callback_registry(&callback_registry); Self { vm, data, - id: NEXT_STORE_ID.fetch_add(1, Ordering::Relaxed), + callback_registry: CallbackRegistryOwner(callback_registry), } } @@ -251,14 +314,41 @@ impl Store { self.vm.run() } - pub fn script_callback(&self, callable: Value) -> VmResult> + pub fn resolve_exported_callable(&self, name: &str) -> VmResult { + self.vm.resolve_exported_callable(name) + } + + pub fn script_callback_by_name( + &mut self, + name: &str, + ) -> VmResult> + where + Args: ScriptArgs, + Ret: ScriptResult, + { + let callable = self.resolve_exported_callable(name)?; + self.script_callback(callable) + } + + pub fn script_callback( + &mut self, + callable: Value, + ) -> VmResult> where Args: ScriptArgs, Ret: ScriptResult, { + if !self.callback_registry.0.load(Ordering::Acquire) { + self.install_callback_registry(); + } let Value::Callable(value) = &callable else { return Err(VmError::InvalidCallable); }; + if !self.vm.owns_callable(&callable) { + return Err(VmError::InvalidFrameState( + "script callable does not belong to this store", + )); + } let prototype = self .vm .program() @@ -274,18 +364,38 @@ impl Store { got: u8::try_from(arity).unwrap_or(u8::MAX), }); } + if let Some(TypeSchema::Callable { params, result }) = prototype.schema.as_ref() { + if let Some(args) = Args::schemas() + && (args.len() != params.len() + || !args + .iter() + .zip(params) + .all(|(actual, expected)| callback_schema_accepts(expected, actual))) + { + return Err(VmError::TypeMismatch("script callback argument schema")); + } + if let Some(actual) = Ret::schema() + && !callback_schema_accepts(result, &actual) + { + return Err(VmError::TypeMismatch("script callback result schema")); + } + } Ok(ScriptCallback { - store_id: self.id, + registry: Arc::clone(&self.callback_registry.0), callable, schema: prototype.schema.clone(), - subscribed: Arc::new(AtomicBool::new(true)), + subscription: Arc::new(AtomicBool::new(true)), marker: PhantomData, }) } pub fn enqueue_callback(&mut self, invocation: QueuedScriptInvocation) -> VmResult<()> { self.validate_invocation(&invocation)?; - self.vm.queue_callable(invocation.callable, invocation.args) + self.vm.queue_callable_with_subscription( + invocation.callable, + invocation.args, + Some(invocation.subscription), + ) } pub fn drain_callbacks(&mut self) -> VmResult> { @@ -300,14 +410,44 @@ impl Store { } fn validate_invocation(&self, invocation: &QueuedScriptInvocation) -> VmResult<()> { - if invocation.store_id != self.id { + if !Arc::ptr_eq(&invocation.registry, &self.callback_registry.0) { return Err(VmError::InvalidFrameState( "script callback belongs to another store", )); } + if !invocation.registry.load(Ordering::Acquire) { + return Err(VmError::InvalidFrameState( + "script callback registry is invalidated", + )); + } + if !invocation.subscription.load(Ordering::Acquire) { + return Err(VmError::InvalidFrameState( + "script callback is unsubscribed", + )); + } Ok(()) } + pub fn reset_for_reuse(&mut self) { + self.vm.reset_for_reuse(); + self.install_callback_registry(); + } + + pub fn replace_vm(&mut self, mut vm: Vm) { + self.callback_registry.0.store(false, Ordering::Release); + self.vm.shutdown(); + let callback_registry = Arc::new(AtomicBool::new(true)); + vm.register_callback_registry(&callback_registry); + self.callback_registry = CallbackRegistryOwner(callback_registry); + self.vm = vm; + } + + fn install_callback_registry(&mut self) { + let callback_registry = Arc::new(AtomicBool::new(true)); + self.vm.register_callback_registry(&callback_registry); + self.callback_registry = CallbackRegistryOwner(callback_registry); + } + pub fn resume(&mut self) -> VmResult { self.vm.resume() } @@ -417,6 +557,10 @@ impl Store { } } +fn callback_schema_accepts(expected: &TypeSchema, actual: &TypeSchema) -> bool { + matches!(expected, TypeSchema::Unknown) || expected == actual +} + impl Store<()> { pub fn from_vm(vm: Vm) -> Self { Self::new(vm, ()) diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 79840f81..4ec34c00 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -24,6 +24,36 @@ fn root_ret_completes_explicit_halt_frame() { assert_eq!(vm.stack(), &[]); } +#[test] +fn reset_for_reuse_keeps_host_operation_ids_monotonic() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + assert_eq!(vm.allocate_host_op_id(), 1); + vm.reset_for_reuse(); + assert_eq!(vm.allocate_host_op_id(), 2); +} + +#[test] +fn shared_capture_cell_rejects_callable_ownership_cycle() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(1)); + let cell = Arc::new(Mutex::new(Value::Null)); + vm.capture_cells.insert(0, Arc::clone(&cell)); + let environment = Arc::new(crate::CallableEnvironment { + cells: Mutex::new(vec![cell]), + }); + let callable = Value::Callable(Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::Closure, + env: Some(environment), + })); + assert!(matches!( + vm.store_local_with_drop_contract(0, callable), + Err(VmError::InvalidFrameState( + "callable capture ownership cycle is unsupported" + )) + )); + assert_eq!(vm.locals()[0], Value::Null); +} + #[test] fn callable_operand_type_hint_roundtrips() { let packed = pack_operand_types(ValueType::Callable, ValueType::Callable); @@ -402,12 +432,13 @@ fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { ); let alias = callback.clone(); - let wrong_type: crate::ScriptCallback<(bool,), i64> = store - .script_callback(callable) - .expect("callable arity should bind"); assert!(matches!( - wrong_type.call(&mut store, (false,)), - Err(VmError::TypeMismatch("callable argument schema")) + store.script_callback::<(bool,), i64>(callable.clone()), + Err(VmError::TypeMismatch("script callback argument schema")) + )); + assert!(matches!( + store.script_callback::<(i64,), bool>(callable.clone()), + Err(VmError::TypeMismatch("script callback result schema")) )); callback.unsubscribe(); @@ -418,6 +449,259 @@ fn typed_script_callbacks_invoke_queue_unsubscribe_and_invalidate() { "script callback is unsubscribed" )) )); + + let independently_subscribed: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable) + .expect("second callback should bind"); + let queued_before_unsubscribe = independently_subscribed + .prepare((1,)) + .expect("active callback should prepare"); + independently_subscribed.unsubscribe(); + assert!(matches!( + store.enqueue_callback(queued_before_unsubscribe), + Err(VmError::InvalidFrameState( + "script callback is unsubscribed" + )) + )); +} + +#[test] +fn callback_unsubscribe_cancels_already_enqueued_work() { + let compiled = crate::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one; + "#, + ) + .expect("callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("callable result"); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable) + .expect("callback should bind"); + let queued = callback.prepare((41,)).expect("callback should prepare"); + store + .enqueue_callback(queued) + .expect("callback should enqueue"); + callback.unsubscribe(); + assert_eq!( + store + .drain_callbacks() + .expect("canceled queue should drain"), + Vec::::new() + ); +} + +#[test] +fn store_reset_and_replacement_invalidate_callback_registries() { + let compiled = crate::compile_source_for_repl( + r#" + fn add_one(value: int) -> int { value + 1 } + add_one; + "#, + ) + .expect("first callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("first root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("first callable result"); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(callable) + .expect("first callback should bind"); + let prepared = callback.prepare((1,)).expect("callback should prepare"); + + store.reset_for_reuse(); + assert!(!callback.is_subscribed()); + assert!(matches!( + store.enqueue_callback(prepared), + Err(VmError::InvalidFrameState( + "script callback belongs to another store" + )) + )); + + let replacement = crate::compile_source_for_repl( + r#" + fn double(value: int) -> int { value * 2 } + double; + "#, + ) + .expect("replacement callback source should compile"); + let mut replacement_vm = Vm::new(replacement.program.with_local_count(replacement.locals)); + assert_eq!( + replacement_vm.run().expect("replacement root should halt"), + VmStatus::Halted + ); + let replacement_callable = replacement_vm + .stack() + .last() + .cloned() + .expect("replacement callable result"); + store.replace_vm(replacement_vm); + let replacement_callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback(replacement_callable) + .expect("replacement callback should bind"); + assert_eq!( + replacement_callback + .call(&mut store, (21,)) + .expect("replacement callback should run"), + 42 + ); +} + +#[test] +fn synchronous_callback_error_unwinds_before_next_invocation() { + let compiled = crate::compile_source_for_repl( + r#" + fn fail() -> int { 1 / 0 } + fn answer() -> int { 42 } + fail; + answer; + "#, + ) + .expect("callback error source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let fail_callable = vm.stack()[0].clone(); + let answer_callable = vm.stack()[1].clone(); + let mut store = crate::Store::from_vm(vm); + let fail: crate::ScriptCallback<(), i64> = store + .script_callback(fail_callable) + .expect("failing callback should bind"); + let answer: crate::ScriptCallback<(), i64> = store + .script_callback(answer_callable) + .expect("answer callback should bind"); + + assert!(matches!( + fail.call(&mut store, ()), + Err(VmError::DivisionByZero) + )); + assert_eq!(store.vm().call_depth(), 0); + assert!(store.vm().execution_frames().is_empty()); + assert_eq!( + answer + .call(&mut store, ()) + .expect("next callback should run without reset"), + 42 + ); +} + +#[test] +fn final_script_callback_releases_capture_environment_once() { + let compiled = crate::compile_source_for_repl( + r#" + fn make_callback() { + let captured = 42; + || captured + } + make_callback(); + "#, + ) + .expect("capturing callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let callable = vm.stack().last().cloned().expect("capturing callback"); + let Value::Callable(callable_value) = &callable else { + panic!("expected callable value"); + }; + let environment = callable_value + .env + .as_ref() + .expect("capturing callback should own an environment"); + let weak_environment = Arc::downgrade(environment); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(), i64> = store + .script_callback(callable.clone()) + .expect("capturing callback should bind"); + + store.vm_mut().shutdown(); + drop(callable); + drop(store); + assert!(weak_environment.upgrade().is_some()); + drop(callback); + assert!(weak_environment.upgrade().is_none()); +} + +#[test] +fn store_resolves_only_exported_script_functions_by_name() { + let compiled = crate::compile_source_for_repl( + r#" + pub fn add_one(value: int) -> int { value + 1 } + fn private_double(value: int) -> int { value * 2 } + "#, + ) + .expect("exported callback source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + let mut store = crate::Store::from_vm(vm); + let callback: crate::ScriptCallback<(i64,), i64> = store + .script_callback_by_name("add_one") + .expect("exported callback should resolve"); + assert_eq!(callback.call(&mut store, (41,)).expect("export call"), 42); + assert!(matches!( + store.resolve_exported_callable("private_double"), + Err(VmError::HostError(_)) + )); +} + +#[test] +fn store_rejects_callable_values_from_another_store() { + let first = + crate::compile_source_for_repl("pub fn value() -> int { 11 }").expect("first store source"); + let mut first_store = + crate::Store::from_vm(Vm::new(first.program.with_local_count(first.locals))); + assert_eq!(first_store.run().expect("first root"), VmStatus::Halted); + let foreign = first_store + .resolve_exported_callable("value") + .expect("first export"); + + let second = crate::compile_source_for_repl("pub fn value() -> int { 22 }") + .expect("second store source"); + let mut second_store = + crate::Store::from_vm(Vm::new(second.program.with_local_count(second.locals))); + assert_eq!(second_store.run().expect("second root"), VmStatus::Halted); + let injected_slot = u8::try_from(second_store.vm().program().exported_callables[0].local_slot) + .expect("test slot fits u8"); + second_store + .vm_mut() + .set_local(injected_slot, foreign.clone()) + .expect("foreign value can be injected into raw VM state"); + assert!(matches!( + second_store.script_callback::<(), i64>(foreign), + Err(VmError::InvalidFrameState( + "script callable does not belong to this store" + )) + )); +} + +#[test] +fn callback_queue_preserves_completed_results_and_remaining_events_after_error() { + let compiled = crate::compile_source_for_repl( + r#" + pub fn first() -> int { 1 } + pub fn fail() -> int { 1 / 0 } + pub fn third() -> int { 3 } + "#, + ) + .expect("queue source"); + let mut store = + crate::Store::from_vm(Vm::new(compiled.program.with_local_count(compiled.locals))); + assert_eq!(store.run().expect("queue root"), VmStatus::Halted); + let first: crate::ScriptCallback<(), i64> = store.script_callback_by_name("first").unwrap(); + let fail: crate::ScriptCallback<(), i64> = store.script_callback_by_name("fail").unwrap(); + let third: crate::ScriptCallback<(), i64> = store.script_callback_by_name("third").unwrap(); + store.enqueue_callback(first.prepare(()).unwrap()).unwrap(); + store.enqueue_callback(fail.prepare(()).unwrap()).unwrap(); + store.enqueue_callback(third.prepare(()).unwrap()).unwrap(); + + assert!(matches!( + store.drain_callbacks(), + Err(VmError::DivisionByZero) + )); + assert_eq!(store.take_callback_result::().unwrap(), Some(1)); + assert_eq!(store.vm().queued_callable_count(), 1); + assert_eq!(store.drain_callbacks().unwrap(), vec![Value::Int(3)]); } #[test] diff --git a/src/vmbc.rs b/src/vmbc.rs index 6935fde1..c0732d0f 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -3,8 +3,8 @@ use std::fmt::Write; use crate::builtins::BuiltinFunction; use crate::bytecode::{ - CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, FunctionRegion, - RootCallableBinding, ScriptFunction, TypeMap, ValueType, + CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, + FunctionRegion, RootCallableBinding, ScriptFunction, TypeMap, ValueType, }; use crate::compiler::ir::TypeSchema; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; @@ -16,8 +16,8 @@ const VERSION_V3: u16 = 3; const VERSION_V4: u16 = 4; const VERSION_V5: u16 = 5; const VERSION_V6: u16 = 6; -const VERSION_V9: u16 = 9; -const ENCODE_VERSION: u16 = VERSION_V9; +const VERSION_V10: u16 = 10; +const ENCODE_VERSION: u16 = VERSION_V10; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -227,7 +227,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V9 { + if version != VERSION_V10 { return Err(WireError::UnsupportedVersion(version)); } @@ -289,8 +289,13 @@ pub fn decode_program(bytes: &[u8]) -> Result { } else { None }; - let (script_functions, callable_prototypes, function_regions, root_callable_bindings) = - read_callable_metadata(&mut cursor)?; + let ( + script_functions, + callable_prototypes, + function_regions, + root_callable_bindings, + exported_callables, + ) = read_callable_metadata(&mut cursor)?; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -302,6 +307,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { program.callable_prototypes = callable_prototypes; program.function_regions = function_regions; program.root_callable_bindings = root_callable_bindings; + program.exported_callables = exported_callables; Ok(program) } @@ -591,6 +597,17 @@ fn validate_callable_metadata(program: &Program) -> Result<(), ValidationError> )); } } + let mut export_names = HashSet::new(); + for exported in &program.exported_callables { + if exported.name.is_empty() + || exported.local_slot as usize >= program.local_count + || !export_names.insert(exported.name.as_str()) + { + return Err(ValidationError::InvalidCallableMetadata( + "exported callable metadata is invalid", + )); + } + } Ok(()) } @@ -838,6 +855,11 @@ fn write_callable_metadata(out: &mut Vec, program: &Program) -> Result<(), W out.extend_from_slice(&binding.local_slot.to_le_bytes()); out.extend_from_slice(&binding.prototype_id.to_le_bytes()); } + write_u32_count("exported callables", program.exported_callables.len(), out)?; + for exported in &program.exported_callables { + write_string("exported callable name", &exported.name, out)?; + out.extend_from_slice(&exported.local_slot.to_le_bytes()); + } Ok(()) } @@ -854,6 +876,7 @@ type CallableMetadata = ( Vec, Vec, Vec, + Vec, ); fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result { @@ -949,11 +972,20 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result int; let mut i = 0; let mut sum = 0; while i < 8 { @@ -227,7 +227,7 @@ impl HostFunction for PendingOnceThenAddOne { #[test] fn jit_pending_host_call_waits_and_resumes_without_replay() { let source = r#" - fn maybe_wait(x); + fn maybe_wait(x: int) -> int; let mut i = 0; let mut sum = 0; while i < 5 { diff --git a/tests/vm/vm_async_runtime_tests.rs b/tests/vm/vm_async_runtime_tests.rs index d72a0948..b58f8fe8 100644 --- a/tests/vm/vm_async_runtime_tests.rs +++ b/tests/vm/vm_async_runtime_tests.rs @@ -88,6 +88,14 @@ impl HostAsyncBridge for TestAsyncBridge { .expect("test async ops lock poisoned") .poll_op(op_id, cx) } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.ops + .lock() + .expect("test async ops lock poisoned") + .pending + .remove(&op_id); + } } struct AsyncAddOneFunction { @@ -197,6 +205,31 @@ async fn async_host_call_waits_and_resumes_via_tokio_runtime() { assert_eq!(vm.stack(), &[Value::Int(42)]); } +#[tokio::test(flavor = "current_thread")] +async fn reset_cancels_pending_host_bridge_operation() { + let ops = Arc::new(Mutex::new(TestAsyncOps::default())); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(build_async_import_program(41)); + vm.bind_function( + "edge::async_add_one", + Box::new(AsyncAddOneFunction::new( + ops.clone(), + calls, + Duration::from_secs(60), + )), + ); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + + assert!(matches!( + vm.run().expect("pending call"), + VmStatus::Waiting(_) + )); + assert_eq!(ops.lock().unwrap().pending.len(), 1); + vm.reset_for_reuse(); + assert_eq!(ops.lock().unwrap().pending.len(), 0); + assert_eq!(vm.waiting_host_op_id(), None); +} + #[tokio::test(flavor = "current_thread")] async fn vm_waiting_on_async_host_op_does_not_block_tokio_tasks() { let ops = Arc::new(Mutex::new(TestAsyncOps::default())); diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 227a7fd3..5eff37ac 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -55,7 +55,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]]), 9); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 10); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -85,10 +85,10 @@ fn decode_rejects_invalid_magic_version_and_truncation() { )); let mut old_version = encoded.clone(); - old_version[4..6].copy_from_slice(&8u16.to_le_bytes()); + old_version[4..6].copy_from_slice(&9u16.to_le_bytes()); assert!(matches!( decode_program(&old_version), - Err(WireError::UnsupportedVersion(8)) + Err(WireError::UnsupportedVersion(9)) )); let truncated = &encoded[..encoded.len() - 1]; @@ -146,7 +146,7 @@ fn validate_accepts_known_good_program() { } #[test] -fn callable_metadata_roundtrips_vmbc_v9() { +fn callable_metadata_roundtrips_vmbc_v10() { let compiled = vm::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } From 36f81879284dd86ae0e2fe1dbecbb473a6247549 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 01:52:48 +0800 Subject: [PATCH 13/18] feat(vm): finalize callable backend format revisions --- pd-vm-nostd/src/vmbc.rs | 48 ++- pd-vm-nostd/tests/embedded_vmbc.rs | 23 ++ src/debugger/recording.rs | 10 +- src/vm/aot/artifact.rs | 477 +++--------------------- src/vm/native/mod.rs | 2 +- src/vmbc.rs | 242 ++++++------ tests/wire/assembler_vmbc_edge_tests.rs | 35 +- 7 files changed, 261 insertions(+), 576 deletions(-) diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 37acd5cd..6de3153b 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -11,6 +11,43 @@ const MAGIC: [u8; 4] = *b"VMBC"; const VERSION_V10: u16 = 10; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; +const MAX_CONSTANT_DEPTH: usize = 64; + +fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result { + if depth >= MAX_CONSTANT_DEPTH { + return Err(WireError::LengthTooLarge("constant nesting depth", depth)); + } + match cursor.read_u8()? { + 0 => Ok(Value::Int(cursor.read_i64()?)), + 1 => Ok(Value::Bool(cursor.read_bool()?)), + 2 => Ok(Value::string(cursor.read_string()?)), + 3 => Ok(Value::Float(cursor.read_f64()?)), + 4 => Ok(Value::Null), + 5 => Ok(Value::bytes(cursor.read_blob()?.to_vec())), + 6 => { + let count = cursor.read_u32()? as usize; + let mut values = Vec::new(); + reserve(&mut values, "constant array", count)?; + for _ in 0..count { + values.push(read_constant(cursor, depth + 1)?); + } + Ok(Value::array(values)) + } + 7 => { + let count = cursor.read_u32()? as usize; + let mut entries = Vec::new(); + reserve(&mut entries, "constant map", count)?; + for _ in 0..count { + entries.push(( + read_constant(cursor, depth + 1)?, + read_constant(cursor, depth + 1)?, + )); + } + Ok(Value::map(entries)) + } + tag => Err(WireError::InvalidConstantTag(tag)), + } +} pub fn decode_program(bytes: &[u8]) -> Result { let mut cursor = Cursor::new(bytes); @@ -32,16 +69,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let mut constants = Vec::new(); reserve(&mut constants, "constants", constant_count)?; for _ in 0..constant_count { - let value = match cursor.read_u8()? { - 0 => Value::Int(cursor.read_i64()?), - 1 => Value::Bool(cursor.read_bool()?), - 2 => Value::string(cursor.read_string()?), - 3 => Value::Float(cursor.read_f64()?), - 4 => Value::Null, - 5 => Value::bytes(cursor.read_blob()?.to_vec()), - tag => return Err(WireError::InvalidConstantTag(tag)), - }; - constants.push(value); + constants.push(read_constant(&mut cursor, 0)?); } let code = cursor.read_blob()?.to_vec(); diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 9fe52968..d61c6242 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -52,6 +52,29 @@ fn embedded_decoder_reads_host_generated_v10() { assert_eq!(program.imports()[0].arity, 1); } +#[test] +fn embedded_decoder_reads_nested_container_constants() { + let source = Program::new( + vec![Value::array(vec![ + Value::Int(1), + Value::map(vec![(Value::string("key"), Value::Bool(true))]), + ])], + vec![OpCode::Ret as u8], + ); + let bytes = encode_program(&source).expect("nested constants should encode"); + let program = decode_program(&bytes).expect("nested constants should decode"); + assert_eq!( + program.constants()[0], + EmbeddedValue::array(vec![ + EmbeddedValue::Int(1), + EmbeddedValue::map(vec![( + EmbeddedValue::string("key"), + EmbeddedValue::Bool(true), + )]), + ]) + ); +} + #[test] fn embedded_decoder_accepts_compiler_type_and_debug_metadata() { let compiled = compile_source("let mut x = 40; x = x + 2; print(x);") diff --git a/src/debugger/recording.rs b/src/debugger/recording.rs index 54cbd344..516c6c21 100644 --- a/src/debugger/recording.rs +++ b/src/debugger/recording.rs @@ -102,7 +102,7 @@ impl VmRecording { pub fn encode(&self) -> Result, VmRecordingError> { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 4; + const VERSION: u16 = 5; let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -167,7 +167,7 @@ impl VmRecording { pub fn decode(bytes: &[u8]) -> Result { const MAGIC: [u8; 4] = *b"PDRC"; - const VERSION: u16 = 4; + const VERSION: u16 = 5; let mut cursor = RecordingCursor::new(bytes); @@ -594,16 +594,16 @@ mod tests { } #[test] - fn recording_v4_rejects_legacy_versions() { + fn recording_v5_rejects_legacy_versions() { let recording = VmRecording { program: Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]), frames: Vec::new(), terminal_status: Some(VmStatus::Halted), }; let bytes = recording.encode().expect("recording should encode"); - assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 4); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 5); - for legacy in [1u16, 2u16, 3u16] { + for legacy in [1u16, 2u16, 3u16, 4u16] { let mut legacy_bytes = bytes.clone(); legacy_bytes[4..6].copy_from_slice(&legacy.to_le_bytes()); assert!(matches!( diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index b0e3bb6d..b7f387f7 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -1,8 +1,7 @@ use std::io; use std::path::Path; -use crate::bytecode::{Program, TypeMap, Value, ValueType}; -use crate::compiler::ir::TypeSchema; +use crate::bytecode::Program; use crate::vm::native::{ helper_entry_offset, interrupt_helper_entry_offset, selected_codegen_backend, }; @@ -12,8 +11,8 @@ use super::super::jit::JitConfig; use super::compile::CompiledProgram; const MAGIC: [u8; 4] = *b"PAT\0"; -const VERSION: u16 = 5; -const ABI_VERSION: u16 = 4; +const VERSION: u16 = 6; +const ABI_VERSION: u16 = 5; const FLAGS: u16 = 0; #[derive(Debug)] @@ -360,386 +359,11 @@ fn write_string( } fn encode_program_payload(program: &Program) -> Result, AotArtifactError> { - let mut out = Vec::new(); - write_u32("program constants", program.constants.len(), &mut out)?; - for constant in &program.constants { - write_value(constant, &mut out)?; - } - write_u32("program code length", program.code.len(), &mut out)?; - out.extend_from_slice(&program.code); - write_u32("program imports", program.imports.len(), &mut out)?; - for import in &program.imports { - write_string("import name", &import.name, &mut out)?; - out.push(import.arity); - out.push(import.return_type as u8); - } - write_type_map(program.type_map.as_ref(), &mut out)?; - Ok(out) + crate::vmbc::encode_program(program).map_err(AotArtifactError::Wire) } fn decode_program_payload(bytes: &[u8]) -> Result { - let mut cursor = Cursor::new(bytes); - - let constant_count = cursor.read_u32()? as usize; - let mut constants = Vec::with_capacity(constant_count); - for _ in 0..constant_count { - constants.push(cursor.read_value()?); - } - - let code_len = cursor.read_u32()? as usize; - let code = cursor.read_exact(code_len)?.to_vec(); - - let import_count = cursor.read_u32()? as usize; - let mut imports = Vec::with_capacity(import_count); - for _ in 0..import_count { - imports.push(crate::bytecode::HostImport { - name: cursor.read_string()?, - arity: cursor.read_u8()?, - return_type: read_value_type(cursor.read_u8()?)?, - }); - } - - let type_map = read_type_map(&mut cursor)?; - if !cursor.is_at_end() { - return Err(AotArtifactError::TrailingBytes); - } - - let mut program = Program::with_imports_and_debug(constants, code, imports, None); - program.type_map = type_map; - Ok(program) -} - -fn write_value(value: &Value, out: &mut Vec) -> Result<(), AotArtifactError> { - match value { - Value::Null => out.push(0), - Value::Int(value) => { - out.push(1); - out.extend_from_slice(&value.to_le_bytes()); - } - Value::Float(value) => { - out.push(2); - out.extend_from_slice(&value.to_le_bytes()); - } - Value::Bool(value) => { - out.push(3); - out.push(u8::from(*value)); - } - Value::String(value) => { - out.push(4); - write_u32("value string", value.len(), out)?; - out.extend_from_slice(value.as_bytes()); - } - Value::Bytes(value) => { - out.push(5); - write_u32("value bytes", value.len(), out)?; - out.extend_from_slice(value.as_slice()); - } - Value::Array(values) => { - out.push(6); - write_u32("value array", values.len(), out)?; - for value in values.iter() { - write_value(value, out)?; - } - } - Value::Map(entries) => { - out.push(7); - let mut encoded_entries = entries - .iter() - .map(|(key, value)| { - let mut entry_bytes = Vec::new(); - write_value(key, &mut entry_bytes)?; - write_value(value, &mut entry_bytes)?; - Ok(entry_bytes) - }) - .collect::, AotArtifactError>>()?; - encoded_entries.sort_unstable(); - write_u32("value map", encoded_entries.len(), out)?; - for entry in encoded_entries { - out.extend_from_slice(&entry); - } - } - Value::Callable(_) => return Err(AotArtifactError::InvalidValueType(9)), - } - Ok(()) -} - -fn write_type_map(type_map: Option<&TypeMap>, out: &mut Vec) -> Result<(), AotArtifactError> { - let Some(type_map) = type_map else { - out.push(0); - return Ok(()); - }; - - out.push(1); - out.push(u8::from(type_map.strict_types)); - write_u32("type map locals", type_map.local_types.len(), out)?; - for ty in &type_map.local_types { - out.push(*ty as u8); - } - for schema in &type_map.local_schemas { - write_optional_schema(schema.as_ref(), out)?; - } - write_bool_slice("type map callable slots", &type_map.callable_slots, out)?; - write_bool_slice("type map optional slots", &type_map.optional_slots, out)?; - - let mut operand_entries = type_map - .operand_types - .iter() - .map(|(offset, pair)| (*offset, *pair)) - .collect::>(); - operand_entries.sort_unstable_by_key(|(offset, _)| *offset); - write_u32("type map operands", operand_entries.len(), out)?; - for (offset, (lhs, rhs)) in operand_entries { - write_u32("type map operand offset", offset, out)?; - out.push(lhs as u8); - out.push(rhs as u8); - } - Ok(()) -} - -fn read_type_map(cursor: &mut Cursor<'_>) -> Result, AotArtifactError> { - let flag = cursor.read_u8()?; - if flag == 0 { - return Ok(None); - } - if flag != 1 { - return Err(AotArtifactError::InvalidValueType(flag)); - } - - let strict_types = match cursor.read_u8()? { - 0 => false, - 1 => true, - other => return Err(AotArtifactError::InvalidValueType(other)), - }; - let local_count = cursor.read_u32()? as usize; - let mut local_types = Vec::with_capacity(local_count); - for _ in 0..local_count { - local_types.push(read_value_type(cursor.read_u8()?)?); - } - let mut local_schemas = Vec::with_capacity(local_count); - for _ in 0..local_count { - local_schemas.push(read_optional_schema(cursor)?); - } - let callable_slots = read_bool_vec(cursor, local_count)?; - let optional_slots = read_bool_vec(cursor, local_count)?; - - let operand_count = cursor.read_u32()? as usize; - let mut operand_types = std::collections::HashMap::with_capacity(operand_count); - for _ in 0..operand_count { - let offset = cursor.read_u32()? as usize; - let lhs = read_value_type(cursor.read_u8()?)?; - let rhs = read_value_type(cursor.read_u8()?)?; - operand_types.insert(offset, (lhs, rhs)); - } - - Ok(Some(TypeMap { - strict_types, - local_types, - local_schemas, - callable_slots, - optional_slots, - operand_types, - })) -} - -fn read_value_type(raw: u8) -> Result { - match raw { - 0 => Ok(ValueType::Unknown), - 1 => Ok(ValueType::Null), - 2 => Ok(ValueType::Int), - 3 => Ok(ValueType::Float), - 4 => Ok(ValueType::Bool), - 5 => Ok(ValueType::String), - 6 => Ok(ValueType::Bytes), - 7 => Ok(ValueType::Array), - 8 => Ok(ValueType::Map), - 9 => Ok(ValueType::Callable), - other => Err(AotArtifactError::InvalidValueType(other)), - } -} - -fn write_bool_slice( - field: &'static str, - values: &[bool], - out: &mut Vec, -) -> Result<(), AotArtifactError> { - write_u32(field, values.len(), out)?; - out.extend(values.iter().map(|value| u8::from(*value))); - Ok(()) -} - -fn read_bool_vec( - cursor: &mut Cursor<'_>, - expected_len: usize, -) -> Result, AotArtifactError> { - let count = cursor.read_u32()? as usize; - if count != expected_len { - return Err(AotArtifactError::UnexpectedEof); - } - let mut values = Vec::with_capacity(count); - for _ in 0..count { - values.push(match cursor.read_u8()? { - 0 => false, - 1 => true, - other => return Err(AotArtifactError::InvalidValueType(other)), - }); - } - Ok(values) -} - -fn write_optional_schema( - schema: Option<&TypeSchema>, - out: &mut Vec, -) -> Result<(), AotArtifactError> { - match schema { - Some(schema) => { - out.push(1); - write_schema(schema, out)?; - } - None => out.push(0), - } - Ok(()) -} - -fn read_optional_schema(cursor: &mut Cursor<'_>) -> Result, AotArtifactError> { - match cursor.read_u8()? { - 0 => Ok(None), - 1 => Ok(Some(read_schema(cursor)?)), - other => Err(AotArtifactError::InvalidValueType(other)), - } -} - -fn write_schema(schema: &TypeSchema, out: &mut Vec) -> Result<(), AotArtifactError> { - match schema { - TypeSchema::Unknown => out.push(0), - TypeSchema::Null => out.push(1), - TypeSchema::Int => out.push(2), - TypeSchema::Float => out.push(3), - TypeSchema::Number => out.push(4), - TypeSchema::Bool => out.push(5), - TypeSchema::String => out.push(6), - TypeSchema::Bytes => out.push(7), - TypeSchema::Optional(inner) => { - out.push(16); - write_schema(inner, out)?; - } - TypeSchema::GenericParam(name) => { - out.push(8); - write_string("schema generic", name, out)?; - } - TypeSchema::Named(name, type_args) => { - out.push(9); - write_string("schema name", name, out)?; - write_u32("schema type args", type_args.len(), out)?; - for type_arg in type_args { - write_schema(type_arg, out)?; - } - } - TypeSchema::Array(item) => { - out.push(10); - write_schema(item, out)?; - } - TypeSchema::ArrayTuple(items) => { - out.push(11); - write_u32("schema tuple items", items.len(), out)?; - for item in items { - write_schema(item, out)?; - } - } - TypeSchema::ArrayTupleRest { prefix, rest } => { - out.push(12); - write_u32("schema tuple prefix", prefix.len(), out)?; - for item in prefix { - write_schema(item, out)?; - } - write_schema(rest, out)?; - } - TypeSchema::Map(item) => { - out.push(13); - write_schema(item, out)?; - } - TypeSchema::Object(fields) => { - out.push(14); - let mut entries = fields.iter().collect::>(); - entries.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs)); - write_u32("schema object fields", entries.len(), out)?; - for (name, value) in entries { - write_string("schema object field", name, out)?; - write_schema(value, out)?; - } - } - TypeSchema::Callable { params, result } => { - out.push(15); - write_u32("schema callable params", params.len(), out)?; - for param in params { - write_schema(param, out)?; - } - write_schema(result, out)?; - } - } - Ok(()) -} - -fn read_schema(cursor: &mut Cursor<'_>) -> Result { - match cursor.read_u8()? { - 0 => Ok(TypeSchema::Unknown), - 1 => Ok(TypeSchema::Null), - 2 => Ok(TypeSchema::Int), - 3 => Ok(TypeSchema::Float), - 4 => Ok(TypeSchema::Number), - 5 => Ok(TypeSchema::Bool), - 6 => Ok(TypeSchema::String), - 7 => Ok(TypeSchema::Bytes), - 16 => Ok(TypeSchema::Optional(Box::new(read_schema(cursor)?))), - 8 => Ok(TypeSchema::GenericParam(cursor.read_string()?)), - 9 => { - let name = cursor.read_string()?; - let count = cursor.read_u32()? as usize; - let mut type_args = Vec::with_capacity(count); - for _ in 0..count { - type_args.push(read_schema(cursor)?); - } - Ok(TypeSchema::Named(name, type_args)) - } - 10 => Ok(TypeSchema::Array(Box::new(read_schema(cursor)?))), - 11 => { - let count = cursor.read_u32()? as usize; - let mut items = Vec::with_capacity(count); - for _ in 0..count { - items.push(read_schema(cursor)?); - } - Ok(TypeSchema::ArrayTuple(items)) - } - 12 => { - let count = cursor.read_u32()? as usize; - let mut prefix = Vec::with_capacity(count); - for _ in 0..count { - prefix.push(read_schema(cursor)?); - } - let rest = Box::new(read_schema(cursor)?); - Ok(TypeSchema::ArrayTupleRest { prefix, rest }) - } - 13 => Ok(TypeSchema::Map(Box::new(read_schema(cursor)?))), - 14 => { - let count = cursor.read_u32()? as usize; - let mut fields = std::collections::HashMap::with_capacity(count); - for _ in 0..count { - let name = cursor.read_string()?; - let value = read_schema(cursor)?; - fields.insert(name, value); - } - Ok(TypeSchema::Object(fields)) - } - 15 => { - let count = cursor.read_u32()? as usize; - let mut params = Vec::with_capacity(count); - for _ in 0..count { - params.push(read_schema(cursor)?); - } - let result = Box::new(read_schema(cursor)?); - Ok(TypeSchema::Callable { params, result }) - } - other => Err(AotArtifactError::InvalidValueType(other)), - } + crate::vmbc::decode_program(bytes).map_err(AotArtifactError::Wire) } struct Cursor<'a> { @@ -793,51 +417,6 @@ impl<'a> Cursor<'a> { String::from_utf8(self.read_exact(len)?.to_vec()).map_err(|_| AotArtifactError::InvalidUtf8) } - fn read_i64(&mut self) -> Result { - Ok(i64::from_le_bytes(self.read_exact_array::<8>()?)) - } - - fn read_f64(&mut self) -> Result { - Ok(f64::from_le_bytes(self.read_exact_array::<8>()?)) - } - - fn read_value(&mut self) -> Result { - match self.read_u8()? { - 0 => Ok(Value::Null), - 1 => Ok(Value::Int(self.read_i64()?)), - 2 => Ok(Value::Float(self.read_f64()?)), - 3 => match self.read_u8()? { - 0 => Ok(Value::Bool(false)), - 1 => Ok(Value::Bool(true)), - other => Err(AotArtifactError::InvalidBool(other)), - }, - 4 => Ok(Value::string(self.read_string()?)), - 5 => { - let len = self.read_u32()? as usize; - Ok(Value::bytes(self.read_exact(len)?.to_vec())) - } - 6 => { - let len = self.read_u32()? as usize; - let mut values = Vec::with_capacity(len); - for _ in 0..len { - values.push(self.read_value()?); - } - Ok(Value::array(values)) - } - 7 => { - let len = self.read_u32()? as usize; - let mut entries = Vec::with_capacity(len); - for _ in 0..len { - let key = self.read_value()?; - let value = self.read_value()?; - entries.push((key, value)); - } - Ok(Value::map(entries)) - } - other => Err(AotArtifactError::InvalidValueTag(other)), - } - } - fn is_at_end(&self) -> bool { self.offset == self.bytes.len() } @@ -846,7 +425,7 @@ impl<'a> Cursor<'a> { #[cfg(test)] mod tests { use super::*; - use crate::{BytecodeBuilder, Program, Value, ValueType}; + use crate::{BytecodeBuilder, Program, Value, ValueType, VmStatus}; #[test] fn aot_artifact_decode_rejects_invalid_magic_and_trailing_bytes() { @@ -942,4 +521,48 @@ mod tests { "standalone vm should install aot" ); } + + #[test] + fn aot_artifact_v6_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"); + 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]]), 6); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 5); + + let mut old_format = encoded.clone(); + old_format[4..6].copy_from_slice(&5u16.to_le_bytes()); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), + Err(AotArtifactError::UnsupportedVersion(5)) + )); + let mut old_abi = encoded.clone(); + old_abi[6..8].copy_from_slice(&4u16.to_le_bytes()); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&old_abi, JitConfig::default()), + Err(AotArtifactError::UnsupportedAbiVersion(4)) + )); + + let mut standalone = + Vm::new_from_aot_artifact_with_jit_config(&encoded, JitConfig::default()) + .expect("standalone callable artifact should load"); + assert_eq!( + standalone.run().expect("root should halt"), + VmStatus::Halted + ); + let callable = standalone + .resolve_exported_callable("add_one") + .expect("export metadata should survive artifact roundtrip"); + assert_eq!( + standalone + .invoke_callable(callable, &[Value::Int(41)]) + .expect("artifact callable should execute"), + Value::Int(42) + ); + } } diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index fad480d7..22245541 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -49,7 +49,7 @@ 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 = 3; +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 4; #[cfg(feature = "cranelift-jit")] pub(crate) fn selected_codegen_backend() -> &'static str { diff --git a/src/vmbc.rs b/src/vmbc.rs index c0732d0f..9fee6388 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -11,13 +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_V2: u16 = 2; -const VERSION_V3: u16 = 3; -const VERSION_V4: u16 = 4; -const VERSION_V5: u16 = 5; -const VERSION_V6: u16 = 6; const VERSION_V10: u16 = 10; -const ENCODE_VERSION: u16 = VERSION_V10; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -148,71 +142,125 @@ impl std::fmt::Display for ValidationError { impl std::error::Error for ValidationError {} +const MAX_CONSTANT_DEPTH: usize = 64; + +fn write_constant(value: &Value, out: &mut Vec, depth: usize) -> Result<(), WireError> { + if depth >= MAX_CONSTANT_DEPTH { + return Err(WireError::LengthTooLarge("constant nesting depth", depth)); + } + match value { + Value::Int(value) => { + out.push(0); + out.extend_from_slice(&value.to_le_bytes()); + } + Value::Bool(value) => { + out.push(1); + out.push(u8::from(*value)); + } + Value::String(value) => { + out.push(2); + write_u32_len("constant string", value.len(), out)?; + out.extend_from_slice(value.as_bytes()); + } + Value::Float(value) => { + out.push(3); + out.extend_from_slice(&value.to_le_bytes()); + } + Value::Null => out.push(4), + Value::Bytes(value) => { + out.push(5); + write_u32_len("constant bytes", value.len(), out)?; + out.extend_from_slice(value.as_slice()); + } + Value::Array(values) => { + out.push(6); + write_u32_count("constant array", values.len(), out)?; + for value in values.iter() { + write_constant(value, out, depth + 1)?; + } + } + Value::Map(entries) => { + out.push(7); + write_u32_count("constant map", entries.len(), out)?; + for (key, value) in entries.iter() { + write_constant(key, out, depth + 1)?; + write_constant(value, out, depth + 1)?; + } + } + Value::Callable(_) => return Err(WireError::UnsupportedConstantType("callable")), + } + Ok(()) +} + +fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result { + if depth >= MAX_CONSTANT_DEPTH { + return Err(WireError::LengthTooLarge("constant nesting depth", depth)); + } + match cursor.read_u8()? { + 0 => Ok(Value::Int(cursor.read_i64()?)), + 1 => match cursor.read_u8()? { + 0 => Ok(Value::Bool(false)), + 1 => Ok(Value::Bool(true)), + other => Err(WireError::InvalidBool(other)), + }, + 2 => { + let len = cursor.read_u32()? as usize; + let bytes = cursor.read_exact(len)?; + let text = String::from_utf8(bytes.to_vec()).map_err(|_| WireError::InvalidUtf8)?; + Ok(Value::string(text)) + } + 3 => Ok(Value::Float(cursor.read_f64()?)), + 4 => Ok(Value::Null), + 5 => { + let len = cursor.read_u32()? as usize; + Ok(Value::bytes(cursor.read_exact(len)?.to_vec())) + } + 6 => { + let count = cursor.read_u32()? as usize; + let mut values = Vec::with_capacity(count); + for _ in 0..count { + values.push(read_constant(cursor, depth + 1)?); + } + Ok(Value::array(values)) + } + 7 => { + let count = cursor.read_u32()? as usize; + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + entries.push(( + read_constant(cursor, depth + 1)?, + read_constant(cursor, depth + 1)?, + )); + } + Ok(Value::map(entries)) + } + tag => Err(WireError::InvalidConstantTag(tag)), + } +} + pub fn encode_program(program: &Program) -> Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&ENCODE_VERSION.to_le_bytes()); + out.extend_from_slice(&VERSION_V10.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; for constant in &program.constants { - match constant { - Value::Null => { - out.push(4); - } - Value::Int(value) => { - out.push(0); - out.extend_from_slice(&value.to_le_bytes()); - } - Value::Float(value) => { - out.push(3); - out.extend_from_slice(&value.to_le_bytes()); - } - Value::Bool(value) => { - out.push(1); - out.push(u8::from(*value)); - } - Value::String(value) => { - out.push(2); - write_u32_len("constant string", value.len(), &mut out)?; - out.extend_from_slice(value.as_bytes()); - } - Value::Bytes(value) => { - out.push(5); - write_u32_len("constant bytes", value.len(), &mut out)?; - out.extend_from_slice(value.as_slice()); - } - Value::Array(_) => { - return Err(WireError::UnsupportedConstantType("array")); - } - Value::Map(_) => { - return Err(WireError::UnsupportedConstantType("map")); - } - Value::Callable(_) => { - return Err(WireError::UnsupportedConstantType("callable")); - } - } + write_constant(constant, &mut out, 0)?; } write_u32_len("code", program.code.len(), &mut out)?; out.extend_from_slice(&program.code); - if ENCODE_VERSION >= VERSION_V4 { - write_u32_count("imports", program.imports.len(), &mut out)?; - for import in &program.imports { - write_string("import name", &import.name, &mut out)?; - out.push(import.arity); - out.push(import.return_type as u8); - } + write_u32_count("imports", program.imports.len(), &mut out)?; + for import in &program.imports { + write_string("import name", &import.name, &mut out)?; + out.push(import.arity); + out.push(import.return_type as u8); } - if ENCODE_VERSION >= VERSION_V6 { - write_type_map(&mut out, program.type_map.as_ref())?; - } - - if ENCODE_VERSION >= VERSION_V2 { - write_debug_info(&mut out, program.debug.as_ref())?; - } + write_type_map(&mut out, program.type_map.as_ref())?; + write_debug_info(&mut out, program.debug.as_ref())?; write_callable_metadata(&mut out, program)?; Ok(out) @@ -239,33 +287,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let constant_count = cursor.read_u32()? as usize; let mut constants = Vec::with_capacity(constant_count); for _ in 0..constant_count { - let tag = cursor.read_u8()?; - let value = match tag { - 4 => Value::Null, - 0 => Value::Int(cursor.read_i64()?), - 3 => Value::Float(cursor.read_f64()?), - 1 => { - let raw = cursor.read_u8()?; - match raw { - 0 => Value::Bool(false), - 1 => Value::Bool(true), - other => return Err(WireError::InvalidBool(other)), - } - } - 2 => { - let len = cursor.read_u32()? as usize; - let text_bytes = cursor.read_exact(len)?; - let text = - String::from_utf8(text_bytes.to_vec()).map_err(|_| WireError::InvalidUtf8)?; - Value::string(text) - } - 5 => { - let len = cursor.read_u32()? as usize; - Value::bytes(cursor.read_exact(len)?.to_vec()) - } - other => return Err(WireError::InvalidConstantTag(other)), - }; - constants.push(value); + constants.push(read_constant(&mut cursor, 0)?); } let code_len = cursor.read_u32()? as usize; @@ -279,16 +301,8 @@ pub fn decode_program(bytes: &[u8]) -> Result { return_type: read_value_type(cursor.read_u8()?)?, }); } - let type_map = if version >= VERSION_V6 { - read_type_map(&mut cursor)? - } else { - None - }; - let debug = if version >= VERSION_V2 { - read_debug_info(&mut cursor, version)? - } else { - None - }; + let type_map = read_type_map(&mut cursor)?; + let debug = read_debug_info(&mut cursor)?; let ( script_functions, callable_prototypes, @@ -1035,10 +1049,8 @@ fn write_debug_info(out: &mut Vec, debug: Option<&DebugInfo>) -> Result<(), for local in &debug.locals { write_string("debug local name", &local.name, out)?; out.push(local.index); - if ENCODE_VERSION >= VERSION_V5 { - write_optional_u32(local.declared_line, out); - write_optional_u32(local.last_line, out); - } + write_optional_u32(local.declared_line, out); + write_optional_u32(local.last_line, out); } Ok(()) @@ -1046,7 +1058,7 @@ fn write_debug_info(out: &mut Vec, debug: Option<&DebugInfo>) -> Result<(), } } -fn read_debug_info(cursor: &mut Cursor<'_>, version: u16) -> Result, WireError> { +fn read_debug_info(cursor: &mut Cursor<'_>) -> Result, WireError> { let flag = cursor.read_u8()?; match flag { 0 => Ok(None), @@ -1081,28 +1093,16 @@ fn read_debug_info(cursor: &mut Cursor<'_>, version: u16) -> Result= VERSION_V3 { - let local_count = cursor.read_u32()? as usize; - let mut locals = Vec::with_capacity(local_count); - for _ in 0..local_count { - let name = cursor.read_string()?; - let index = cursor.read_u8()?; - let (declared_line, last_line) = if version >= VERSION_V5 { - (read_optional_u32(cursor)?, read_optional_u32(cursor)?) - } else { - (None, None) - }; - locals.push(LocalInfo { - name, - index, - declared_line, - last_line, - }); - } - locals - } else { - Vec::new() - }; + let local_count = cursor.read_u32()? as usize; + let mut locals = Vec::with_capacity(local_count); + for _ in 0..local_count { + locals.push(LocalInfo { + name: cursor.read_string()?, + index: cursor.read_u8()?, + declared_line: read_optional_u32(cursor)?, + last_line: read_optional_u32(cursor)?, + }); + } Ok(Some(DebugInfo { source, diff --git a/tests/wire/assembler_vmbc_edge_tests.rs b/tests/wire/assembler_vmbc_edge_tests.rs index e2ba845d..cfb1a241 100644 --- a/tests/wire/assembler_vmbc_edge_tests.rs +++ b/tests/wire/assembler_vmbc_edge_tests.rs @@ -1,7 +1,9 @@ +use std::sync::Arc; + use vm::{ - Assembler, AssemblerError, DebugInfo, DisassembleOptions, LineInfo, OpCode, Program, - ValidationError, Value, WireError, assemble, compile_source, decode_program, - disassemble_program_with_options, encode_program, validate_program, + Assembler, AssemblerError, CallableKind, CallableValue, DebugInfo, DisassembleOptions, + LineInfo, OpCode, Program, ValidationError, Value, WireError, assemble, compile_source, + decode_program, disassemble_program_with_options, encode_program, validate_program, }; fn assert_asm_error_contains(source: &str, expected: &str) { @@ -120,17 +122,26 @@ fn assemble_reports_local_index_overflow() { } #[test] -fn encode_rejects_array_and_map_constants() { - let array_program = Program::new(vec![Value::array(vec![])], vec![OpCode::Ret as u8]); - let array_err = encode_program(&array_program).expect_err("array constants should be rejected"); +fn array_and_map_constants_roundtrip_while_callable_constants_are_rejected() { + let constants = vec![Value::array(vec![ + Value::Int(1), + Value::map(vec![(Value::string("key"), Value::Bool(true))]), + ])]; + let program = Program::new(constants.clone(), vec![OpCode::Ret as u8]); + let decoded = decode_program(&encode_program(&program).expect("containers should encode")) + .expect("containers should decode"); + assert_eq!(decoded.constants, constants); + + let callable = Value::Callable(Arc::new(CallableValue { + prototype_id: 0, + kind: CallableKind::FunctionItem, + env: None, + })); + let callable_program = Program::new(vec![callable], vec![OpCode::Ret as u8]); assert!(matches!( - array_err, - WireError::UnsupportedConstantType("array") + encode_program(&callable_program), + Err(WireError::UnsupportedConstantType("callable")) )); - - let map_program = Program::new(vec![Value::map(vec![])], vec![OpCode::Ret as u8]); - let map_err = encode_program(&map_program).expect_err("map constants should be rejected"); - assert!(matches!(map_err, WireError::UnsupportedConstantType("map"))); } #[test] From ee93406325ec3c67ebe19542f739d7ec84403eb7 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 02:19:36 +0800 Subject: [PATCH 14/18] feat(vm): make script call depth configurable --- README.md | 16 +++++++++ pd-vm-nostd/src/error.rs | 7 ++++ pd-vm-nostd/src/lib.rs | 2 +- pd-vm-nostd/src/vm.rs | 17 ++++++++- pd-vm-nostd/tests/embedded_host.rs | 21 +++++++++++ src/cli.rs | 56 +++++++++++++++++++++++++++++- src/lib.rs | 8 ++--- src/vm/mod.rs | 32 +++++++++++++++-- src/vm/tests.rs | 22 ++++++++++++ 9 files changed, 171 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1143c0f2..84c01bf3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ cargo build --workspace --release - [Bytecode and VMBC](#bytecode-and-vmbc) - [JIT](#jit) - [Regex Cache](#regex-cache) + - [Script Call Depth](#script-call-depth) - [Fuel Metering](#fuel-metering) - [Epoch Interruption](#epoch-interruption) - [Wasm Lint](#wasm-lint) @@ -198,6 +199,21 @@ Reducing the capacity evicts least-recently-used entries immediately. Cache statistics are available through `regex_cache_entry_count()`, `regex_cache_compile_count()`, and `regex_cache_hit_count()`. +### Script Call Depth + +Desktop and `no_std` VMs default to 1024 simultaneously active script call +frames. Embedders can inspect or change the positive limit per VM: + +```rust +let mut vm = Vm::new(program); +assert_eq!(vm.max_script_call_depth(), 1024); +vm.set_max_script_call_depth(256)?; +``` + +`pd-vm-run --max-call-depth 256 script.rss` applies the same limit from the +CLI. A value of zero is rejected. Exceeding the configured limit returns +`VmError::CallStackOverflow`. + ### Fuel Metering `pd-vm` provides Wasmtime-style fuel controls on both `Vm` and `Store`: diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index bb748abd..35caec48 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -15,6 +15,7 @@ pub enum VmError { InvalidCallable, InvalidCallablePrototype(u32), CallStackOverflow, + InvalidCallStackLimit(usize), InvalidCallArity { import: String, expected: u8, @@ -52,6 +53,12 @@ impl fmt::Display for VmError { write!(f, "invalid callable prototype: {index}") } Self::CallStackOverflow => f.write_str("script call stack overflow"), + Self::InvalidCallStackLimit(limit) => { + write!( + f, + "invalid script call stack limit {limit}: expected a positive value" + ) + } Self::InvalidCallArity { import, expected, diff --git a/pd-vm-nostd/src/lib.rs b/pd-vm-nostd/src/lib.rs index 3557a34e..6d86dafa 100644 --- a/pd-vm-nostd/src/lib.rs +++ b/pd-vm-nostd/src/lib.rs @@ -21,7 +21,7 @@ pub use program::{ HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, ValueType, }; pub use value::{CallableEnvironment, CallableKind, CallableValue, Value}; -pub use vm::{Vm, VmResult, VmStatus}; +pub use vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, Vm, VmResult, VmStatus}; pub use vmbc::decode_program; pub(crate) use host::resolve_host_functions; diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 77a77990..ef0cca48 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -12,6 +12,7 @@ use super::{ }; pub type VmResult = Result; +pub const DEFAULT_MAX_SCRIPT_CALL_DEPTH: usize = 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VmStatus { @@ -43,6 +44,7 @@ pub struct Vm { host_dispatcher: Option>, context: C, fuel: Option, + max_script_call_depth: usize, frames: Vec, } @@ -87,6 +89,7 @@ impl Vm { host_dispatcher, context, fuel: None, + max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, frames: Vec::new(), }; vm.initialize_root_callables(); @@ -176,7 +179,7 @@ impl Vm { } fn call_value(&mut self, argc: u8) -> VmResult<()> { - if self.frames.len() >= 1024 { + if self.frames.len() >= self.max_script_call_depth { return Err(VmError::CallStackOverflow); } let operand_count = argc as usize + 1; @@ -524,6 +527,18 @@ impl Vm { self.fuel = Some(fuel); } + pub fn max_script_call_depth(&self) -> usize { + self.max_script_call_depth + } + + pub fn set_max_script_call_depth(&mut self, limit: usize) -> VmResult<()> { + if limit == 0 { + return Err(VmError::InvalidCallStackLimit(limit)); + } + self.max_script_call_depth = limit; + Ok(()) + } + pub fn clear_fuel(&mut self) { self.fuel = None; } diff --git a/pd-vm-nostd/tests/embedded_host.rs b/pd-vm-nostd/tests/embedded_host.rs index 1bdd9649..373ee198 100644 --- a/pd-vm-nostd/tests/embedded_host.rs +++ b/pd-vm-nostd/tests/embedded_host.rs @@ -55,6 +55,27 @@ fn script_call_frames_execute_in_embedded_runtime() { assert_eq!(vm.stack(), &[EmbeddedValue::Int(42)]); } +#[test] +fn script_call_depth_limit_is_configurable() { + let program = compile_embedded( + r#" + fn recurse(value: int) -> int { recurse(value) } + recurse(1); + "#, + ); + let mut vm = EmbeddedVm::new(program); + + assert_eq!(vm.max_script_call_depth(), 1024); + assert!(matches!( + vm.set_max_script_call_depth(0), + Err(VmError::InvalidCallStackLimit(0)) + )); + vm.set_max_script_call_depth(3) + .expect("positive call depth should be accepted"); + assert_eq!(vm.max_script_call_depth(), 3); + assert_eq!(vm.run(), Err(VmError::CallStackOverflow)); +} + #[test] fn static_host_binding_mutates_board_context() { let program = compile_embedded( diff --git a/src/cli.rs b/src/cli.rs index 6c31fa36..a02f99c1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,6 +54,7 @@ struct CliConfig { jit_dump: bool, jit_dump_show_machine_code: bool, jit_hot_loop_threshold: Option, + max_call_depth: Option, fuel: Option, epoch_deadline: Option, help: bool, @@ -83,6 +84,7 @@ impl Default for CliConfig { jit_dump: false, jit_dump_show_machine_code: true, jit_hot_loop_threshold: None, + max_call_depth: None, fuel: None, epoch_deadline: None, help: false, @@ -239,6 +241,10 @@ fn try_new_cli_vm_from_standalone_aot(cli: &CliConfig) -> Result, io: fn apply_runtime_flags(vm: &mut Vm, cli: &CliConfig) -> Result<(), io::Error> { vm.set_jit_native_bridge_stats_enabled(cli.jit_dump); + if let Some(limit) = cli.max_call_depth { + vm.set_max_script_call_depth(limit) + .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?; + } if let Some(interval) = cli.epoch_check_interval { vm.set_epoch_check_interval(interval) .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?; @@ -520,6 +526,30 @@ fn parse_cli_args(args: &[String]) -> Result { cfg.fuel = Some(value); index += 2; } + "--max-call-depth" => { + let raw = args + .get(index + 1) + .ok_or_else(|| "missing value for --max-call-depth".to_string())?; + let value = raw + .parse::() + .map_err(|_| format!("invalid --max-call-depth value '{raw}'"))?; + if value == 0 { + return Err("--max-call-depth must be greater than zero".to_string()); + } + cfg.max_call_depth = Some(value); + index += 2; + } + value if value.starts_with("--max-call-depth=") => { + let raw = value.trim_start_matches("--max-call-depth="); + let value = raw + .parse::() + .map_err(|_| format!("invalid --max-call-depth value '{raw}'"))?; + if value == 0 { + return Err("--max-call-depth must be greater than zero".to_string()); + } + cfg.max_call_depth = Some(value); + index += 1; + } "--epoch-deadline" => { let raw = args .get(index + 1) @@ -623,6 +653,7 @@ fn parse_cli_args(args: &[String]) -> Result { || cfg.tcp_addr.is_some() || cfg.jit_dump || cfg.jit_hot_loop_threshold.is_some() + || cfg.max_call_depth.is_some() || cfg.fuel.is_some() || cfg.epoch_deadline.is_some() || cfg.epoch_check_interval.is_some() @@ -650,6 +681,7 @@ fn parse_cli_args(args: &[String]) -> Result { || cfg.tcp_addr.is_some() || cfg.jit_dump || cfg.jit_hot_loop_threshold.is_some() + || cfg.max_call_depth.is_some() || cfg.fuel.is_some() || cfg.epoch_deadline.is_some() || cfg.epoch_check_interval.is_some() @@ -679,6 +711,7 @@ fn parse_cli_args(args: &[String]) -> Result { || cfg.tcp_addr.is_some() || cfg.jit_dump || cfg.jit_hot_loop_threshold.is_some() + || cfg.max_call_depth.is_some() || cfg.fuel.is_some() || cfg.epoch_deadline.is_some() || cfg.epoch_check_interval.is_some() @@ -733,6 +766,7 @@ fn parse_cli_args(args: &[String]) -> Result { || cfg.tcp_addr.is_some() || cfg.jit_dump || cfg.jit_hot_loop_threshold.is_some() + || cfg.max_call_depth.is_some() || cfg.fuel.is_some() || cfg.epoch_deadline.is_some() || cfg.epoch_check_interval.is_some() @@ -853,7 +887,7 @@ fn print_usage(binary_name: &str) { " {binary_name} [--jit-hot-loop ] [--jit-dump|--dump-jit] [--jit-dump-no-code] [--emit-vmbc ] [source_path]" ); println!( - " {binary_name} [--fuel |--epoch-deadline ] [--epoch-check-interval ] [source_path]" + " {binary_name} [--max-call-depth ] [--fuel |--epoch-deadline ] [--epoch-check-interval ] [source_path]" ); println!(" {binary_name} debug [--tcp ] [source_path]"); println!(); @@ -861,6 +895,9 @@ fn print_usage(binary_name: &str) { println!(" -V, --version Show version with git metadata"); println!(" -h, --help Show this help"); println!(" --check In fmt mode, fail if formatting would change the file"); + println!( + " --max-call-depth Set the positive script call-frame limit (default: 1024)" + ); } fn cli_build_features() -> Vec { @@ -1682,6 +1719,7 @@ mod tests { assert!(!cfg.jit_dump); assert!(cfg.jit_dump_show_machine_code); assert!(cfg.jit_hot_loop_threshold.is_none()); + assert!(cfg.max_call_depth.is_none()); assert!(cfg.fuel.is_none()); assert!(cfg.epoch_deadline.is_none()); assert!(cfg.source.is_none()); @@ -1818,6 +1856,22 @@ mod tests { assert_eq!(cfg.source.as_deref(), Some("examples/example.rss")); } + #[test] + fn parse_cli_max_call_depth_flag() { + let cfg = parse_cli_args(&[s("--max-call-depth"), s("3"), s("examples/example.rss")]) + .expect("parse should succeed"); + assert_eq!(cfg.max_call_depth, Some(3)); + + let equals = parse_cli_args(&[s("--max-call-depth=4"), s("examples/example.rss")]) + .expect("equals form should parse"); + assert_eq!(equals.max_call_depth, Some(4)); + + assert_eq!( + parse_cli_args(&[s("--max-call-depth"), s("0"), s("examples/example.rss"),]), + Err("--max-call-depth must be greater than zero".to_string()) + ); + } + #[test] fn parse_cli_fuel_requires_value() { let err = parse_cli_args(&[s("--fuel")]).expect_err("parse should fail"); diff --git a/src/lib.rs b/src/lib.rs index ec0a4b9c..bf9b7113 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,10 +88,10 @@ pub use jit::{ pub use vm::diagnostics::render_vm_error; #[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, EpochCheckpoint, EpochHandle, FuelCheckpoint, - HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, HostFunctionRegistry, - HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, ScriptArgs, - ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, + AotArtifactError, CallOutcome, CallReturn, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, + EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, + HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, + ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, }; #[cfg(feature = "runtime")] diff --git a/src/vm/mod.rs b/src/vm/mod.rs index d30eb744..b9e9bf51 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -92,6 +92,7 @@ pub enum VmError { CallStackOverflow { limit: usize, }, + InvalidCallStackLimit(usize), UnboundImport(String), InvalidOpcode(u8), BytecodeBounds, @@ -162,6 +163,12 @@ impl std::fmt::Display for VmError { VmError::CallStackOverflow { limit } => { write!(f, "script call stack limit {limit} exceeded") } + VmError::InvalidCallStackLimit(limit) => { + write!( + f, + "invalid script call stack limit {limit}: expected a positive value" + ) + } VmError::UnboundImport(name) => write!(f, "unbound host import '{name}'"), VmError::InvalidOpcode(opcode) => write!(f, "invalid opcode {opcode}"), VmError::BytecodeBounds => write!(f, "bytecode bounds"), @@ -194,7 +201,7 @@ impl std::error::Error for VmError {} pub type VmResult = Result; -const MAX_SCRIPT_CALL_DEPTH: usize = 1024; +pub const DEFAULT_MAX_SCRIPT_CALL_DEPTH: usize = 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VmStatus { @@ -337,6 +344,7 @@ pub struct Vm { resolved_calls: Vec, resolved_calls_dirty: bool, call_depth: usize, + max_script_call_depth: usize, execution_frames: Vec, host_return: Option, queued_callables: VecDeque, @@ -655,6 +663,7 @@ impl Vm { resolved_calls: Vec::new(), resolved_calls_dirty: true, call_depth: 0, + max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, execution_frames: vec![ExecutionFrame::root(local_count)], host_return: None, queued_callables: VecDeque::new(), @@ -731,6 +740,23 @@ impl Vm { } } + /// Returns the maximum number of simultaneously active script call frames. + pub fn max_script_call_depth(&self) -> usize { + self.max_script_call_depth + } + + /// Sets the maximum number of simultaneously active script call frames. + /// + /// The limit must be greater than zero. Existing active frames are not unwound; + /// the new limit is checked before the next script frame is entered. + pub fn set_max_script_call_depth(&mut self, limit: usize) -> VmResult<()> { + if limit == 0 { + return Err(VmError::InvalidCallStackLimit(limit)); + } + self.max_script_call_depth = limit; + Ok(()) + } + fn ensure_program_cache_key(&mut self) -> u64 { if !self.program_cache_key_ready { self.program_cache_key = compute_program_cache_key(&self.program); @@ -1226,9 +1252,9 @@ impl Vm { match prototype.target { CallableTarget::ScriptFunction(function_id) => { - if self.call_depth >= MAX_SCRIPT_CALL_DEPTH { + if self.call_depth >= self.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: MAX_SCRIPT_CALL_DEPTH, + limit: self.max_script_call_depth, }); } let function = self diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 4ec34c00..817da2af 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -131,6 +131,28 @@ fn callvalue_enters_script_frame_and_resumes_caller() { assert_eq!(vm.call_depth(), 0); } +#[test] +fn script_call_depth_limit_is_configurable() { + let compiled = crate::compile_source_for_repl( + "fn recurse(value: int) -> int { recurse(value) } recurse(1);", + ) + .expect("recursive callable should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + + assert_eq!(vm.max_script_call_depth(), 1024); + assert!(matches!( + vm.set_max_script_call_depth(0), + Err(VmError::InvalidCallStackLimit(0)) + )); + vm.set_max_script_call_depth(3) + .expect("positive call depth should be accepted"); + assert_eq!(vm.max_script_call_depth(), 3); + assert!(matches!( + vm.run(), + Err(VmError::CallStackOverflow { limit: 3 }) + )); +} + #[test] fn host_can_invoke_exported_callable_and_reset_rebinds_program_owned_value() { let mut bc = crate::BytecodeBuilder::new(); From 245419f992976c16ff4c5599ba0e890af304b8b7 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 02:21:32 +0800 Subject: [PATCH 15/18] plan(vm): define tail call optimization --- plans/tail_call_optimization_plan.md | 205 +++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 plans/tail_call_optimization_plan.md diff --git a/plans/tail_call_optimization_plan.md b/plans/tail_call_optimization_plan.md new file mode 100644 index 00000000..7ff57e08 --- /dev/null +++ b/plans/tail_call_optimization_plan.md @@ -0,0 +1,205 @@ +# Tail Call Optimization Implementation Plan + +**Goal:** Eliminate script-frame growth for semantically valid RSS tail calls, including self-recursion, mutual recursion, closures, and generic function values, while preserving current return, capture, fuel, debugger, JIT, AOT, and `no_std` behavior. + +## Current state + +RustScript currently has no tail-call or tail-recursion optimization: + +- the compiler emits `CallValue(argc)` for script calls and emits `Ret` separately; +- `Vm::execute_call_value` enters a new script frame for every script target; +- direct tail recursion therefore reaches `VmError::CallStackOverflow` at the configured script-call-depth limit; +- the interpreter's current `Call + Ret` fusion only completes a returned host/builtin call and does not reuse script frames; +- Trace JIT and AOT model `CallValue` as a real frame transition and likewise grow the script frame stack. + +The configurable call-depth limit remains a safety guard for non-tail recursion after this work. + +## 1. Define the tail-call semantic contract + +**Target:** Optimize only calls whose result is returned unchanged from the active script frame. + +**Tasks:** + +- Define tail position recursively for function/closure bodies, block tail expressions, `if` branches, `match` arms, and explicit return expressions when the grammar supports them. +- Exclude calls followed by arithmetic, conversion, container construction, assignment, defer/finalization work, or any operation that observes the current frame after the call. +- Optimize script-function-item and closure targets, including sibling and mutual tail calls. Preserve the current behavior for host/builtin targets until asynchronous host-tail continuations have a separate proven design. +- Keep the root frame explicit: a root-to-script call enters one script frame; only an already-active script frame may be replaced. This preserves `Halt`, host invocation, debugger root semantics, and the configured depth limit. +- Specify that one call-boundary interrupt/fuel charge is still consumed per logical tail call, so infinite tail recursion remains interruptible. +- Preserve callable arity/schema checks, capture ownership, map-iterator cleanup, drop events, stack traces, and error attribution at every logical call boundary. + +**Acceptance:** Characterization tests distinguish tail recursion from non-tail recursion and prove that only valid tail positions reuse a frame. + +## 2. Add an explicit tail-call bytecode contract + +**Target:** Encode compiler-proven tail position directly instead of relying on adjacency peepholes. + +**Files:** + +- `src/bytecode.rs` +- `src/assembler.rs` +- `src/vmbc.rs` +- `pd-vm-nostd/src/bytecode.rs` +- `pd-vm-nostd/src/vmbc.rs` +- `tests/wire/assembler_vmbc_edge_tests.rs` +- `tests/wire/wire_tests.rs` + +**Tasks:** + +- Add `TailCallValue(argc)` with the same operand layout as `CallValue(argc)`. +- Bump `BYTECODE_ABI_VERSION` and VMBC to v11 as a hard internal-format break; update no-std decoding and reject all earlier versions. +- Update opcode parsing, mnemonic rendering, assembler APIs, disassembly, validation, stack-effect analysis, function-region checks, and typed operand metadata. +- Require `TailCallValue` to occur inside a script function region. Reject it in the root region and reject malformed arity/stack shapes. +- Keep `Call` host-only and retain existing `CallValue` for non-tail script calls and Rust-host invocation boundaries. + +**Tests:** + +- VMBC v11 round-trip and old-version rejection; +- assembler/disassembler round-trip for `tailcallvalue`; +- validator rejection in the root region and across malformed function regions; +- no-std decoder parity. + +**Acceptance:** Tail intent is explicit, validated, serialized, and backend-independent. + +## 3. Lower RSS tail positions precisely + +**Target:** Emit `TailCallValue` only where the source result is returned unchanged. + +**Files:** + +- `src/compiler/codegen.rs` +- `src/compiler/ast.rs` and parser files only if explicit `return` requires new tail-context plumbing +- `src/compiler/typing.rs` +- `tests/compiler/compiler_rustscript_tests.rs` +- `tests/compiler/compiler_tests.rs` + +**Tasks:** + +- Add a tail-context codegen path, such as `compile_tail_expr`, rather than inferring tail position from emitted bytecode. +- Propagate tail context through block tails, both sides of `if`, all `match` arms, grouped expressions, and explicit return expressions. +- Emit callable plus arguments exactly once, then `TailCallValue(argc)` without a following `Ret` on that control-flow path. +- Preserve ordinary `CallValue + remaining expression + Ret` for non-tail calls. +- Support named functions, closures, aliases, branch-merged callable values, explicit generic function values, and inferred generic callables through the same opcode. +- Keep unresolved-generic, arity, move, borrow, and result-schema diagnostics unchanged. + +**Tests:** + +- direct accumulator recursion and mutual recursion compile to `TailCallValue`; +- closure self-recursion and aliased callable tail calls compile to `TailCallValue`; +- tail calls in both `if` branches and every `match` arm; +- `recurse(x) + 1`, `let y = recurse(x); y`, and calls followed by cleanup remain `CallValue`; +- generic tail-recursive functions retain substituted callable schemas. + +**Acceptance:** Disassembly proves precise tail marking with no adjacency-only heuristic. + +## 4. Reuse script frames in interpreter and `no_std` + +**Target:** Replace the active script frame atomically without increasing active script depth. + +**Files:** + +- `src/vm/mod.rs` +- `src/vm/value.rs` +- `src/vm/map_iter.rs` +- `src/vm/tests.rs` +- `pd-vm-nostd/src/vm.rs` +- `pd-vm-nostd/tests/embedded_host.rs` +- `tests/compiler/compiler_rustscript_tests.rs` + +**Tasks:** + +- Split callable validation/operand extraction from frame entry so `CallValue` and `TailCallValue` share target, arity, schema, and capture checks. +- Add one frame-replacement operation that: + - requires an active script frame; + - preserves the current frame's typed continuation and caller operand-stack base; + - releases current frame locals, capture-cell bindings, iterator state, and owned values exactly once; + - resizes/reinitializes the local window for the callee; + - installs parameters, captures, self binding, prototype id, and callee entry IP; + - leaves `call_depth` and `execution_frames.len()` unchanged. +- Perform all fallible validation before mutating the active frame. On failure, preserve deterministic unwind/error state. +- Charge fuel/epoch at every logical tail-call boundary and keep infinite tail recursion resumable/interruptible. +- Mirror the same semantics in `pd-vm-nostd` without allocation beyond the replacement frame/local window already required by the callee. + +**Tests:** + +- millions of accumulator tail calls complete with `max_script_call_depth = 1` after the initial root-to-script entry; +- mutual recursion and recursive closures keep a constant script-frame depth; +- non-tail recursion still reports the configured `CallStackOverflow` limit; +- tail-call arity/type errors report the callee and do not corrupt caller state; +- captures, moved values, mutable borrowed cells, map iterators, and drop-contract counters are released exactly once; +- fuel and epoch interruption stop infinite tail recursion and resume correctly; +- no-std parity for finite and interrupted tail recursion. + +**Acceptance:** Interpreter and no-std execute valid tail recursion in constant script-frame space. + +## 5. Implement native Trace JIT and AOT parity + +**Target:** Keep tail calls inside native execution with frame replacement rather than interpreter fallback. + +**Files:** + +- `src/vm/native/bridge.rs` +- `src/vm/native/codegen.rs` +- `src/vm/native/mod.rs` +- `src/vm/jit/trace.rs` +- `src/vm/jit/ir.rs` +- `src/vm/jit/recorder.rs` +- `src/vm/jit/native/lower.rs` +- `src/vm/aot/cfg.rs` +- `src/vm/aot/ir.rs` +- `src/vm/aot/ssa.rs` +- `src/vm/aot/compile.rs` +- `tests/jit/jit_tests.rs` + +**Tasks:** + +- Add a native frame-replacement helper/ABI operation distinct from frame entry. Bump `NATIVE_CALLABLE_ABI_VERSION`, AOT artifact version/ABI, and native trace cache revision. +- Model `TailCallValue` explicitly in Trace JIT and AOT IR/SSA terminators. +- Resolve the dynamic callable target, replace the active frame, and link/jump to the callee's native entry without increasing native link-dispatch depth or handing execution to the interpreter. +- Preserve return-to-caller continuation: when the eventual callee returns, it resumes the original caller of the replaced frame. +- Preserve interrupt/fuel checks at logical call boundaries and pending/error propagation. +- Reject unsupported native shapes during compile/install with a deterministic feature error; do not fail halfway through a frame replacement. + +**Tests:** + +- direct, mutual, closure, capture-bearing, and generic tail recursion under Trace JIT and AOT; +- constant frame depth and zero interpreter fallback; +- fuel yield/resume and epoch interruption inside infinite native tail recursion; +- artifact/cache rejection after ABI revision changes; +- result/error parity with interpreter and no-std. + +**Acceptance:** At least one dynamic closure tail-call path and one named mutual-recursion path execute natively in each backend with constant frame depth. + +## 6. Debugger, recording, diagnostics, docs, and final verification + +**Target:** Preserve logical observability despite physical frame reuse. + +**Files:** + +- `src/debugger/mod.rs` +- `src/debugger/recording.rs` +- `src/debug_info.rs` +- `src/vm/diagnostics.rs` +- `README.md` +- relevant examples and changelog files + +**Tasks:** + +- Decide and document debugger behavior: expose the current physical stack by default and record a tail-call transition event containing caller/callee prototype and source location. +- Ensure `next`, `out`, breakpoints, stack rendering, and recording/replay remain deterministic across a tail-call transition. +- Bump debugger recording format and reject earlier recordings. +- Render tail-call errors at the logical call site and include a bounded tail-call transition history if stack traces need recursion context without unbounded memory. +- Document `TailCallValue`, supported source tail positions, interaction with `--max-call-depth`, and the absence of optimization for host/builtin tail calls in the first release. + +**Final verification:** + +```bash +cargo fmt --all -- --check +cargo test --workspace --all-features +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo build --workspace --all-features --release +git diff --check +``` + +Also run focused interpreter, no-std, Trace JIT, AOT, VMBC, debugger recording, fuel, epoch, capture-lifecycle, and configurable-depth matrices. + +**Acceptance:** Valid script tail recursion is constant-space in every runtime backend; non-tail recursion remains bounded by the configured call-depth limit; debugging and interruption remain usable. \ No newline at end of file From 5f8ea6908b95fadec0981c6542cb4743a3d3671e Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 02:26:35 +0800 Subject: [PATCH 16/18] fix(builtins): keep callable metadata builtins internal --- src/builtins/runtime/core.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/builtins/runtime/core.rs b/src/builtins/runtime/core.rs index d1666c65..4abd9338 100644 --- a/src/builtins/runtime/core.rs +++ b/src/builtins/runtime/core.rs @@ -910,6 +910,14 @@ mod tests { assert_eq!(BuiltinFunction::from_call_index(index), Some(builtin)); } assert_eq!(BUILTIN_CALL_COUNT, 89); + for name in ["__bind_callable", "__detach_local"] { + assert!( + !crate::builtins::language_builtin_specs() + .iter() + .any(|spec| spec.name == name), + "internal callable metadata operation must not be language-visible: {name}" + ); + } for alias in BUILTIN_CALL_BASE + 89..=BUILTIN_CALL_BASE + 92 { assert_eq!(BuiltinFunction::from_call_index(alias), None); } From 9bc6446f6932ed29f177e0b7352174d6eb09588e Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 02:45:40 +0800 Subject: [PATCH 17/18] plan(vm): define static capture cycle analysis --- plans/static_capture_cycle_analysis_plan.md | 465 ++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 plans/static_capture_cycle_analysis_plan.md diff --git a/plans/static_capture_cycle_analysis_plan.md b/plans/static_capture_cycle_analysis_plan.md new file mode 100644 index 00000000..1cefc58d --- /dev/null +++ b/plans/static_capture_cycle_analysis_plan.md @@ -0,0 +1,465 @@ +# Static Capture Ownership-Cycle Analysis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `codex-superpowers-subagent-driven-development` or `codex-superpowers-executing-plans` to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Reject every callable capture ownership cycle during compilation or bytecode validation, first with early source diagnostics and then with a sound capture-region/provenance analysis, so desktop and no-std runtimes can remove recursive cycle checks from captured-local stores. + +**Architecture:** Add a flow-sensitive ownership-provenance pass after lifetime rewriting and type inference. The pass models capture cells, callable environments, arrays, maps, branches, loops, calls, and host boundaries as a finite may-reference graph. A write into capture cell `C` is accepted only when the graph proves that the written value cannot reach `C`; unknown provenance is a compile-time error. The same invariant is encoded in Program metadata and revalidated for VMBC/AOT/untrusted assembled Programs before runtime cycle checks are removed. + +**Tech Stack:** Rust, RustScript frontend IR, availability/lifetime analysis, interprocedural fixed-point dataflow, VMBC validator, desktop `Arc>`, no-std `Rc>`, Trace JIT, AOT. + +--- + +## Semantic boundary + +Static rejection cannot accept every dynamically cycle-free program in a higher-order language while also proving every execution cycle-free. Unbounded closure allocation, dynamic container indexing, recursion, host-returned `Value`, and runtime aliases prevent a complete no-false-positive decision procedure. + +For this plan, **static precise determination** means: + +1. exact reachability over the compiler's finite symbolic capture-region/provenance graph; +2. sound joins at branches, loops, recursive SCCs, containers, and higher-order calls; +3. rejection of `Unknown` provenance at a captured-cell write; +4. validation of the same proof contract for every executable Program, including VMBC and manually assembled bytecode. + +This boundary guarantees that removal of the runtime graph walk does not permit an RC cycle. It intentionally rejects programs whose safety cannot be proven. + +## File map + +**Create:** + +- `src/compiler/lifetime/capture_cycles.rs` — stage-1 source diagnostics and stage-2 ownership analysis. +- `src/compiler/lifetime/capture_cycles/domain.rs` — capture regions, abstract values, heap nodes, reachability, and joins. +- `src/compiler/lifetime/capture_cycles/interprocedural.rs` — function/callable summaries and SCC fixed point. +- `src/compiler/lifetime/capture_cycles/tests.rs` — unit tests for graph transfer and join rules. + +**Modify:** + +- `src/compiler/lifetime.rs` — expose the capture-cycle pass. +- `src/compiler/pipeline.rs` — run the pass after availability rewriting and type inference, before codegen. +- `src/compiler/mod.rs` — add focused compile diagnostics. +- `src/compiler/ir.rs` — carry source/callable identity required by diagnostics and summaries. +- `src/compiler/typing.rs` and `src/compiler/typing/*` — expose callable/container schemas to provenance classification. +- `src/bytecode.rs` — store capture-write proof metadata and bump internal ABI revisions. +- `src/vmbc.rs` — serialize and validate proof metadata. +- `src/assembler.rs` — require explicit proof metadata for assembled capture-writing Programs. +- `src/vm/mod.rs` — remove the desktop runtime recursive graph walk only after proof validation is mandatory. +- `pd-vm-nostd/src/bytecode.rs` — decode proof metadata. +- `pd-vm-nostd/src/vmbc.rs` — enforce the revised VMBC contract. +- `pd-vm-nostd/src/vm.rs` — remove the no-std runtime graph walk only after validation is mandatory. +- `src/vm/jit/*`, `src/vm/aot/*`, and debugger recording files — bump revisions and preserve the verified invariant. +- `tests/compiler/compiler_rustscript_tests.rs` — source-level positive and negative cases. +- `tests/wire/assembler_vmbc_edge_tests.rs` and `tests/wire/wire_tests.rs` — untrusted Program and VMBC proof validation. +- `README.md` — document the ownership-cycle rule and conservative unknown-provenance rejection. + +--- + +## Stage 1: Static early diagnostics for obvious cycles + +### Task 1: Characterize current runtime-only behavior + +**Files:** + +- Modify: `tests/compiler/compiler_rustscript_tests.rs` +- Modify: `src/vm/tests.rs` +- Modify: `pd-vm-nostd/tests/embedded_host.rs` + +- [ ] Add source cases for direct self-capture assignment, indirect assignment through an alias, container-wrapped callable assignment, branch-dependent cycles, safe scalar captured mutation, and safe assignment of a callable that does not capture the target cell. + +Use cases shaped like: + +```rust +let mut callback = null; +let closure = || callback; +callback = closure; +``` + +and: + +```rust +let mut count = 0; +let increment = || { + count = count + 1; + count +}; +``` + +- [ ] Run the focused compiler/runtime tests and record that direct cycles currently compile and fail only in `store_local_with_drop_contract` or no-std `Stloc`. + +```bash +cargo test -p pd-vm --all-features --test compiler_tests capture_cycle -- --nocapture +cargo test -p pd-vm --all-features --lib shared_capture_cell_rejects_callable_ownership_cycle -- --nocapture +cargo test -p pd-vm-nostd capture_cycle -- --nocapture +``` + +- [ ] Keep these tests as the semantic migration matrix; change expected phases only in the tasks that introduce each diagnostic. + +### Task 2: Add focused compile diagnostics + +**Files:** + +- Modify: `src/compiler/mod.rs` +- Create: `src/compiler/lifetime/capture_cycles.rs` +- Modify: `src/compiler/lifetime.rs` +- Modify: `src/compiler/pipeline.rs` + +- [ ] Add two compile errors with source line and local name: + +```rust +CompileError::CaptureOwnershipCycle { + line: Option, + source_name: Option, + captured_local: String, + path: Vec, +} + +CompileError::UnprovenCaptureWrite { + line: Option, + source_name: Option, + captured_local: String, + reason: String, +} +``` + +- [ ] Add `capture_cycles::reject_obvious_cycles(&FrontendIr) -> Result<(), CompileError>` after lifetime capture modes are known. + +- [ ] In stage 1, detect only syntactically certain back-edges: + + - assigning a closure/function value directly to a cell captured by that callable; + - assigning a local whose exact callable binding captures the target cell; + - assigning an array/map literal that directly contains such a callable; + - direct aliases whose callable identity is still exact in `callable_bindings`. + +- [ ] Build diagnostic paths such as: + +```text +assignment to 'callback' would create a callable capture ownership cycle: +callback -> closure environment -> callback +``` + +- [ ] Do not reject scalar mutation or callable values proven to capture different cells. + +- [ ] Run focused tests. Direct cases must now fail during compilation; dynamic and indirect cases must continue reaching the runtime guard until stage 2. + +```bash +cargo test -p pd-vm --all-features --test compiler_tests obvious_capture_cycle -- --nocapture +``` + +- [ ] Commit stage 1 independently. + +```bash +git add src/compiler tests/compiler/compiler_rustscript_tests.rs +git commit -m "Reject obvious callable capture cycles statically" +``` + +--- + +## Stage 2: Static precise determination over ownership provenance + +### Task 3: Define the ownership-provenance domain + +**Files:** + +- Create: `src/compiler/lifetime/capture_cycles/domain.rs` +- Create: `src/compiler/lifetime/capture_cycles/tests.rs` +- Modify: `src/compiler/lifetime/capture_cycles.rs` + +- [ ] Define symbolic region and heap identities: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct CaptureRegionId { + owner: CallableOwnerId, + source_slot: LocalSlot, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct HeapNodeId(u32); + +#[derive(Clone, Debug, PartialEq, Eq)] +enum OwnershipProvenance { + AcyclicScalar, + References(BTreeSet), + Heap(HeapNodeId), + Unknown(UnknownProvenanceReason), +} +``` + +- [ ] Model callable environments and aggregate values: + +```rust +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct OwnershipGraph { + cell_contents: BTreeMap, + heap_edges: BTreeMap>, +} + +enum OwnershipTarget { + Capture(CaptureRegionId), + Heap(HeapNodeId), +} +``` + +- [ ] Implement deterministic `join`, `references`, `reachable`, and `write_capture` operations. + +`write_capture(target, value)` must: + +1. reject `Unknown`; +2. reject when `value` reaches `target` through callable, cell, array, or map edges; +3. update the cell edge only after checks pass. + +- [ ] Unit-test direct, transitive, aggregate, mutually recursive, branch-joined, and non-cyclic graphs. + +```bash +cargo test -p pd-vm --all-features --lib compiler::lifetime::capture_cycles -- --nocapture +``` + +### Task 4: Add flow-sensitive intraprocedural transfer + +**Files:** + +- Modify: `src/compiler/lifetime/capture_cycles.rs` +- Modify: `src/compiler/ir.rs` +- Modify: `src/compiler/typing.rs` +- Modify: `src/compiler/typing/*` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` + +- [ ] Track one `OwnershipProvenance` per local alongside the capture-cell graph. + +- [ ] Implement expression transfer rules: + + - primitive/string/bytes values: `AcyclicScalar`; + - callable creation: references exactly its borrowed/mutably borrowed capture regions; moved/copy captures contribute the provenance of captured values; + - local loads: copy the local provenance; + - array/map literals: allocate a symbolic heap node and union member/key/value edges; + - array/map concatenation and mutation: update or conservatively join heap edges; + - dynamic indexing: return the aggregate member provenance; + - `if`/`match`: join local and graph states from all reachable branches; + - loops: iterate to a monotone fixed point; + - values whose schema excludes callable/array/map: `AcyclicScalar` even when their origin is a host call; + - dynamic `Value`, unknown callable result, or erased aggregate element: `Unknown`. + +- [ ] At every assignment to a `Borrow`/`BorrowMut` capture region, call `write_capture` and emit `CaptureOwnershipCycle` or `UnprovenCaptureWrite`. + +- [ ] Preserve safe stateful closures and assignments between independently proven regions. + +- [ ] Add tests for container nesting, branch merges, loops, separate factory evaluations, aliases sharing one environment, and dynamic index reads. + +### Task 5: Add interprocedural and higher-order summaries + +**Files:** + +- Create: `src/compiler/lifetime/capture_cycles/interprocedural.rs` +- Modify: `src/compiler/lifetime/capture_cycles.rs` +- Modify: `src/compiler/ir.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` + +- [ ] Define parameterized summaries: + +```rust +#[derive(Clone, Debug, PartialEq, Eq)] +struct CallableOwnershipSummary { + return_provenance: OwnershipExpr, + captured_writes: Vec, + parameter_escape: Vec, +} +``` + +- [ ] Express summary provenance in terms of parameter indices and captured regions so generic and higher-order functions do not collapse immediately to `Unknown`. + +- [ ] Build the callable call graph, compute strongly connected components, and iterate summaries inside each SCC until no summary changes. + +- [ ] At a call site, substitute argument provenance into the callee summary and apply captured-write effects to the caller graph. + +- [ ] For dynamic callable dispatch, join every target retained by callable type/control-flow analysis. If the target set is open or erased, return `Unknown` and reject only when it reaches a captured-cell write. + +- [ ] Treat host functions according to declared schemas and effects: + + - primitive-only return schemas are `AcyclicScalar`; + - callable/array/map/unknown returns are `Unknown` unless the host declaration carries a verified `capture_free_return` effect; + - a host function accepting mutable VM access cannot provide that effect automatically. + +- [ ] Add compile tests for higher-order returns, generic identity/apply, recursive callable SCCs, host-returned values, callback parameters, and REPL entry locals. + +### Task 6: Make unknown-provenance rejection explicit language behavior + +**Files:** + +- Modify: `README.md` +- Modify: `src/compiler/mod.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` + +- [ ] Document that captured-cell writes require statically proven non-reachability. + +- [ ] Add diagnostics that identify the first provenance loss, for example: + +```text +cannot prove assignment to captured local 'callback' is ownership-cycle free: +value comes from dynamic callable return with an open target set +``` + +- [ ] Add source-level escape hatches only for declarations that can be verified mechanically. Do not add an unchecked annotation that bypasses the invariant. + +- [ ] Run all compiler suites in default, strict, dynamic, REPL, source-file, and linked-module modes. + +- [ ] Commit the precise analysis before changing runtime behavior. + +```bash +git add src/compiler README.md tests/compiler +git commit -m "Prove capture writes cycle free at compile time" +``` + +--- + +## Stage 3: Make the proof mandatory and remove runtime checks + +### Task 7: Encode and validate the Program proof contract + +**Files:** + +- Modify: `src/bytecode.rs` +- Modify: `src/assembler.rs` +- Modify: `src/vmbc.rs` +- Modify: `pd-vm-nostd/src/bytecode.rs` +- Modify: `pd-vm-nostd/src/vmbc.rs` +- Modify: `tests/wire/assembler_vmbc_edge_tests.rs` +- Modify: `tests/wire/wire_tests.rs` + +- [ ] Add Program metadata that identifies capture-backed local slots and records each compiler-verified captured write site: + +```rust +pub struct CaptureWriteProof { + pub instruction_ip: u32, + pub target_slot: u16, + pub provenance_hash: u64, +} +``` + +- [ ] Compute `provenance_hash` from the normalized ownership summary, callable layouts, capture layouts, local slot layout, and instruction operands. + +- [ ] Bump bytecode ABI and VMBC as a hard internal-format break. Reject earlier VMBC versions. + +- [ ] Extend `validate_program` so every `Stloc` that can target a capture-backed slot has a matching proof record and every proof record matches the current Program metadata/code hash. + +- [ ] Require manually assembled Programs to supply proof metadata. Programs without proof may execute only when validation proves that no local is capture-backed or no captured write exists. + +- [ ] Make `Vm::new`, VMBC decode, AOT artifact load, no-std `Vm::new`, and public Program installation paths validate the proof before execution. + +- [ ] Add tampering tests: changed opcode, target slot, callable layout, capture layout, local count, or proof hash must reject the Program before running. + +- [ ] Verify that malformed/untrusted bytecode cannot bypass the source compiler invariant. + +### Task 8: Remove desktop and no-std runtime graph walks + +**Files:** + +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/tests.rs` +- Modify: `pd-vm-nostd/src/vm.rs` +- Modify: `pd-vm-nostd/tests/embedded_host.rs` + +- [ ] Replace desktop captured-local store logic with direct cell replacement after Program validation: + +```rust +if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + let previous = { + let mut captured = cell + .lock() + .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; + core::mem::replace(&mut *captured, value.clone()) + }; + self.locals[absolute] = value; + self.drop_value_with_contract(previous); + return Ok(()); +} +``` + +- [ ] Delete desktop `value_references_capture_cell` and its `HashSet` dependency. + +- [ ] Remove the equivalent no-std recursive `Rc>` graph walk and its visited-vector allocation. + +- [ ] Replace runtime-cycle tests with compile rejection and Program-validation tests. Keep direct VM tests proving that validated captured scalar/callable writes update aliases and release previous values exactly once. + +- [ ] Add an assertion/test hook proving the recursive runtime check is absent from interpreter and no-std execution. + +### Task 9: Update native backends, recordings, and cache revisions + +**Files:** + +- Modify: `src/vm/native/mod.rs` +- Modify: `src/vm/native/bridge.rs` +- Modify: `src/vm/jit/*` +- Modify: `src/vm/aot/*` +- Modify: `src/debugger/recording.rs` +- Modify: relevant backend and artifact tests + +- [ ] Confirm Trace JIT and AOT stores already route through verified local-slot metadata or helper contracts. Remove any duplicate cycle walk if one exists. + +- [ ] Include the capture-write proof hash in native trace and AOT cache keys. + +- [ ] Bump native callable ABI, AOT artifact, and debugger recording revisions because old artifacts lack the mandatory proof. + +- [ ] Reject old recordings/artifacts and add tampering tests for proof mismatch. + +- [ ] Run native tests showing safe captured mutation remains inside compiled execution with no interpreter handoff. + +### Task 10: Final soundness gate + +**Files:** + +- Modify: `README.md` +- Modify: all capture-cycle test files from previous tasks + +- [ ] Audit every way a `Value` can reach `Stloc`: + + - source expressions; + - callable return values; + - host return values; + - callback arguments; + - REPL persisted locals; + - arrays/maps and dynamic indexing; + - VMBC/AOT load; + - public assembler and Program constructors; + - debugger replay; + - no-std host dispatch. + +- [ ] Require each path to produce proven provenance or reject before execution. + +- [ ] Run the final matrix: + +```bash +cargo fmt --all -- --check +cargo check --workspace +cargo check --workspace --no-default-features +cargo check --workspace --all-features +cargo test --workspace --all-features +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo build --workspace --all-features --release +git diff --check +``` + +- [ ] Run focused compiler, REPL, linked module, VMBC, assembler, no-std, Trace JIT, AOT, debugger recording, host callback, capture aliasing, drop-contract, and artifact-tampering suites. + +- [ ] Search the tree and confirm that both recursive runtime functions and their cycle-error strings are gone: + +```bash +git grep -n -E "value_references_capture_cell|callable capture ownership cycle is unsupported" -- src pd-vm-nostd +``` + +Expected: no runtime implementation matches. + +- [ ] Commit runtime removal only after every proof-entry path passes. + +```bash +git add src pd-vm-nostd tests README.md +git commit -m "Remove runtime capture cycle checks" +``` + +## Completion criteria + +- Obvious ownership cycles fail early with source-focused diagnostics. +- All remaining captured-cell writes are accepted only when static provenance proves no path back to the target cell. +- Unknown/open provenance at a captured-cell write is rejected at compile time. +- VMBC, AOT, debugger replay, assembler, and direct Program construction cannot bypass proof validation. +- Desktop and no-std no longer recursively inspect runtime `Value` graphs during captured-local stores. +- Safe mutable captures, independent closure instances, shared aliases, higher-order callables, generics, containers, callbacks, JIT, and AOT retain their behavior. +- RC ownership remains cycle-free without introducing tracing GC. \ No newline at end of file From b3b481ef9ce72d07763ae6911746ae0c06b19228 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 18 Jul 2026 03:03:55 +0800 Subject: [PATCH 18/18] feat(wasm): add callable values playground example --- .../examples/rss-callable-values-example.rss | 166 ++++++++++++++++++ pd-vm-wasm/src/lib.rs | 6 +- 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 pd-vm-wasm/examples/rss-callable-values-example.rss diff --git a/pd-vm-wasm/examples/rss-callable-values-example.rss b/pd-vm-wasm/examples/rss-callable-values-example.rss new file mode 100644 index 00000000..5203ab6d --- /dev/null +++ b/pd-vm-wasm/examples/rss-callable-values-example.rss @@ -0,0 +1,166 @@ +// First-class callable values in RustScript. +// This example covers the supported RSS language surface: named functions, +// closures, captures, higher-order calls, recursion, containers, identity, +// builtins, generic function values, and contextual generic inference. +// Named functions are values and can be aliased, reassigned, and passed around. +fn add_one(value: int) -> int { + value + 1 +} + +fn add_two(value: int) -> int { + value + 2 +} + +fn apply_twice(mapper: fn(int) -> int, value: int) -> int { + let once = mapper(value); + mapper(once) +} + +let named = add_one; +assert(named(41) == 42); +assert(apply_twice(add_one, 40) == 42); + +let mut rebound = add_one; +rebound = add_two; +assert(rebound(40) == 42); + +// Compatible callable signatures merge across control flow. +let branch_mapper = if true => { + add_one +} else => { + add_two +}; +assert(branch_mapper(41) == 42); + +let match_mapper = match 1 { + 1 => add_one, + _ => add_two, +}; +assert(match_mapper(41) == 42); + +// Functions can return named callable values. +fn get_adder() { + add_one +} + +let returned_function = get_adder(); +assert(returned_function(41) == 42); + +// Closure literals support zero or more parameters and contextual signatures. +let answer = 42; +let read_answer = || answer; +let double = |value| value * 2; +let typed_closure: fn(int) -> int = |value| value + 1; +assert(read_answer() == 42); +assert(double(21) == 42); +assert(typed_closure(41) == 42); + +// Capture-free and capture-bearing closures can be passed to higher-order code. +let increment = |value| value + 1; +assert(apply_twice(increment, 40) == 42); + +let delta = 2; +let captured_add = |value| value + delta; +assert(captured_add(40) == 42); + +// Copy, shared borrow, mutable borrow, and move capture modes remain explicit. +let copied = "copy"; +let copy_capture = |suffix| copied.copy() + suffix; +assert(copy_capture("!") == "copy!"); +assert(copied == "copy"); + +let borrowed = "borrow"; +let borrow_capture = |suffix| &borrowed + suffix; +assert(borrow_capture("!") == "borrow!"); +assert(borrowed == "borrow"); + +let mut shared = 1; +let read_shared = || &shared; +shared = 42; +assert(read_shared() == 42); + +let mut mut_shared = "mut"; +let mut_borrow_capture = |suffix| &mut mut_shared + suffix; +assert(mut_borrow_capture("!") == "mut!"); +assert(mut_shared == "mut"); + +let moved = "move"; +let move_capture = |suffix| moved + suffix; +assert(move_capture("!") == "move!"); + +// Escaping closures retain their environment after the factory returns. +fn make_adder(delta: int) { + |value| value + delta +} + +let add_five = make_adder(5); +assert(add_five(37) == 42); + +// Capturing nested functions are closure instances. Aliases share one +// environment, while separate factory calls allocate independent environments. +fn make_counter() { + let mut count = 0; + fn next() { + count = count + 1; + count + } + next +} + +let first_counter = make_counter(); +let counter_alias = first_counter; +let second_counter = make_counter(); +assert(first_counter() == 1); +assert(counter_alias() == 2); +assert(second_counter() == 1); + +// Closure literals support finite recursion through their self binding. +let recursive_closure = |value| if value == 0 => { + 0 +} else => { + recursive_closure(value - 1); + value +}; +assert(recursive_closure(5) == 5); + +// Callable values can be stored in arrays and maps, then invoked after lookup. +let callable_array = [add_one, add_two]; +let from_array = callable_array[0]; +assert(from_array(41) == 42); + +let callable_map = {mapper: add_two}; +let from_map = callable_map.mapper; +assert(from_map(40) == 42); + +// Callable equality uses function-item identity or closure-environment identity. +let item_alias = add_one; +let first_closure = |value| value + 1; +let second_closure = |value| value + 1; +assert(item_alias == add_one); +assert(first_closure == first_closure); +assert(first_closure != second_closure); + +// Builtin host functions are callable values too. +let length = len; +assert(length("rss") == 3); + +// Generic named functions can become explicit callable values. +fn identity(value: T) -> T { + value +} + +let int_identity = identity:: < int >; +assert(int_identity(42) == 42); + +// A callable annotation can infer the type arguments of a bare generic value. +let string_identity: fn(string) -> string = identity; +assert(string_identity("rustscript") == "rustscript"); + +// Higher-order generic context can infer a bare generic callable argument. +fn apply(mapper: fn(T) -> T, value: T) -> T { + mapper(value) +} + +assert(apply::(identity, 42) == 42); + +print("callable values: all assertions passed"); diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 4444447f..9ccc0896 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -980,9 +980,13 @@ mod runtime_tests { compile_source_with_flavor_and_options, }; - fn rss_playground_examples() -> [(&'static str, &'static str); 5] { + fn rss_playground_examples() -> [(&'static str, &'static str); 6] { [ ("Demo", include_str!("../examples/rss-complex-example.rss")), + ( + "Callable Values Example", + include_str!("../examples/rss-callable-values-example.rss"), + ), ( "IFFT Example", include_str!("../examples/rss-ifft-example.rss"),