@tishlang/tish 2.10.1 → 2.16.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/src/cli_help.rs +16 -0
  5. package/crates/tish/src/main.rs +24 -4
  6. package/crates/tish/tests/integration_test.rs +149 -0
  7. package/crates/tish_ast/src/ast.rs +10 -0
  8. package/crates/tish_builtins/src/array.rs +529 -56
  9. package/crates/tish_builtins/src/collections.rs +114 -40
  10. package/crates/tish_builtins/src/string.rs +95 -8
  11. package/crates/tish_bytecode/src/chunk.rs +7 -0
  12. package/crates/tish_bytecode/src/compiler.rs +560 -64
  13. package/crates/tish_bytecode/src/lib.rs +1 -1
  14. package/crates/tish_bytecode/src/opcode.rs +154 -2
  15. package/crates/tish_bytecode/src/serialize.rs +2 -0
  16. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  17. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  18. package/crates/tish_compile/src/codegen.rs +17736 -5269
  19. package/crates/tish_compile/src/infer.rs +1707 -190
  20. package/crates/tish_compile/src/lib.rs +29 -4
  21. package/crates/tish_compile/src/resolve.rs +66 -5
  22. package/crates/tish_compile/src/types.rs +224 -28
  23. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  24. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  25. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  26. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  27. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  28. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  29. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  30. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  31. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  32. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  33. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  34. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  35. package/crates/tish_compile_js/src/codegen.rs +91 -4
  36. package/crates/tish_compile_js/src/lib.rs +5 -2
  37. package/crates/tish_compile_js/src/tests_jsx.rs +175 -7
  38. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  39. package/crates/tish_core/Cargo.toml +4 -0
  40. package/crates/tish_core/src/json.rs +104 -5
  41. package/crates/tish_core/src/lib.rs +174 -0
  42. package/crates/tish_core/src/shape.rs +4 -2
  43. package/crates/tish_core/src/value.rs +565 -35
  44. package/crates/tish_core/src/vmref.rs +14 -0
  45. package/crates/tish_eval/src/eval.rs +675 -76
  46. package/crates/tish_eval/src/natives.rs +19 -0
  47. package/crates/tish_eval/src/value.rs +76 -21
  48. package/crates/tish_ffi/src/lib.rs +11 -1
  49. package/crates/tish_ffi/tests/double_free.rs +35 -0
  50. package/crates/tish_fmt/src/lib.rs +75 -1
  51. package/crates/tish_lexer/src/lib.rs +76 -0
  52. package/crates/tish_lexer/src/token.rs +4 -0
  53. package/crates/tish_lint/src/lib.rs +126 -0
  54. package/crates/tish_lsp/README.md +2 -1
  55. package/crates/tish_lsp/src/main.rs +378 -28
  56. package/crates/tish_native/src/build.rs +41 -0
  57. package/crates/tish_parser/Cargo.toml +4 -0
  58. package/crates/tish_parser/src/lib.rs +27 -0
  59. package/crates/tish_parser/src/parser.rs +302 -20
  60. package/crates/tish_resolve/src/lib.rs +9 -0
  61. package/crates/tish_runtime/Cargo.toml +5 -0
  62. package/crates/tish_runtime/src/http.rs +28 -10
  63. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  64. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  65. package/crates/tish_runtime/src/lib.rs +523 -42
  66. package/crates/tish_runtime/src/timers.rs +49 -2
  67. package/crates/tish_ui/src/jsx.rs +253 -0
  68. package/crates/tish_vm/src/jit.rs +2514 -117
  69. package/crates/tish_vm/src/vm.rs +1227 -182
  70. package/package.json +1 -1
  71. package/platform/darwin-arm64/tish +0 -0
  72. package/platform/darwin-x64/tish +0 -0
  73. package/platform/linux-arm64/tish +0 -0
  74. package/platform/linux-x64/tish +0 -0
  75. package/platform/win32-x64/tish.exe +0 -0
@@ -20,19 +20,64 @@
20
20
  //! Not compiled for wasm targets (cranelift-jit emits host code).
21
21
 
22
22
  use std::collections::HashMap;
23
+ use std::sync::Arc;
23
24
  use std::sync::{Mutex, OnceLock};
24
25
 
25
26
  use cranelift::codegen::settings::{self, Configurable};
26
27
  use cranelift::prelude::types;
27
28
  use cranelift::prelude::{
28
- AbiParam, Block, FloatCC, FunctionBuilder, FunctionBuilderContext, InstBuilder, IntCC, MemFlags,
29
- Value as ClifValue, Variable,
29
+ AbiParam, Block, FloatCC, FunctionBuilder, FunctionBuilderContext, InstBuilder, IntCC,
30
+ MemFlags, Value as ClifValue, Variable,
30
31
  };
31
32
  use cranelift_jit::{JITBuilder, JITModule};
32
33
  use cranelift_module::{Linkage, Module};
33
34
 
34
35
  use tishlang_ast::{BinOp, UnaryOp};
35
- use tishlang_bytecode::{u8_to_binop, u8_to_unaryop, Chunk, Constant, Opcode, NO_REST_PARAM};
36
+ use tishlang_bytecode::{
37
+ u8_to_binop, u8_to_unaryop, Chunk, Constant, MathUnaryFn, Opcode, NO_REST_PARAM,
38
+ };
39
+
40
+ /// A JIT-compiled hot LOOP REGION for on-stack replacement (#190). Unlike [`NumericFn`] (a whole
41
+ /// function over register-`f64` params), this is native code over a chunk's **numeric slot frame**:
42
+ /// the VM copies the region's live-in slots into an `f64` buffer, calls the region, then copies the
43
+ /// live-outs back. ABI: `extern "C" fn(slots: *mut f64, deopt: *mut u8) -> i32`, returning the EXIT
44
+ /// id (index into [`exits`]). `deopt` is reserved (a `*mut u8` flag): the v1 whitelist is pure numeric
45
+ /// slot math, which cannot deopt mid-run, so it is never set — it keeps the ABI stable for the future
46
+ /// array/property regions that will need a bail path. The pointer is into the never-freed `JITModule`.
47
+ #[derive(Clone)]
48
+ pub struct LoopFn {
49
+ ptr: usize,
50
+ /// Slots read or written inside the region — the live set. The `f64` buffer is indexed by
51
+ /// POSITION here (buffer slot `p` ↔ chunk slot `used_slots[p]`); the emitted code loads/stores at
52
+ /// that same position, so the VM and the native code agree without threading slot numbers.
53
+ pub used_slots: Vec<u16>,
54
+ /// Exit id → the chunk `ip` to resume interpreting at (a loop-exit / `break` target outside the
55
+ /// region). The region always has ≥1 exit (an exit-less region would be an uninterruptible native
56
+ /// loop, so compilation bails).
57
+ pub exits: Vec<usize>,
58
+ }
59
+
60
+ // SAFETY: identical to `NumericFn` — `ptr` is immutable executable code in the process-global,
61
+ // never-dropped `JITModule`; the slot buffer is caller-owned. Send/Sync so the frame VM (which may
62
+ // run on any thread) can hold a cached `LoopFn`.
63
+ unsafe impl Send for LoopFn {}
64
+ unsafe impl Sync for LoopFn {}
65
+
66
+ impl LoopFn {
67
+ /// Run the region. `buf` holds the live-ins (`used_slots.len()` `f64`s), updated in place with the
68
+ /// live-outs on return. `deopt` is a 1-byte flag (unused in v1). Returns the exit id. Safe wrapper
69
+ /// — the raw-pointer transmute (same soundness as [`NumericFn::call`]: immutable native code with a
70
+ /// fixed C ABI) is confined here, so call sites need no `unsafe`.
71
+ #[inline]
72
+ pub fn call(&self, buf: &mut [f64], deopt: &mut u8) -> i32 {
73
+ // SAFETY: `ptr` is immutable executable code compiled for exactly this `(*mut f64, *mut u8)`
74
+ // ABI; `buf`/`deopt` are valid for the call and the region only touches `buf[0..used_slots]`.
75
+ unsafe {
76
+ let f: extern "C" fn(*mut f64, *mut u8) -> i32 = std::mem::transmute(self.ptr);
77
+ f(buf.as_mut_ptr(), deopt as *mut u8)
78
+ }
79
+ }
80
+ }
36
81
 
37
82
  /// A JIT-compiled numeric function: a pointer to native code plus its arity.
38
83
  /// The pointer is into a leaked, never-freed `JITModule`, so it is valid for the
@@ -50,19 +95,61 @@ pub struct NumericFn {
50
95
  /// array-mode uniform 3-pointer ABI (the [`NumericFn::call_arrays`] path). Kept as a `u8` (arity
51
96
  /// ≤ 8) so `NumericFn` stays `Copy`. `TISH_JIT_ARRAYS`-gated; `0` in every default build.
52
97
  array_param_mask: u8,
98
+ /// #187: subset of `array_param_mask` whose arrays are WRITTEN (`arr[i] = v`). After a non-deopt
99
+ /// `call_arrays`, [`try_call_array_jit`] copies each such scratch buffer back into the caller's
100
+ /// `Value::Array`. `0` ⇒ every array param is read-only (no writeback).
101
+ array_writable_mask: u8,
102
+ /// #187: subset of `array_writable_mask` whose elements are written with a BOOL const (`arr[i]=true`)
103
+ /// rather than a number. The JIT flattens elements to `f64`, so the writeback re-boxes by the array's
104
+ /// ORIGINAL element type — sound only when the WRITTEN kind matches that entry type. `try_call_array_jit`
105
+ /// bails to the interpreter when a bool-written array is passed a number array (or vice versa); a mix of
106
+ /// bool + non-bool writes to one array is rejected at compile time (`classify_params` returns `(0,0,0)`).
107
+ array_bool_write_mask: u8,
108
+ /// True when this is a self-recursive function compiled with a trailing `*mut RecurGuard` param
109
+ /// (the recursion-depth bail, #381). Such a function must be invoked via [`NumericFn::call_guarded`];
110
+ /// non-recursive functions keep the plain ABI and [`NumericFn::call`] (zero overhead).
111
+ recur_guarded: bool,
112
+ /// True when this function has JIT'd local `f64` arrays (#189). It uses the plain register-`f64`
113
+ /// [`NumericFn::call`] ABI; an out-of-bounds array access (or a non-numeric return) sets a
114
+ /// per-thread deopt flag ([`jv_take_deopt`]) and the caller discards the result + re-interprets.
115
+ jv: bool,
116
+ /// #187: true when this function embeds a native call to a registered callee. Such a function is
117
+ /// NOT cached by [`try_compile_numeric`] (its embedded callee address could go stale if a
118
+ /// long-lived process reuses chunk addresses across programs) — it is recompiled per closure
119
+ /// creation, which resolves against the live callee registry. `false` (cacheable) for all others.
120
+ uses_xcall: bool,
121
+ /// #187: true when this is a VOID array-mode function (only returns the implicit `null`). Its
122
+ /// `f64` result is a dummy, so [`try_call_array_jit`] returns `Value::Null` instead of a number.
123
+ void_result: bool,
53
124
  }
54
125
 
55
126
  /// A flat numeric array handed to an array-mode JIT function: a raw `f64` slice (`ptr`, `len`).
56
- /// Built by the VM wrapper from a `NumberArray` (zero-copy) or by extracting an all-numeric
57
- /// `Array` into a scratch `Vec<f64>` (the wrapper only builds one when every element is a
58
- /// `Value::Number` non-numeric arrays never reach the JIT, so the slice is always valid `f64`).
127
+ /// Built by the VM wrapper by extracting an all-numeric `Array` into a scratch `Vec<f64>` (the
128
+ /// wrapper only builds one when every element is a `Value::Number`, so the slice is always valid
129
+ /// `f64`). `ptr` is `*mut` because #187 array-param WRITES store through it into the scratch buffer;
130
+ /// read-only params only load, so the mut provenance is harmless there.
59
131
  #[repr(C)]
60
132
  #[derive(Clone, Copy)]
61
133
  pub struct ArrayHandle {
62
- pub ptr: *const f64,
134
+ pub ptr: *mut f64,
63
135
  pub len: usize,
64
136
  }
65
137
 
138
+ /// Recursion guard handed to a self-recursive JIT'd numeric function (#381). tish's JIT is an
139
+ /// additive, bail-to-interpreter tier, so rather than throw from inside JIT'd code (V8/JSC's model),
140
+ /// a too-deep recursion simply BAILS — exactly the deopt path array-mode already uses for an
141
+ /// out-of-bounds read: the function compares its stack pointer to `stack_limit` at entry, and if it
142
+ /// has crossed it (recursion approaching stack exhaustion) it stores `tripped = 1` and returns a
143
+ /// sentinel instead of recursing further. `VmClosure::call` then raises the catchable `RangeError`
144
+ /// through the normal pending-throw path. A single stack-pointer compare per call, sized from the
145
+ /// REAL remaining stack (`stacker::remaining_stack`) — never a per-call counter, so the hot recursion
146
+ /// (fib/spectral_norm) is untaxed. `#[repr(C)]`: `stack_limit` at offset 0, `tripped` at offset 8.
147
+ #[repr(C)]
148
+ pub struct RecurGuard {
149
+ pub stack_limit: usize,
150
+ pub tripped: u8,
151
+ }
152
+
66
153
  /// Array-element reads inside JIT'd loops (`arr[i]`/`arr[const]`). **Default ON**; `TISH_JIT_ARRAYS=0`
67
154
  /// disables it (escape hatch). Cached in a `OnceLock` — NEVER read the env var on a hot path (see the
68
155
  /// frame-VM regression note in docs/perf.md). Purely ADDITIVE: only numeric-array-reduction functions
@@ -73,7 +160,60 @@ pub struct ArrayHandle {
73
160
  #[cfg(not(target_arch = "wasm32"))]
74
161
  pub fn jit_arrays_enabled() -> bool {
75
162
  static ENABLED: OnceLock<bool> = OnceLock::new();
76
- *ENABLED.get_or_init(|| std::env::var("TISH_JIT_ARRAYS").map(|v| v != "0").unwrap_or(true))
163
+ *ENABLED.get_or_init(|| {
164
+ std::env::var("TISH_JIT_ARRAYS")
165
+ .map(|v| v != "0")
166
+ .unwrap_or(true)
167
+ })
168
+ }
169
+
170
+ /// JIT-compiled function-LOCAL `f64` arrays via the `tish_jv_*` runtime (#189). **Default ON**;
171
+ /// `TISH_JIT_JV=0` disables it (escape hatch). Additive: a function whose arrays don't fit the
172
+ /// non-escaping-JV shape just runs interpreted, and any out-of-bounds access deopts to the interpreter.
173
+ #[cfg(not(target_arch = "wasm32"))]
174
+ pub fn jit_jv_enabled() -> bool {
175
+ static ENABLED: OnceLock<bool> = OnceLock::new();
176
+ *ENABLED.get_or_init(|| {
177
+ std::env::var("TISH_JIT_JV")
178
+ .map(|v| v != "0")
179
+ .unwrap_or(true)
180
+ })
181
+ }
182
+
183
+ /// Boolean scalar local slots in the numeric CFG JIT (#187). **Default ON**; `TISH_JIT_BOOL_SLOTS=0`
184
+ /// disables it. A `let flag = false` / `flag = true` / `if (flag)` local is represented as an `f64`
185
+ /// `0.0`/`1.0`; a syntactic pre-pass ([`classify_bool_slots`]) tags the slots, and the equality
186
+ /// guard in [`emit_simple_op`] + the return-of-bool bail keep it sound (a bool never reaches a
187
+ /// diverging `bool === number` compare or a boolean function result). Unblocks e.g. fannkuch.
188
+ pub fn jit_bool_slots_enabled() -> bool {
189
+ static ENABLED: OnceLock<bool> = OnceLock::new();
190
+ *ENABLED.get_or_init(|| {
191
+ std::env::var("TISH_JIT_BOOL_SLOTS")
192
+ .map(|v| v != "0")
193
+ .unwrap_or(true)
194
+ })
195
+ }
196
+
197
+ /// The self-recursion stack guard (#381). **Default ON**; `TISH_JIT_RECUR_GUARD=0` disables it.
198
+ ///
199
+ /// A JIT'd self-recursive numeric function recurses on the native stack (SelfCall lowers to a native
200
+ /// call), bypassing the VM's `inc_call_depth` ceiling. Without a guard, unbounded numeric recursion
201
+ /// overflows the native stack — an uncatchable `SIGSEGV`/abort that takes down the whole worker
202
+ /// process (all in-flight requests on it), the DoS hole #381 exists to close. The guard adds a
203
+ /// trailing `*mut RecurGuard` param whose entry compares the stack pointer to a per-thread limit and
204
+ /// bails (→ a catchable `RangeError`, like the interp/VM paths) before overflow. This is like Go's
205
+ /// per-prologue stack check — cheap safety for a server language — but the extra param must stay live
206
+ /// across the recursive calls, so it costs ~12-34% on hot numeric recursion (worst on trivial bodies
207
+ /// like `fib`). Trusted, provably-bounded hot recursion can opt out for raw speed. Cached in a
208
+ /// `OnceLock`; the env read is off the hot path (compile time only), never per call.
209
+ #[cfg(not(target_arch = "wasm32"))]
210
+ pub fn jit_recur_guard_enabled() -> bool {
211
+ static ENABLED: OnceLock<bool> = OnceLock::new();
212
+ *ENABLED.get_or_init(|| {
213
+ std::env::var("TISH_JIT_RECUR_GUARD")
214
+ .map(|v| v != "0")
215
+ .unwrap_or(true)
216
+ })
77
217
  }
78
218
 
79
219
  // SAFETY: `ptr` references immutable executable code in a module that is never
@@ -132,24 +272,140 @@ impl NumericFn {
132
272
  7 => {
133
273
  let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64) -> f64 =
134
274
  std::mem::transmute(self.ptr);
135
- f(args[0], args[1], args[2], args[3], args[4], args[5], args[6])
275
+ f(
276
+ args[0], args[1], args[2], args[3], args[4], args[5], args[6],
277
+ )
136
278
  }
137
279
  8 => {
138
280
  let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64, f64) -> f64 =
139
281
  std::mem::transmute(self.ptr);
140
- f(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7])
282
+ f(
283
+ args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7],
284
+ )
285
+ }
286
+ _ => f64::NAN,
287
+ }
288
+ }
289
+ }
290
+
291
+ /// Whether this function was compiled with a `RecurGuard` param (self-recursive). If so it MUST be
292
+ /// invoked via [`call_guarded`], not [`call`] (the ABI has the trailing pointer param). #381
293
+ #[inline]
294
+ pub fn recur_guarded(&self) -> bool {
295
+ self.recur_guarded
296
+ }
297
+
298
+ /// Call a self-recursive (`recur_guarded`) function, passing the `RecurGuard` as a trailing pointer
299
+ /// param. On return the caller inspects `guard.tripped`: if set, the recursion hit the stack limit
300
+ /// and bailed (the numeric result is a discarded sentinel) → raise a catchable `RangeError`. #381
301
+ #[inline]
302
+ pub fn call_guarded(&self, args: &[f64], guard: *mut RecurGuard) -> f64 {
303
+ // Same `extern "C"` f64-register ABI as `call`, plus a trailing pointer arg for the guard.
304
+ unsafe {
305
+ match self.arity {
306
+ 1 => {
307
+ let f: extern "C" fn(f64, *mut RecurGuard) -> f64 =
308
+ std::mem::transmute(self.ptr);
309
+ f(args[0], guard)
310
+ }
311
+ 2 => {
312
+ let f: extern "C" fn(f64, f64, *mut RecurGuard) -> f64 =
313
+ std::mem::transmute(self.ptr);
314
+ f(args[0], args[1], guard)
315
+ }
316
+ 3 => {
317
+ let f: extern "C" fn(f64, f64, f64, *mut RecurGuard) -> f64 =
318
+ std::mem::transmute(self.ptr);
319
+ f(args[0], args[1], args[2], guard)
320
+ }
321
+ 4 => {
322
+ let f: extern "C" fn(f64, f64, f64, f64, *mut RecurGuard) -> f64 =
323
+ std::mem::transmute(self.ptr);
324
+ f(args[0], args[1], args[2], args[3], guard)
325
+ }
326
+ 5 => {
327
+ let f: extern "C" fn(f64, f64, f64, f64, f64, *mut RecurGuard) -> f64 =
328
+ std::mem::transmute(self.ptr);
329
+ f(args[0], args[1], args[2], args[3], args[4], guard)
330
+ }
331
+ 6 => {
332
+ let f: extern "C" fn(f64, f64, f64, f64, f64, f64, *mut RecurGuard) -> f64 =
333
+ std::mem::transmute(self.ptr);
334
+ f(args[0], args[1], args[2], args[3], args[4], args[5], guard)
335
+ }
336
+ 7 => {
337
+ let f: extern "C" fn(
338
+ f64,
339
+ f64,
340
+ f64,
341
+ f64,
342
+ f64,
343
+ f64,
344
+ f64,
345
+ *mut RecurGuard,
346
+ ) -> f64 = std::mem::transmute(self.ptr);
347
+ f(
348
+ args[0], args[1], args[2], args[3], args[4], args[5], args[6], guard,
349
+ )
350
+ }
351
+ 8 => {
352
+ let f: extern "C" fn(
353
+ f64,
354
+ f64,
355
+ f64,
356
+ f64,
357
+ f64,
358
+ f64,
359
+ f64,
360
+ f64,
361
+ *mut RecurGuard,
362
+ ) -> f64 = std::mem::transmute(self.ptr);
363
+ f(
364
+ args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7],
365
+ guard,
366
+ )
141
367
  }
142
368
  _ => f64::NAN,
143
369
  }
144
370
  }
145
371
  }
146
372
 
373
+ /// Whether this function has JIT'd local arrays (#189). Such a function uses the ordinary
374
+ /// register-`f64` [`call`] ABI — its OOB/deopt signal is the per-thread flag (see
375
+ /// [`jv_reset_deopt`] / [`jv_take_deopt`]), not a trailing pointer param.
376
+ #[inline]
377
+ pub fn is_jv(&self) -> bool {
378
+ self.jv
379
+ }
380
+
147
381
  /// Bit k set ⇒ param k is an array param (read via `arr[i]`). `0` ⇒ pure-numeric (use [`call`]).
148
382
  #[inline]
149
383
  pub fn array_param_mask(&self) -> u8 {
150
384
  self.array_param_mask
151
385
  }
152
386
 
387
+ /// #187: bit k set ⇒ array param k is WRITTEN (`arr[i] = v`) and its scratch buffer must be copied
388
+ /// back into the caller's `Value::Array` after a non-deopt run. Subset of [`array_param_mask`].
389
+ #[inline]
390
+ pub fn array_writable_mask(&self) -> u8 {
391
+ self.array_writable_mask
392
+ }
393
+
394
+ /// #187: bit k set ⇒ writable array param k is written with BOOL consts (`arr[i]=true`), not numbers.
395
+ /// The writeback re-boxes by the entry array's element type, so [`try_call_array_jit`] must bail when
396
+ /// a bool-written array receives a number array (or a number-written array receives a bool array).
397
+ #[inline]
398
+ pub fn array_bool_write_mask(&self) -> u8 {
399
+ self.array_bool_write_mask
400
+ }
401
+
402
+ /// #187: true when this array-mode function is VOID (returns the implicit `null`); its `f64` result
403
+ /// is a dummy, so [`try_call_array_jit`] returns `Value::Null` for it.
404
+ #[inline]
405
+ pub fn returns_void(&self) -> bool {
406
+ self.void_result
407
+ }
408
+
153
409
  /// Call an array-mode function (`array_param_mask != 0`). `numeric` holds the f64 values for the
154
410
  /// numeric params in numeric-param order; `arrays` the [`ArrayHandle`]s for the array params in
155
411
  /// array-param order. Returns `(result, deopt)` — when `deopt` is true an out-of-bounds access
@@ -157,9 +413,22 @@ impl NumericFn {
157
413
  /// (OOB reads return `Value::Null` in the VM, whose per-operator coercion the JIT can't replicate).
158
414
  #[inline]
159
415
  pub fn call_arrays(&self, numeric: &[f64], arrays: &[ArrayHandle]) -> (f64, bool) {
416
+ self.call_arrays_guarded(numeric, arrays, std::ptr::null_mut())
417
+ }
418
+
419
+ /// #187: array-mode call, optionally passing a trailing `*mut RecurGuard` (non-null iff
420
+ /// `recur_guarded()` — a self-recursive array fn). On return the caller inspects `guard.tripped`:
421
+ /// set ⇒ the native recursion hit the stack limit and bailed (result is a sentinel) → RangeError.
422
+ #[inline]
423
+ pub fn call_arrays_guarded(
424
+ &self,
425
+ numeric: &[f64],
426
+ arrays: &[ArrayHandle],
427
+ guard: *mut RecurGuard,
428
+ ) -> (f64, bool) {
160
429
  let mut deopt: u8 = 0;
161
- // ONE uniform signature for every array-mode fn: (numeric*, handles*, deopt*) -> f64. Empty
162
- // slices pass a dangling-but-aligned non-null ptr (the body only loads indices it uses).
430
+ // ONE uniform signature for every array-mode fn: (numeric*, handles*, deopt*[, guard*]) -> f64.
431
+ // Empty slices pass a dangling-but-aligned non-null ptr (the body only loads indices it uses).
163
432
  let num_ptr = if numeric.is_empty() {
164
433
  std::ptr::NonNull::<f64>::dangling().as_ptr() as *const f64
165
434
  } else {
@@ -171,9 +440,19 @@ impl NumericFn {
171
440
  arrays.as_ptr()
172
441
  };
173
442
  let res = unsafe {
174
- let f: extern "C" fn(*const f64, *const ArrayHandle, *mut u8) -> f64 =
175
- std::mem::transmute(self.ptr);
176
- f(num_ptr, arr_ptr, &mut deopt as *mut u8)
443
+ if guard.is_null() {
444
+ let f: extern "C" fn(*const f64, *const ArrayHandle, *mut u8) -> f64 =
445
+ std::mem::transmute(self.ptr);
446
+ f(num_ptr, arr_ptr, &mut deopt as *mut u8)
447
+ } else {
448
+ let f: extern "C" fn(
449
+ *const f64,
450
+ *const ArrayHandle,
451
+ *mut u8,
452
+ *mut RecurGuard,
453
+ ) -> f64 = std::mem::transmute(self.ptr);
454
+ f(num_ptr, arr_ptr, &mut deopt as *mut u8, guard)
455
+ }
177
456
  };
178
457
  (res, deopt != 0)
179
458
  }
@@ -190,7 +469,32 @@ struct JitGlobal {
190
469
  /// reused by a different chunk, so we recompile (and overwrite) instead of returning stale native
191
470
  /// code. `None` still caches "not JIT-eligible". See [`chunk_fingerprint`].
192
471
  cache: HashMap<usize, (u64, Option<NumericFn>)>,
472
+ /// OSR loop-region cache (#190), keyed by `(chunk address, loop header ip)` with the same
473
+ /// fingerprint guard as `cache`. `None` caches "region not compilable" so a loop that fails the
474
+ /// whitelist is scanned once, not on every back-edge past the trigger threshold.
475
+ osr_cache: HashMap<(usize, usize), (u64, Option<LoopFn>)>,
193
476
  counter: usize,
477
+ /// `FuncId` of the imported `tish_math_call` host fn (#186), declared once at module init and
478
+ /// re-imported into each compiled function via `declare_func_in_func`.
479
+ math_call_id: cranelift_module::FuncId,
480
+ /// `FuncId`s of the imported `tish_jv_*` vector runtime (#189).
481
+ jv_fns: JvFns,
482
+ /// #187: directly-callable numeric callees, keyed by the stable global name a top-level function is
483
+ /// bound to (`Chunk.global_name`). Populated when a plain register-`f64` function compiles; a
484
+ /// caller's `name(args)` then lowers to a native cranelift call to `id`. Sound because the compiler
485
+ /// only stamps `global_name` on functions it proved are never reassigned/shadowed program-wide, so
486
+ /// the binding can never change under a cached caller. A caller that references a name NOT yet here
487
+ /// (a forward reference) simply bails to the interpreter.
488
+ callees: HashMap<Arc<str>, CalleeEntry>,
489
+ }
490
+
491
+ /// #187: a registered directly-callable numeric callee (register-`f64` ABI). Callers resolve against
492
+ /// the LIVE registry at compile time and are never cached ([`NumericFn::uses_xcall`]), so a name
493
+ /// re-registered by a later program simply overwrites this — a stale callee is never invoked.
494
+ #[derive(Clone, Copy)]
495
+ struct CalleeEntry {
496
+ id: cranelift_module::FuncId,
497
+ arity: u8,
194
498
  }
195
499
 
196
500
  // SAFETY: `JITModule` is `!Send`, but the single instance lives behind the
@@ -200,7 +504,172 @@ unsafe impl Send for JitGlobal {}
200
504
 
201
505
  static JIT: OnceLock<Option<Mutex<JitGlobal>>> = OnceLock::new();
202
506
 
203
- fn new_module() -> Option<JITModule> {
507
+ // #189 a minimal C-ABI `f64` vector runtime for JIT-compiled function-LOCAL arrays that never
508
+ // escape the frame. A qualifying local slot (only def = empty `[]`, only uses = push / `[i]` /
509
+ // `[i]=v` / `.length`) is addressed by a `u64` HANDLE into a per-thread arena instead of a boxed
510
+ // `Value::Array`, so its index math is a bounds-checked slab lookup, not a per-op bound-method
511
+ // alloc — and, because the arena is a plain `Vec<Vec<f64>>` behind a `RefCell`, the host fns need
512
+ // NO `unsafe` (no raw-pointer deref). SOUND because such arrays never escape: no `Value` ever
513
+ // references a `Vec`, so an out-of-bounds access can set the deopt flag, let the (about-to-be-
514
+ // discarded) native run finish, and re-run the interpreter with no observable state change. Handle
515
+ // `0` is the null sentinel (a JV slot inits to 0); every exit frees every live slot back to the
516
+ // free list, so the arena is bounded by a function's peak live-array count, not total allocations.
517
+ #[cfg(not(target_arch = "wasm32"))]
518
+ struct JvArena {
519
+ vecs: Vec<Vec<f64>>,
520
+ free: Vec<usize>,
521
+ deopt: bool,
522
+ }
523
+ #[cfg(not(target_arch = "wasm32"))]
524
+ thread_local! {
525
+ static JV_ARENA: std::cell::RefCell<JvArena> = const {
526
+ std::cell::RefCell::new(JvArena { vecs: Vec::new(), free: Vec::new(), deopt: false })
527
+ };
528
+ }
529
+ /// `handle` (1-based, `0` = null) → arena index, or `None` for the null handle.
530
+ #[cfg(not(target_arch = "wasm32"))]
531
+ #[inline]
532
+ fn jv_index(handle: u64) -> Option<usize> {
533
+ handle.checked_sub(1).map(|i| i as usize)
534
+ }
535
+ #[cfg(not(target_arch = "wasm32"))]
536
+ extern "C" fn tish_jv_new(cap: u64) -> u64 {
537
+ JV_ARENA.with(|c| {
538
+ let mut a = c.borrow_mut();
539
+ let v = Vec::with_capacity(cap as usize);
540
+ let idx = match a.free.pop() {
541
+ Some(i) => {
542
+ a.vecs[i] = v;
543
+ i
544
+ }
545
+ None => {
546
+ a.vecs.push(v);
547
+ a.vecs.len() - 1
548
+ }
549
+ };
550
+ idx as u64 + 1 // handle = index + 1 (0 stays reserved for null)
551
+ })
552
+ }
553
+ #[cfg(not(target_arch = "wasm32"))]
554
+ extern "C" fn tish_jv_push(handle: u64, x: f64) {
555
+ if let Some(idx) = jv_index(handle) {
556
+ JV_ARENA.with(|c| {
557
+ if let Some(v) = c.borrow_mut().vecs.get_mut(idx) {
558
+ v.push(x);
559
+ }
560
+ });
561
+ }
562
+ }
563
+ #[cfg(not(target_arch = "wasm32"))]
564
+ extern "C" fn tish_jv_get(handle: u64, i: u64) -> f64 {
565
+ JV_ARENA.with(|c| {
566
+ let mut a = c.borrow_mut();
567
+ match jv_index(handle)
568
+ .and_then(|idx| a.vecs.get(idx))
569
+ .and_then(|v| v.get(i as usize))
570
+ {
571
+ Some(&x) => x,
572
+ None => {
573
+ a.deopt = true; // OOB (or null): caller discards the result + re-interprets
574
+ f64::NAN
575
+ }
576
+ }
577
+ })
578
+ }
579
+ #[cfg(not(target_arch = "wasm32"))]
580
+ extern "C" fn tish_jv_set(handle: u64, i: u64, x: f64) {
581
+ JV_ARENA.with(|c| {
582
+ let mut a = c.borrow_mut();
583
+ match jv_index(handle)
584
+ .and_then(|idx| a.vecs.get_mut(idx))
585
+ .and_then(|v| v.get_mut(i as usize))
586
+ {
587
+ Some(p) => *p = x,
588
+ None => a.deopt = true, // OOB (or null) → deopt (no store performed)
589
+ }
590
+ })
591
+ }
592
+ #[cfg(not(target_arch = "wasm32"))]
593
+ extern "C" fn tish_jv_len(handle: u64) -> u64 {
594
+ JV_ARENA.with(|c| {
595
+ jv_index(handle)
596
+ .and_then(|idx| c.borrow().vecs.get(idx).map(|v| v.len() as u64))
597
+ .unwrap_or(0)
598
+ })
599
+ }
600
+ #[cfg(not(target_arch = "wasm32"))]
601
+ extern "C" fn tish_jv_free(handle: u64) {
602
+ if let Some(idx) = jv_index(handle) {
603
+ JV_ARENA.with(|c| {
604
+ let mut a = c.borrow_mut();
605
+ if let Some(v) = a.vecs.get_mut(idx) {
606
+ v.clear(); // keep capacity; the slot is returned to the free list for reuse
607
+ a.free.push(idx);
608
+ }
609
+ });
610
+ }
611
+ }
612
+ /// Signal a deopt from JIT'd code that can't produce an `f64` result (a non-numeric `return`, e.g. the
613
+ /// dead `return null` epilogue after a `while (true)`); the wrapper then re-interprets. #189
614
+ #[cfg(not(target_arch = "wasm32"))]
615
+ extern "C" fn tish_jv_deopt() {
616
+ JV_ARENA.with(|c| c.borrow_mut().deopt = true);
617
+ }
618
+ /// Clear the per-thread deopt flag before entering a JV function. #189
619
+ #[cfg(not(target_arch = "wasm32"))]
620
+ pub(crate) fn jv_reset_deopt() {
621
+ JV_ARENA.with(|c| c.borrow_mut().deopt = false);
622
+ }
623
+ /// Read and clear the deopt flag after a JV function returns; `true` ⇒ discard its result + interpret.
624
+ #[cfg(not(target_arch = "wasm32"))]
625
+ pub(crate) fn jv_take_deopt() -> bool {
626
+ JV_ARENA.with(|c| {
627
+ let mut a = c.borrow_mut();
628
+ std::mem::replace(&mut a.deopt, false)
629
+ })
630
+ }
631
+
632
+ /// Imported `FuncId`s of the `tish_jv_*` vector runtime (#189), declared once at module init.
633
+ #[derive(Clone, Copy)]
634
+ struct JvFns {
635
+ new: cranelift_module::FuncId,
636
+ push: cranelift_module::FuncId,
637
+ get: cranelift_module::FuncId,
638
+ set: cranelift_module::FuncId,
639
+ len: cranelift_module::FuncId,
640
+ free: cranelift_module::FuncId,
641
+ deopt: cranelift_module::FuncId,
642
+ }
643
+
644
+ /// Per-function-build handles for JV local arrays (#189): the `tish_jv_*` `FuncRef`s imported into
645
+ /// *this* function, plus the set of slots classified as JV arrays. Passed to [`build_body_cfg`].
646
+ /// The deopt flag lives in the per-thread arena (set by `get`/`set`/`deopt`), so — unlike array
647
+ /// mode — the JV function ABI carries no trailing deopt pointer.
648
+ struct JvCtx<'a> {
649
+ new: cranelift::codegen::ir::FuncRef,
650
+ push: cranelift::codegen::ir::FuncRef,
651
+ get: cranelift::codegen::ir::FuncRef,
652
+ set: cranelift::codegen::ir::FuncRef,
653
+ len: cranelift::codegen::ir::FuncRef,
654
+ free: cranelift::codegen::ir::FuncRef,
655
+ deopt: cranelift::codegen::ir::FuncRef,
656
+ slots: &'a std::collections::HashSet<usize>,
657
+ }
658
+
659
+ /// Host entry point the JIT calls for `Math.<fn>` intrinsics it doesn't lower to a native op (#186):
660
+ /// sin/cos/tan/exp/log/round/sign/… The single source of truth is [`MathUnaryFn::apply`], so JIT ≡
661
+ /// VM ≡ interpreter bit-for-bit. `extern "C"` and registered as a JIT symbol; the id is the operand.
662
+ #[cfg(not(target_arch = "wasm32"))]
663
+ extern "C" fn tish_math_call(id: i32, x: f64) -> f64 {
664
+ match tishlang_bytecode::MathUnaryFn::from_u16(id as u16) {
665
+ Some(m) => m.apply(x),
666
+ None => f64::NAN,
667
+ }
668
+ }
669
+
670
+ /// Build the JIT module and declare the imported host functions (`tish_math_call` #186, the
671
+ /// `tish_jv_*` vector runtime #189), returning the module + their `FuncId`s.
672
+ fn new_module() -> Option<(JITModule, cranelift_module::FuncId, JvFns)> {
204
673
  let mut flag_builder = settings::builder();
205
674
  // JIT code is loaded at a fixed address; no PIC / colocated libcalls needed.
206
675
  flag_builder.set("use_colocated_libcalls", "false").ok()?;
@@ -210,17 +679,82 @@ fn new_module() -> Option<JITModule> {
210
679
  let isa = isa_builder
211
680
  .finish(settings::Flags::new(flag_builder))
212
681
  .ok()?;
213
- let builder = JITBuilder::with_isa(isa, cranelift_module::default_libcall_names());
214
- Some(JITModule::new(builder))
682
+ let mut builder = JITBuilder::with_isa(isa, cranelift_module::default_libcall_names());
683
+ builder.symbol("tish_math_call", tish_math_call as *const u8);
684
+ builder.symbol("tish_jv_new", tish_jv_new as *const u8);
685
+ builder.symbol("tish_jv_push", tish_jv_push as *const u8);
686
+ builder.symbol("tish_jv_get", tish_jv_get as *const u8);
687
+ builder.symbol("tish_jv_set", tish_jv_set as *const u8);
688
+ builder.symbol("tish_jv_len", tish_jv_len as *const u8);
689
+ builder.symbol("tish_jv_free", tish_jv_free as *const u8);
690
+ builder.symbol("tish_jv_deopt", tish_jv_deopt as *const u8);
691
+ let mut module = JITModule::new(builder);
692
+
693
+ // `tish_math_call(i32 fn-id, f64 x) -> f64`.
694
+ let mut msig = module.make_signature();
695
+ msig.params.push(AbiParam::new(types::I32));
696
+ msig.params.push(AbiParam::new(types::F64));
697
+ msig.returns.push(AbiParam::new(types::F64));
698
+ let math_id = module
699
+ .declare_function("tish_math_call", Linkage::Import, &msig)
700
+ .ok()?;
701
+
702
+ // Helper to declare a `tish_jv_*` import from param/return abi lists.
703
+ let mut declare = |name: &str, params: &[AbiParam], rets: &[AbiParam]| {
704
+ let mut s = module.make_signature();
705
+ s.params.extend_from_slice(params);
706
+ s.returns.extend_from_slice(rets);
707
+ module.declare_function(name, Linkage::Import, &s).ok()
708
+ };
709
+ // A JV array is addressed by an `i64` HANDLE (a per-thread arena index; `0` = null), not a raw
710
+ // pointer. `get`/`set` need no deopt-pointer arg — OOB sets a per-thread flag the wrapper reads.
711
+ let jv = JvFns {
712
+ new: declare(
713
+ "tish_jv_new",
714
+ &[AbiParam::new(types::I64)],
715
+ &[AbiParam::new(types::I64)],
716
+ )?,
717
+ push: declare(
718
+ "tish_jv_push",
719
+ &[AbiParam::new(types::I64), AbiParam::new(types::F64)],
720
+ &[],
721
+ )?,
722
+ get: declare(
723
+ "tish_jv_get",
724
+ &[AbiParam::new(types::I64), AbiParam::new(types::I64)],
725
+ &[AbiParam::new(types::F64)],
726
+ )?,
727
+ set: declare(
728
+ "tish_jv_set",
729
+ &[
730
+ AbiParam::new(types::I64),
731
+ AbiParam::new(types::I64),
732
+ AbiParam::new(types::F64),
733
+ ],
734
+ &[],
735
+ )?,
736
+ len: declare(
737
+ "tish_jv_len",
738
+ &[AbiParam::new(types::I64)],
739
+ &[AbiParam::new(types::I64)],
740
+ )?,
741
+ free: declare("tish_jv_free", &[AbiParam::new(types::I64)], &[])?,
742
+ deopt: declare("tish_jv_deopt", &[], &[])?,
743
+ };
744
+ Some((module, math_id, jv))
215
745
  }
216
746
 
217
747
  fn jit() -> Option<&'static Mutex<JitGlobal>> {
218
748
  JIT.get_or_init(|| {
219
- new_module().map(|module| {
749
+ new_module().map(|(module, math_call_id, jv_fns)| {
220
750
  Mutex::new(JitGlobal {
221
751
  module,
222
752
  cache: HashMap::new(),
753
+ osr_cache: HashMap::new(),
223
754
  counter: 0,
755
+ math_call_id,
756
+ jv_fns,
757
+ callees: HashMap::new(),
224
758
  })
225
759
  })
226
760
  })
@@ -297,6 +831,19 @@ fn chunk_fingerprint(chunk: &Chunk) -> u64 {
297
831
 
298
832
  /// Get (or compile, then cache) the native numeric function for `chunk`.
299
833
  /// Returns `None` if the chunk isn't a straight-line numeric function.
834
+ /// #187: clear the directly-callable-callee registry at the start of each top-level program run, so a
835
+ /// long-lived process (REPL / embedder) never resolves a callee registered by a PRIOR program (a name
836
+ /// re-registered non-numerically would otherwise leave a stale native entry). Cross-callers aren't
837
+ /// cached, so they always re-resolve against the freshly-populated registry.
838
+ #[cfg(not(target_arch = "wasm32"))]
839
+ pub fn reset_callees() {
840
+ if let Some(lock) = jit() {
841
+ if let Ok(mut g) = lock.lock() {
842
+ g.callees.clear();
843
+ }
844
+ }
845
+ }
846
+
300
847
  pub fn try_compile_numeric(chunk: &Chunk) -> Option<NumericFn> {
301
848
  if !chunk.slot_based
302
849
  || chunk.rest_param_index != NO_REST_PARAM
@@ -317,10 +864,461 @@ pub fn try_compile_numeric(chunk: &Chunk) -> Option<NumericFn> {
317
864
  }
318
865
  }
319
866
  let result = compile_chunk(&mut g, chunk);
320
- g.cache.insert(key, (fp, result));
867
+ // #187: a function that embeds a native call to a registered callee is NOT cached — its callee
868
+ // address could go stale across programs in a long-lived process. It recompiles per closure
869
+ // creation (once, in practice), resolving against the live registry. Everything else caches.
870
+ if !result.is_some_and(|nf| nf.uses_xcall) {
871
+ g.cache.insert(key, (fp, result));
872
+ }
873
+ result
874
+ }
875
+
876
+ /// Compile the hot loop region `[header_ip, region_end)` of `chunk` to native code (#190 OSR), or
877
+ /// `None` if it is not a pure-numeric slot loop. Cached per `(chunk, header_ip)` with a fingerprint
878
+ /// guard (negative results included, so a non-compilable loop is scanned once). Called from the frame
879
+ /// VM's `JumpBack` handler once a loop's back-edge counter crosses the trigger threshold.
880
+ pub fn try_compile_loop(chunk: &Chunk, header_ip: usize, region_end: usize) -> Option<LoopFn> {
881
+ let key = (chunk as *const Chunk as usize, header_ip);
882
+ let fp = chunk_fingerprint(chunk);
883
+ let lock = jit()?;
884
+ let mut g = lock.lock().ok()?;
885
+ if let Some((cached_fp, cached)) = g.osr_cache.get(&key) {
886
+ if *cached_fp == fp {
887
+ return cached.clone();
888
+ }
889
+ }
890
+ let result = compile_loop_region(&mut g, chunk, header_ip, region_end);
891
+ g.osr_cache.insert(key, (fp, result.clone()));
321
892
  result
322
893
  }
323
894
 
895
+ /// Lower one hot loop region to a `LoopFn`. The region must be pure numeric slot math: the same
896
+ /// opcode whitelist as [`build_body_cfg`] minus everything that touches a non-slot value (calls,
897
+ /// member/index, arrays, objects, `LoadVar`, `Return`). Reuses [`emit_simple_op`] for the arithmetic
898
+ /// so the region is bit-for-bit identical to the interpreter's `eval_binop`. Live-ins are loaded from
899
+ /// the `slots` pointer at entry; each loop-exit target stores the live set back and returns its id.
900
+ fn compile_loop_region(
901
+ g: &mut JitGlobal,
902
+ chunk: &Chunk,
903
+ header_ip: usize,
904
+ region_end: usize,
905
+ ) -> Option<LoopFn> {
906
+ let code = &chunk.code;
907
+ let num_slots = chunk.num_slots as usize;
908
+ if num_slots == 0 || num_slots > 256 || header_ip >= region_end || region_end > code.len() {
909
+ return None;
910
+ }
911
+
912
+ // 1. Scan the region: validate the whitelist (op_size = None ⇒ bail), collect in-region block
913
+ // leaders, the live slot set, and the EXIT targets (jump targets outside the region). A
914
+ // JumpBack must stay inside the region (its own loop, possibly nested); one leaving the region
915
+ // is not a structured single-region loop → bail.
916
+ let mut leaders: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
917
+ leaders.insert(header_ip);
918
+ let mut used: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
919
+ let mut exit_targets: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
920
+ let mut ip = header_ip;
921
+ while ip < region_end {
922
+ let op = Opcode::from_u8(*code.get(ip)?)?;
923
+ match op {
924
+ // Pure slot / stack / arithmetic / structured control flow — the region vocabulary.
925
+ Opcode::Nop
926
+ | Opcode::Pop
927
+ | Opcode::Dup
928
+ | Opcode::EnterBlock
929
+ | Opcode::ExitBlock
930
+ | Opcode::LoopVarsEnd
931
+ | Opcode::LoopVarsBegin
932
+ | Opcode::UnaryOp
933
+ | Opcode::MathUnary => {} // #186 — `Math.<fn>(x)`: 1 f64 in, 1 f64 out (like UnaryOp)
934
+ Opcode::LoadLocal | Opcode::StoreLocal => {
935
+ used.insert(peek_u16(code, ip + 1)?);
936
+ }
937
+ Opcode::LoadConst => match chunk.constants.get(peek_u16(code, ip + 1)? as usize) {
938
+ Some(Constant::Number(_)) | Some(Constant::Bool(_)) => {}
939
+ _ => return None, // a String/Null/Closure const is not numeric slot math
940
+ },
941
+ Opcode::BinOp => {
942
+ // Reject non-numeric binops up front (Pow/In/logical) so the scan and the emit agree.
943
+ match peek_u16(code, ip + 1)
944
+ .map(|r| r as u8)
945
+ .and_then(u8_to_binop)?
946
+ {
947
+ BinOp::And | BinOp::Or | BinOp::Pow | BinOp::In => return None,
948
+ _ => {}
949
+ }
950
+ }
951
+ Opcode::Jump => {
952
+ let off = peek_u16(code, ip + 1)? as i16 as isize;
953
+ let t = ((ip + 3) as isize + off).max(0) as usize;
954
+ if t < header_ip || t >= region_end {
955
+ exit_targets.insert(t);
956
+ } else {
957
+ leaders.insert(t);
958
+ }
959
+ }
960
+ Opcode::JumpIfFalse => {
961
+ let off = peek_u16(code, ip + 1)? as i16 as isize;
962
+ let t = ((ip + 3) as isize + off).max(0) as usize;
963
+ if t < header_ip || t >= region_end {
964
+ exit_targets.insert(t);
965
+ } else {
966
+ leaders.insert(t);
967
+ }
968
+ leaders.insert(ip + 3); // fall-through
969
+ }
970
+ Opcode::JumpBack => {
971
+ let dist = peek_u16(code, ip + 1)? as usize;
972
+ let t = (ip + 3).checked_sub(dist)?;
973
+ if t < header_ip || t >= region_end {
974
+ return None; // back-edge leaving the region — not a single structured region
975
+ }
976
+ leaders.insert(t);
977
+ }
978
+ // Everything else (Call, SelfCall, LoadVar, GetIndex, member/object/array, Return, …)
979
+ // reads or writes a non-slot Value the f64 buffer can't carry → not OSR-eligible.
980
+ _ => return None,
981
+ }
982
+ ip += op_size(op)?;
983
+ }
984
+ // An exit-less region would compile to an uninterruptible native infinite loop — never OSR it.
985
+ if exit_targets.is_empty() {
986
+ return None;
987
+ }
988
+
989
+ // buffer position of each live slot (both the emitted loads/stores and the VM copy use this).
990
+ let used_slots: Vec<u16> = used.iter().copied().collect();
991
+ let buf_pos: HashMap<u16, usize> = used_slots
992
+ .iter()
993
+ .enumerate()
994
+ .map(|(p, &s)| (s, p))
995
+ .collect();
996
+ let exits: Vec<usize> = exit_targets.iter().copied().collect();
997
+ let exit_id: HashMap<usize, usize> = exits.iter().enumerate().map(|(i, &t)| (t, i)).collect();
998
+
999
+ // 2. Build the region function. Signature: (slots: i64 ptr, deopt: i64 ptr) -> i32 exit id.
1000
+ let ptr_ty = g.module.target_config().pointer_type();
1001
+ let mut sig = g.module.make_signature();
1002
+ sig.params.push(AbiParam::new(ptr_ty)); // slots buffer
1003
+ sig.params.push(AbiParam::new(ptr_ty)); // deopt flag (reserved)
1004
+ sig.returns.push(AbiParam::new(types::I32));
1005
+
1006
+ let name = format!("tish_osr_{}", g.counter);
1007
+ g.counter += 1;
1008
+ let id = g
1009
+ .module
1010
+ .declare_function(&name, Linkage::Export, &sig)
1011
+ .ok()?;
1012
+
1013
+ let mut ctx = g.module.make_context();
1014
+ ctx.func.signature = sig.clone();
1015
+ let math_fref = g.module.declare_func_in_func(g.math_call_id, &mut ctx.func);
1016
+ let mut fbctx = FunctionBuilderContext::new();
1017
+ let built = build_loop_region_body(
1018
+ &mut ctx.func,
1019
+ &mut fbctx,
1020
+ chunk,
1021
+ header_ip,
1022
+ region_end,
1023
+ &leaders,
1024
+ &used_slots,
1025
+ &buf_pos,
1026
+ &exit_id,
1027
+ math_fref,
1028
+ );
1029
+ if !built {
1030
+ g.module.clear_context(&mut ctx);
1031
+ return None;
1032
+ }
1033
+ if g.module.define_function(id, &mut ctx).is_err() {
1034
+ g.module.clear_context(&mut ctx);
1035
+ return None;
1036
+ }
1037
+ g.module.clear_context(&mut ctx);
1038
+ if g.module.finalize_definitions().is_err() {
1039
+ return None;
1040
+ }
1041
+ let fptr = g.module.get_finalized_function(id);
1042
+ Some(LoopFn {
1043
+ ptr: fptr as usize,
1044
+ used_slots,
1045
+ exits,
1046
+ })
1047
+ }
1048
+
1049
+ /// Emit the CLIF body of a loop region (#190). `true` on success; `false` on any shape the emitter
1050
+ /// can't lower (the caller then negative-caches the region). Structure mirrors [`build_body_cfg`]'s
1051
+ /// translate loop, but: slots load from / store to the `slots` pointer instead of register params,
1052
+ /// and a jump/branch to an EXIT target lands in an exit block that writes the live set back and
1053
+ /// returns the exit id. No self-call, no arrays, no `Return` (all rejected in the scan).
1054
+ #[allow(clippy::too_many_arguments)]
1055
+ fn build_loop_region_body(
1056
+ func: &mut cranelift::codegen::ir::Function,
1057
+ fbctx: &mut FunctionBuilderContext,
1058
+ chunk: &Chunk,
1059
+ header_ip: usize,
1060
+ region_end: usize,
1061
+ leaders: &std::collections::BTreeSet<usize>,
1062
+ used_slots: &[u16],
1063
+ buf_pos: &HashMap<u16, usize>,
1064
+ exit_id: &HashMap<usize, usize>,
1065
+ math_fref: cranelift::codegen::ir::FuncRef,
1066
+ ) -> bool {
1067
+ let code = &chunk.code;
1068
+ let num_slots = chunk.num_slots as usize;
1069
+ let mut bcx = FunctionBuilder::new(func, fbctx);
1070
+
1071
+ let blocks: HashMap<usize, Block> = leaders.iter().map(|&o| (o, bcx.create_block())).collect();
1072
+ let exit_blocks: HashMap<usize, Block> =
1073
+ exit_id.keys().map(|&t| (t, bcx.create_block())).collect();
1074
+
1075
+ // A jump target is either an in-region leader or a loop-exit target (the scan proved it is one).
1076
+ let target_block =
1077
+ |t: usize| -> Option<Block> { blocks.get(&t).or_else(|| exit_blocks.get(&t)).copied() };
1078
+
1079
+ // Entry: load the live-in slots from the buffer, then jump into the loop header.
1080
+ let entry = bcx.create_block();
1081
+ bcx.append_block_params_for_function_params(entry);
1082
+ bcx.switch_to_block(entry);
1083
+ let params: Vec<ClifValue> = bcx.block_params(entry).to_vec();
1084
+ let slots_ptr = params[0]; // params[1] = deopt flag, reserved (v1 never writes it)
1085
+ let vars: Vec<Variable> = (0..num_slots)
1086
+ .map(|_| bcx.declare_var(types::F64))
1087
+ .collect();
1088
+ for (slot, &var) in vars.iter().enumerate() {
1089
+ // Live slots load from their buffer position; slots the region never touches init to 0 (dead).
1090
+ let init = if let Some(&p) = buf_pos.get(&(slot as u16)) {
1091
+ bcx.ins()
1092
+ .load(types::F64, MemFlags::new(), slots_ptr, (p * 8) as i32)
1093
+ } else {
1094
+ bcx.ins().f64const(0.0)
1095
+ };
1096
+ bcx.def_var(var, init);
1097
+ }
1098
+ let header_block = match blocks.get(&header_ip) {
1099
+ Some(&b) => b,
1100
+ None => return false,
1101
+ };
1102
+ bcx.ins().jump(header_block, &[]);
1103
+
1104
+ // Translate the region. Operand stack is empty at every block boundary (statement-level flow).
1105
+ let mut stack: Vec<(ClifValue, bool)> = Vec::new();
1106
+ bcx.switch_to_block(header_block);
1107
+ let mut cur = header_block;
1108
+ let mut terminated = false;
1109
+ let mut ip = header_ip;
1110
+ while ip < region_end {
1111
+ if let Some(&blk) = blocks.get(&ip) {
1112
+ if blk != cur {
1113
+ if !terminated {
1114
+ if !stack.is_empty() {
1115
+ return false;
1116
+ }
1117
+ bcx.ins().jump(blk, &[]);
1118
+ }
1119
+ bcx.switch_to_block(blk);
1120
+ cur = blk;
1121
+ terminated = false;
1122
+ stack.clear();
1123
+ }
1124
+ }
1125
+ let op = match Opcode::from_u8(code[ip]).zip(op_size_at(code, ip)) {
1126
+ Some((o, _)) => o,
1127
+ None => return false,
1128
+ };
1129
+ if terminated {
1130
+ ip += match op_size(op) {
1131
+ Some(s) => s,
1132
+ None => return false,
1133
+ };
1134
+ continue;
1135
+ }
1136
+ match op {
1137
+ Opcode::LoadLocal => {
1138
+ let slot = match peek_u16(code, ip + 1) {
1139
+ Some(s) => s as usize,
1140
+ None => return false,
1141
+ };
1142
+ let v = match vars.get(slot) {
1143
+ Some(v) => *v,
1144
+ None => return false,
1145
+ };
1146
+ stack.push((bcx.use_var(v), false));
1147
+ ip += 3;
1148
+ }
1149
+ Opcode::StoreLocal => {
1150
+ let slot = match peek_u16(code, ip + 1) {
1151
+ Some(s) => s as usize,
1152
+ None => return false,
1153
+ };
1154
+ let (val, is_bool) = match stack.pop() {
1155
+ Some(x) => x,
1156
+ None => return false,
1157
+ };
1158
+ if is_bool {
1159
+ return false; // no boolean slots (keeps the number/bool distinction clean)
1160
+ }
1161
+ let v = match vars.get(slot) {
1162
+ Some(v) => *v,
1163
+ None => return false,
1164
+ };
1165
+ bcx.def_var(v, val);
1166
+ ip += 3;
1167
+ }
1168
+ Opcode::Pop => {
1169
+ if stack.pop().is_none() {
1170
+ return false;
1171
+ }
1172
+ ip += 1;
1173
+ }
1174
+ Opcode::Dup => {
1175
+ let top = match stack.last() {
1176
+ Some(x) => *x,
1177
+ None => return false,
1178
+ };
1179
+ stack.push(top);
1180
+ ip += 1;
1181
+ }
1182
+ Opcode::Nop | Opcode::EnterBlock | Opcode::ExitBlock | Opcode::LoopVarsEnd => ip += 1,
1183
+ Opcode::LoopVarsBegin => ip += 3,
1184
+ Opcode::Jump => {
1185
+ let off = match peek_u16(code, ip + 1) {
1186
+ Some(o) => o as i16 as isize,
1187
+ None => return false,
1188
+ };
1189
+ let t = ((ip + 3) as isize + off).max(0) as usize;
1190
+ let blk = match target_block(t) {
1191
+ Some(b) => b,
1192
+ None => return false,
1193
+ };
1194
+ if !stack.is_empty() {
1195
+ return false;
1196
+ }
1197
+ bcx.ins().jump(blk, &[]);
1198
+ terminated = true;
1199
+ ip += 3;
1200
+ }
1201
+ Opcode::JumpBack => {
1202
+ let dist = match peek_u16(code, ip + 1) {
1203
+ Some(d) => d as usize,
1204
+ None => return false,
1205
+ };
1206
+ let t = match (ip + 3).checked_sub(dist) {
1207
+ Some(t) => t,
1208
+ None => return false,
1209
+ };
1210
+ let blk = match blocks.get(&t) {
1211
+ Some(&b) => b,
1212
+ None => return false,
1213
+ };
1214
+ if !stack.is_empty() {
1215
+ return false;
1216
+ }
1217
+ bcx.ins().jump(blk, &[]);
1218
+ terminated = true;
1219
+ ip += 3;
1220
+ }
1221
+ Opcode::JumpIfFalse => {
1222
+ let off = match peek_u16(code, ip + 1) {
1223
+ Some(o) => o as i16 as isize,
1224
+ None => return false,
1225
+ };
1226
+ let (cond, _) = match stack.pop() {
1227
+ Some(x) => x,
1228
+ None => return false,
1229
+ };
1230
+ if !stack.is_empty() {
1231
+ return false;
1232
+ }
1233
+ let falsy = falsy_flag(&mut bcx, cond);
1234
+ let t = ((ip + 3) as isize + off).max(0) as usize;
1235
+ let target = match target_block(t) {
1236
+ Some(b) => b,
1237
+ None => return false,
1238
+ };
1239
+ let fallthrough = match blocks.get(&(ip + 3)) {
1240
+ Some(&b) => b,
1241
+ None => return false,
1242
+ };
1243
+ bcx.ins().brif(falsy, target, &[], fallthrough, &[]);
1244
+ terminated = true;
1245
+ ip += 3;
1246
+ }
1247
+ Opcode::MathUnary => {
1248
+ // #186 — `Math.<fn>(x)`: pop the arg, emit the native op / host call, push the result.
1249
+ let id = match peek_u16(code, ip + 1) {
1250
+ Some(v) => v,
1251
+ None => return false,
1252
+ };
1253
+ let mfn = match MathUnaryFn::from_u16(id) {
1254
+ Some(m) => m,
1255
+ None => return false,
1256
+ };
1257
+ let (x, _) = match stack.pop() {
1258
+ Some(v) => v,
1259
+ None => return false,
1260
+ };
1261
+ let r = emit_math_unary(&mut bcx, math_fref, mfn, x);
1262
+ stack.push((r, false));
1263
+ ip += 3;
1264
+ }
1265
+ _ => match emit_simple_op(&mut bcx, chunk, code, &mut ip, &mut stack, &[], 0) {
1266
+ SimpleOp::Handled(_) => {}
1267
+ _ => return false, // LoadConst/BinOp/UnaryOp handled; anything else → keep interpreting
1268
+ },
1269
+ }
1270
+ }
1271
+ if !terminated {
1272
+ return false; // the region must end on its back-edge (a terminator)
1273
+ }
1274
+
1275
+ // Exit blocks: flush the live set back through the slots pointer and return the exit id.
1276
+ for (&t, &blk) in &exit_blocks {
1277
+ bcx.switch_to_block(blk);
1278
+ for (p, &slot) in used_slots.iter().enumerate() {
1279
+ let v = bcx.use_var(vars[slot as usize]);
1280
+ bcx.ins()
1281
+ .store(MemFlags::new(), v, slots_ptr, (p * 8) as i32);
1282
+ }
1283
+ let id = bcx.ins().iconst(types::I32, exit_id[&t] as i64);
1284
+ bcx.ins().return_(&[id]);
1285
+ }
1286
+
1287
+ bcx.seal_all_blocks();
1288
+ bcx.finalize();
1289
+ true
1290
+ }
1291
+
1292
+ /// `op_size` of the opcode at `off`, or `None` if the byte is not a known opcode.
1293
+ fn op_size_at(code: &[u8], off: usize) -> Option<usize> {
1294
+ op_size(Opcode::from_u8(*code.get(off)?)?)
1295
+ }
1296
+
1297
+ /// Lower `Math.<mfn>(x)` (#186): a single native op for the ones bit-identical to Rust's `f64`
1298
+ /// methods (== the `tishlang_builtins::math` fns), else a call to the imported `tish_math_call` host
1299
+ /// fn (`math_fref`) which routes through [`MathUnaryFn::apply`] — so JIT ≡ VM ≡ interp exactly.
1300
+ fn emit_math_unary(
1301
+ bcx: &mut FunctionBuilder,
1302
+ math_fref: cranelift::codegen::ir::FuncRef,
1303
+ mfn: MathUnaryFn,
1304
+ x: ClifValue,
1305
+ ) -> ClifValue {
1306
+ match mfn {
1307
+ MathUnaryFn::Sqrt => bcx.ins().sqrt(x),
1308
+ MathUnaryFn::Floor => bcx.ins().floor(x),
1309
+ MathUnaryFn::Ceil => bcx.ins().ceil(x),
1310
+ MathUnaryFn::Trunc => bcx.ins().trunc(x),
1311
+ MathUnaryFn::Abs => bcx.ins().fabs(x),
1312
+ // `Round` (JS `-0` tie edge), `Sign`, and every transcendental go through the host call so
1313
+ // the result is byte-identical to the interpreter (no native-op divergence).
1314
+ _ => {
1315
+ let id = bcx.ins().iconst(types::I32, mfn as i64);
1316
+ let call = bcx.ins().call(math_fref, &[id, x]);
1317
+ bcx.inst_results(call)[0]
1318
+ }
1319
+ }
1320
+ }
1321
+
324
1322
  /// Lower `f64` comparison to `1.0`/`0.0` (JS boolean-in-number form).
325
1323
  fn fcmp_f64(bcx: &mut FunctionBuilder, cc: FloatCC, a: ClifValue, b: ClifValue) -> ClifValue {
326
1324
  let cond = bcx.ins().fcmp(cc, a, b);
@@ -343,6 +1341,50 @@ fn js_to_int32(bcx: &mut FunctionBuilder, x: ClifValue) -> ClifValue {
343
1341
  bcx.ins().select(finite, red, zero)
344
1342
  }
345
1343
 
1344
+ /// #187: import every registered directly-callable callee this chunk references (via `LoadVar name`)
1345
+ /// into `func`, returning `name → (FuncRef, arity)` for the Call-lowering peephole. Empty when the
1346
+ /// chunk calls no registered callee (the common case) — then `LoadVar`/`Call` still bail to interp.
1347
+ #[cfg(not(target_arch = "wasm32"))]
1348
+ fn build_resolved_callees(
1349
+ g: &mut JitGlobal,
1350
+ chunk: &Chunk,
1351
+ func: &mut cranelift::codegen::ir::Function,
1352
+ ) -> HashMap<Arc<str>, (cranelift::codegen::ir::FuncRef, u8)> {
1353
+ let mut resolved = HashMap::new();
1354
+ if g.callees.is_empty() {
1355
+ return resolved;
1356
+ }
1357
+ // Collect (name, id, arity) for referenced registered callees first, so the `g.callees` borrow is
1358
+ // released before the `g.module` mutable borrow in `declare_func_in_func`.
1359
+ let mut refs: Vec<(Arc<str>, cranelift_module::FuncId, u8)> = Vec::new();
1360
+ let code = &chunk.code;
1361
+ let mut ip = 0usize;
1362
+ while ip < code.len() {
1363
+ let op = match Opcode::from_u8(code[ip]) {
1364
+ Some(o) => o,
1365
+ None => break,
1366
+ };
1367
+ if op == Opcode::LoadVar {
1368
+ if let Some(name) = peek_u16(code, ip + 1).and_then(|ni| chunk.names.get(ni as usize)) {
1369
+ if let Some(entry) = g.callees.get(name) {
1370
+ if !refs.iter().any(|(n, _, _)| n == name) {
1371
+ refs.push((Arc::clone(name), entry.id, entry.arity));
1372
+ }
1373
+ }
1374
+ }
1375
+ }
1376
+ ip += match op.instruction_size(code, ip) {
1377
+ Some(s) => s,
1378
+ None => break,
1379
+ };
1380
+ }
1381
+ for (name, id, arity) in refs {
1382
+ let fref = g.module.declare_func_in_func(id, func);
1383
+ resolved.insert(name, (fref, arity));
1384
+ }
1385
+ resolved
1386
+ }
1387
+
346
1388
  fn compile_chunk(g: &mut JitGlobal, chunk: &Chunk) -> Option<NumericFn> {
347
1389
  let arity = chunk.param_count as usize;
348
1390
 
@@ -350,20 +1392,53 @@ fn compile_chunk(g: &mut JitGlobal, chunk: &Chunk) -> Option<NumericFn> {
350
1392
  // 3-pointer array ABI instead of the register-`f64` ABI. mask 0 ⇒ ordinary numeric path below.
351
1393
  #[cfg(not(target_arch = "wasm32"))]
352
1394
  {
353
- let array_mask = if jit_arrays_enabled() {
1395
+ let (array_mask, writable_mask, bool_write_mask) = if jit_arrays_enabled() {
354
1396
  classify_params(chunk, arity)
355
1397
  } else {
356
- 0
1398
+ (0, 0, 0)
357
1399
  };
358
1400
  if array_mask != 0 {
359
- return compile_chunk_arrays(g, chunk, arity, array_mask);
1401
+ return compile_chunk_arrays(
1402
+ g,
1403
+ chunk,
1404
+ arity,
1405
+ array_mask,
1406
+ writable_mask,
1407
+ bool_write_mask,
1408
+ );
360
1409
  }
361
1410
  }
362
1411
 
1412
+ // #381: a self-recursive numeric function recurses on the native stack (SelfCall → a native call).
1413
+ // Compile it with a trailing `*mut RecurGuard` param so its entry can bail before the stack
1414
+ // overflows; non-recursive functions keep the plain register-f64 ABI (no param, no overhead).
1415
+ // Gated by `TISH_JIT_RECUR_GUARD` (default ON) so trusted hot-recursion workloads can trade the
1416
+ // guard's per-call cost for raw speed — see [`jit_recur_guard_enabled`].
1417
+ let recur_guard = jit_recur_guard_enabled() && chunk_has_self_call(chunk);
1418
+
1419
+ // #189: function-local `f64` arrays. Mutually exclusive with `recur_guard` (both would claim the
1420
+ // trailing param slot); a self-recursive array function just isn't JV-compiled. `None` from the
1421
+ // classifier (an array the JIT can't handle) → empty set → the scan bails on `NewArray` → interpret.
1422
+ #[cfg(not(target_arch = "wasm32"))]
1423
+ let jv_slots = if !recur_guard && jit_jv_enabled() {
1424
+ classify_jv_slots(chunk).unwrap_or_default()
1425
+ } else {
1426
+ std::collections::HashSet::new()
1427
+ };
1428
+ #[cfg(target_arch = "wasm32")]
1429
+ let jv_slots: std::collections::HashSet<usize> = std::collections::HashSet::new();
1430
+ let is_jv = !jv_slots.is_empty();
1431
+
363
1432
  let mut sig = g.module.make_signature();
364
1433
  for _ in 0..arity {
365
1434
  sig.params.push(AbiParam::new(types::F64));
366
1435
  }
1436
+ if recur_guard {
1437
+ sig.params
1438
+ .push(AbiParam::new(g.module.target_config().pointer_type())); // *mut RecurGuard
1439
+ }
1440
+ // #189: a JV function keeps the plain register-`f64` ABI — its deopt flag lives in the per-thread
1441
+ // arena (set by `tish_jv_{get,set,deopt}`), not a trailing pointer param.
367
1442
  sig.returns.push(AbiParam::new(types::F64));
368
1443
 
369
1444
  // Declare the function FIRST so its own `FuncRef` is available while building the body — that is
@@ -381,10 +1456,45 @@ fn compile_chunk(g: &mut JitGlobal, chunk: &Chunk) -> Option<NumericFn> {
381
1456
  let mut ctx = g.module.make_context();
382
1457
  ctx.func.signature = sig.clone();
383
1458
  let self_ref = g.module.declare_func_in_func(id, &mut ctx.func);
1459
+ let math_fref = g.module.declare_func_in_func(g.math_call_id, &mut ctx.func);
1460
+ // #189: import the `tish_jv_*` FuncRefs into this function when it has local arrays.
1461
+ let jv_ctx = if is_jv {
1462
+ Some(JvCtx {
1463
+ new: g.module.declare_func_in_func(g.jv_fns.new, &mut ctx.func),
1464
+ push: g.module.declare_func_in_func(g.jv_fns.push, &mut ctx.func),
1465
+ get: g.module.declare_func_in_func(g.jv_fns.get, &mut ctx.func),
1466
+ set: g.module.declare_func_in_func(g.jv_fns.set, &mut ctx.func),
1467
+ len: g.module.declare_func_in_func(g.jv_fns.len, &mut ctx.func),
1468
+ free: g.module.declare_func_in_func(g.jv_fns.free, &mut ctx.func),
1469
+ deopt: g.module.declare_func_in_func(g.jv_fns.deopt, &mut ctx.func),
1470
+ slots: &jv_slots,
1471
+ })
1472
+ } else {
1473
+ None
1474
+ };
1475
+ // #187: import any directly-callable numeric callees this chunk references, so `name(args)` can
1476
+ // lower to a native call. Empty for the vast majority of functions.
1477
+ let resolved = build_resolved_callees(g, chunk, &mut ctx.func);
384
1478
  let mut fbctx = FunctionBuilderContext::new();
385
- let result_bool = match build_body_cfg(&mut ctx.func, &mut fbctx, chunk, arity, Some(self_ref), 0)
386
- {
1479
+ let result_bool = match build_body_cfg(
1480
+ &mut ctx.func,
1481
+ &mut fbctx,
1482
+ chunk,
1483
+ arity,
1484
+ Some(self_ref),
1485
+ 0,
1486
+ recur_guard,
1487
+ math_fref,
1488
+ jv_ctx.as_ref(),
1489
+ &resolved,
1490
+ ) {
387
1491
  Some(b) => b,
1492
+ // A JV function has no straight-line fallback (its ABI has the deopt param `build_body`
1493
+ // doesn't emit), so a `build_body_cfg` bail means "don't JIT" → interpret.
1494
+ None if is_jv => {
1495
+ g.module.clear_context(&mut ctx);
1496
+ return None;
1497
+ }
388
1498
  None => {
389
1499
  g.module.clear_context(&mut ctx);
390
1500
  ctx = g.module.make_context();
@@ -410,86 +1520,178 @@ fn compile_chunk(g: &mut JitGlobal, chunk: &Chunk) -> Option<NumericFn> {
410
1520
  return None;
411
1521
  }
412
1522
  let ptr = g.module.get_finalized_function(id);
1523
+ // #187: a plain register-`f64` top-level function (no arrays / recursion-guard / bool result) with a
1524
+ // provably-stable global name becomes a directly-callable callee. `id` is already finalized above,
1525
+ // so a later caller can `declare_func_in_func(id)` and call it. Re-registering a name (a different
1526
+ // program reusing the address) overwrites with the new `id`/`fp`; callers resolve against the live
1527
+ // registry (and cross-call callers skip the cache), so a stale callee is never invoked.
1528
+ if !is_jv && !recur_guard && !result_bool {
1529
+ if let Some(name) = &chunk.global_name {
1530
+ g.callees.insert(
1531
+ Arc::clone(name),
1532
+ CalleeEntry {
1533
+ id,
1534
+ arity: arity as u8,
1535
+ },
1536
+ );
1537
+ }
1538
+ }
413
1539
  Some(NumericFn {
414
1540
  ptr: ptr as usize,
415
1541
  arity: arity as u8,
416
1542
  result_bool,
417
1543
  array_param_mask: 0,
1544
+ array_writable_mask: 0,
1545
+ array_bool_write_mask: 0,
1546
+ recur_guarded: recur_guard,
1547
+ jv: is_jv,
1548
+ uses_xcall: !resolved.is_empty(),
1549
+ void_result: false, // register-f64 functions return a real f64
418
1550
  })
419
1551
  }
420
1552
 
421
- /// Classify each param slot of an array-mode candidate. Returns a bitmask: **bit k set ⇒ param k is
422
- /// an ARRAY** used only as `arr[i]` / `arr[const]`. Returns `0` when there are no array params OR the
423
- /// function is ineligible for array mode (a param used both as an array and as a number, a `GetIndex`
424
- /// the peephole can't consume, etc.) the caller then takes the ordinary numeric path, which itself
425
- /// bails on `GetIndex`, so a `0` here is always safe (never a miscompile, just no array-JIT).
1553
+ /// Classify each param slot of an array-mode candidate. Returns `(array_mask, writable_mask,
1554
+ /// bool_write_mask)`: **array bit k ⇒ param k is an ARRAY** used only as `arr[i]` (read, `GetIndex`) or
1555
+ /// `arr[i] = v` (write, `SetIndex`); **writable bit k that array is written**; **bool_write bit k a
1556
+ /// writable array written with bool consts (`arr[i]=true`)**. Returns `(0, 0, 0)` when there are no
1557
+ /// array params OR the function is ineligible (a param used as both an array and a number, a mix of bool
1558
+ /// and non-bool writes to one array, an index shape the peephole can't consume, etc.). A `0` mask is
1559
+ /// always safe: the caller takes the ordinary
1560
+ /// numeric path (which itself bails on `GetIndex`/`SetIndex`), and `try_call_array_jit` re-checks every
1561
+ /// arg's runtime type, so a misclassification bails to the interpreter rather than miscompiling.
426
1562
  #[cfg(not(target_arch = "wasm32"))]
427
- fn classify_params(chunk: &Chunk, arity: usize) -> u8 {
1563
+ fn classify_params(chunk: &Chunk, arity: usize) -> (u8, u8, u8) {
428
1564
  if arity == 0 || arity > 8 || (chunk.num_slots as usize) == 0 {
429
- return 0;
1565
+ return (0, 0, 0);
430
1566
  }
431
1567
  let code = &chunk.code;
432
- let mut used_numeric = [false; 8];
1568
+ // #187: which slots hold a Bool value (`arr[i] = boolLocal` must be classified a BOOL write, not a
1569
+ // number write, so a bool local stored into a NUMBER array is caught by the runtime type guard).
1570
+ // Computed unconditionally — a superset only makes the guard bail more (always sound), never miscompile.
1571
+ let bool_slots = classify_bool_slots(chunk);
1572
+ let mut stored = [false; 8]; // param REASSIGNED via StoreLocal (an array param can't be — bail)
433
1573
  let mut used_array = [false; 8];
1574
+ let mut written = [false; 8];
1575
+ // #187: per writable array param, was it written with a Bool const (`arr[i]=true`) and/or a
1576
+ // non-bool value (`arr[i]=5`)? The JIT flattens every element to f64, so the writeback must re-box
1577
+ // by the ORIGINAL element type; that is only sound when the whole array stays one type. A mix of
1578
+ // bool and non-bool writes to the SAME array can never be re-boxed uniformly ⇒ bail at compile time.
1579
+ // `wrote_bool` also feeds the runtime guard (write-kind must match the entry array's element type).
1580
+ let mut wrote_bool = [false; 8];
1581
+ let mut wrote_nonbool = [false; 8];
434
1582
  let mut ip = 0usize;
1583
+ let simple = |o: Option<Opcode>| matches!(o, Some(Opcode::LoadLocal) | Some(Opcode::LoadConst));
435
1584
  while ip < code.len() {
436
1585
  let op = match Opcode::from_u8(code[ip]) {
437
1586
  Some(o) => o,
438
- None => return 0,
1587
+ None => return (0, 0, 0),
439
1588
  };
440
1589
  let size = match op_size(op) {
441
1590
  Some(s) => s,
442
- None => return 0, // an opcode the array CFG can't handle ineligible
1591
+ // #187: a bare `LoadVar name` / `Call argc` (a global function call whose result is numeric)
1592
+ // doesn't touch param classification — treat as a sized no-op. `build_body_cfg` is the gate:
1593
+ // it lowers the call only if the callee resolves, else the whole function bails to interp.
1594
+ None if matches!(op, Opcode::LoadVar | Opcode::Call) => 3,
1595
+ None => return (0, 0, 0), // an opcode the array CFG can't handle ⇒ ineligible
443
1596
  };
444
1597
  match op {
445
1598
  Opcode::LoadLocal => {
446
1599
  let slot = match peek_u16(code, ip + 1) {
447
1600
  Some(s) => s as usize,
448
- None => return 0,
1601
+ None => return (0, 0, 0),
449
1602
  };
450
- // Peephole: `LoadLocal(arr) ; (LoadLocal|LoadConst) ; GetIndex` ⇒ array access of arr.
451
- let idx_then_getindex = matches!(
452
- (
453
- code.get(ip + 3).copied().and_then(Opcode::from_u8),
454
- code.get(ip + 6).copied().and_then(Opcode::from_u8),
455
- ),
456
- (Some(Opcode::LoadLocal), Some(Opcode::GetIndex))
457
- | (Some(Opcode::LoadConst), Some(Opcode::GetIndex))
458
- );
459
- if idx_then_getindex && slot < arity {
1603
+ let at = |off: usize| code.get(ip + off).copied().and_then(Opcode::from_u8);
1604
+ // Read peephole: `LoadLocal(arr) ; (LoadLocal|LoadConst) idx ; GetIndex`.
1605
+ if slot < arity && simple(at(3)) && at(6) == Some(Opcode::GetIndex) {
1606
+ used_array[slot] = true;
1607
+ ip += 7;
1608
+ continue;
1609
+ }
1610
+ // Write peephole: `LoadLocal(arr) ; (LoadLocal|LoadConst) idx ; (LoadLocal|LoadConst)
1611
+ // val ; Dup ; SetIndex` — a plain `arr[i] = v` with a simple index and value.
1612
+ if slot < arity
1613
+ && simple(at(3))
1614
+ && simple(at(6))
1615
+ && at(9) == Some(Opcode::Dup)
1616
+ && at(10) == Some(Opcode::SetIndex)
1617
+ {
460
1618
  used_array[slot] = true;
461
- ip += 7; // consume LoadLocal(arr) + index op + GetIndex
1619
+ written[slot] = true;
1620
+ // Classify the written VALUE (operand at offset 6) so the runtime writeback re-boxes
1621
+ // by the right element type. Only a `LoadConst(Bool)` is a *definite* bool write.
1622
+ // A `LoadLocal(slot)` is trickier: `classify_bool_slots` is a SUPERSET (insert-only —
1623
+ // it never un-tags a slot that is later reassigned a number, and `build_body_cfg`
1624
+ // permits `b = n` into a bool-tagged slot), so a bool-tagged slot may actually hold a
1625
+ // NUMBER at the write site. We can't tell the runtime kind statically, and the flat
1626
+ // `f64` writeback would re-box it wrong — so bail the whole function. (Numeric-slot
1627
+ // writes — e.g. spectral_norm's `out[i] = sum` — are fine: not bool-tagged, non-bool.)
1628
+ let val_is_bool = match at(6) {
1629
+ Some(Opcode::LoadConst) => matches!(
1630
+ peek_u16(code, ip + 7).and_then(|ci| chunk.constants.get(ci as usize)),
1631
+ Some(Constant::Bool(_))
1632
+ ),
1633
+ Some(Opcode::LoadLocal)
1634
+ if peek_u16(code, ip + 7)
1635
+ .is_some_and(|s| bool_slots.contains(&(s as usize))) =>
1636
+ {
1637
+ return (0, 0, 0);
1638
+ }
1639
+ _ => false,
1640
+ };
1641
+ if val_is_bool {
1642
+ wrote_bool[slot] = true;
1643
+ } else {
1644
+ wrote_nonbool[slot] = true;
1645
+ }
1646
+ ip += 11;
462
1647
  continue;
463
- } else if slot < arity {
464
- used_numeric[slot] = true;
465
1648
  }
1649
+ // Otherwise a bare `LoadLocal(param)` — either a numeric value (fine; a param is an
1650
+ // array iff INDEXED) or a self-recursive array-call argument (build_body_cfg validates +
1651
+ // consumes it, else bails). Neither affects classification.
466
1652
  }
467
1653
  Opcode::StoreLocal => {
468
1654
  let slot = match peek_u16(code, ip + 1) {
469
1655
  Some(s) => s as usize,
470
- None => return 0,
1656
+ None => return (0, 0, 0),
471
1657
  };
472
1658
  if slot < arity {
473
- used_numeric[slot] = true; // a written param is numeric-shaped (can't be our array)
1659
+ stored[slot] = true; // reassigning the binding an array param can't be (see tail)
474
1660
  }
475
1661
  }
476
- // Any `GetIndex` not already consumed by the peephole above ⇒ an index shape we don't
477
- // handle (e.g. `arr[i+1]`, `arr[brr[i]]`) ⇒ ineligible.
478
- Opcode::GetIndex => return 0,
1662
+ // Any `GetIndex`/`SetIndex` not consumed by a peephole above ⇒ an index/value shape we don't
1663
+ // handle (`arr[i+1]`, `arr[i] = a + b`, `arr[brr[i]]`, …) ⇒ ineligible.
1664
+ Opcode::GetIndex | Opcode::SetIndex => return (0, 0, 0),
479
1665
  _ => {}
480
1666
  }
481
1667
  ip += size;
482
1668
  }
483
- let mut mask = 0u8;
1669
+ let mut array_mask = 0u8;
1670
+ let mut writable_mask = 0u8;
1671
+ let mut bool_write_mask = 0u8;
484
1672
  for k in 0..arity {
485
1673
  if used_array[k] {
486
- if used_numeric[k] {
487
- return 0; // used as BOTH array and number ambiguous bail
1674
+ // A REASSIGNED array param (`arr = …`) would break the handle-reuse assumption (esp. the
1675
+ // self-call reusing `handles_ptr`), so bail the whole function. A `arr[i] = v` element write
1676
+ // is `written`, not `stored`, and stays fine.
1677
+ if stored[k] {
1678
+ return (0, 0, 0);
1679
+ }
1680
+ // A single array written with BOTH bool and non-bool values can never be re-boxed uniformly
1681
+ // (the JIT has already flattened everything to f64) ⇒ bail rather than miscompile.
1682
+ if wrote_bool[k] && wrote_nonbool[k] {
1683
+ return (0, 0, 0);
1684
+ }
1685
+ array_mask |= 1u8 << k;
1686
+ if written[k] {
1687
+ writable_mask |= 1u8 << k;
1688
+ if wrote_bool[k] {
1689
+ bool_write_mask |= 1u8 << k;
1690
+ }
488
1691
  }
489
- mask |= 1u8 << k;
490
1692
  }
491
1693
  }
492
- mask
1694
+ (array_mask, writable_mask, bool_write_mask)
493
1695
  }
494
1696
 
495
1697
  /// Compile an array-mode function: numeric params + array params (read as `arr[i]`). Uses ONE uniform
@@ -498,23 +1700,60 @@ fn classify_params(chunk: &Chunk, arity: usize) -> u8 {
498
1700
  /// reads set `*deopt` and bail (the caller re-runs the interpreter); non-numeric arrays never reach
499
1701
  /// here (the VM wrapper only calls this when every element is a `Value::Number`).
500
1702
  #[cfg(not(target_arch = "wasm32"))]
501
- fn compile_chunk_arrays(g: &mut JitGlobal, chunk: &Chunk, arity: usize, mask: u8) -> Option<NumericFn> {
1703
+ fn compile_chunk_arrays(
1704
+ g: &mut JitGlobal,
1705
+ chunk: &Chunk,
1706
+ arity: usize,
1707
+ mask: u8,
1708
+ writable_mask: u8,
1709
+ bool_write_mask: u8,
1710
+ ) -> Option<NumericFn> {
1711
+ // #187: a self-recursive array-mode function (queens' `place`) recurses on the native stack, so —
1712
+ // like #381's register-f64 recursion — it gets a trailing `*mut RecurGuard` param + an entry
1713
+ // SP-bail, keeping unbounded recursion a catchable RangeError instead of a SIGSEGV.
1714
+ let recursive = chunk_has_self_call(chunk) && jit_recur_guard_enabled();
502
1715
  let ptr_ty = g.module.target_config().pointer_type();
503
1716
  let mut sig = g.module.make_signature();
504
1717
  sig.params.push(AbiParam::new(ptr_ty)); // numeric_ptr
505
1718
  sig.params.push(AbiParam::new(ptr_ty)); // handles_ptr
506
1719
  sig.params.push(AbiParam::new(ptr_ty)); // deopt_ptr
1720
+ if recursive {
1721
+ sig.params.push(AbiParam::new(ptr_ty)); // *mut RecurGuard (#187/#381)
1722
+ }
507
1723
  sig.returns.push(AbiParam::new(types::F64));
508
1724
 
509
1725
  let name = format!("tish_arr_{}", g.counter);
510
1726
  g.counter += 1;
511
- let id = g.module.declare_function(&name, Linkage::Export, &sig).ok()?;
1727
+ let id = g
1728
+ .module
1729
+ .declare_function(&name, Linkage::Export, &sig)
1730
+ .ok()?;
512
1731
 
513
1732
  let mut ctx = g.module.make_context();
514
1733
  ctx.func.signature = sig.clone();
1734
+ // #187: declare this function's own FuncRef so a self-recursive array-mode call (queens' `place`
1735
+ // passing `cols`/`diag1`/`diag2` back to itself) lowers to a native call reusing the same
1736
+ // `handles_ptr`/`deopt_ptr` — only the numeric args are re-marshalled per level.
1737
+ let self_ref = g.module.declare_func_in_func(id, &mut ctx.func);
1738
+ let math_fref = g.module.declare_func_in_func(g.math_call_id, &mut ctx.func);
1739
+ // #187: array-mode functions (e.g. spectral_norm's multiplyAv) may call a register-f64 callee.
1740
+ let resolved = build_resolved_callees(g, chunk, &mut ctx.func);
515
1741
  let mut fbctx = FunctionBuilderContext::new();
516
- // No self-call in array mode (recursive call would need the array signature) → pass None.
517
- if build_body_cfg(&mut ctx.func, &mut fbctx, chunk, arity, None, mask).is_none() {
1742
+ // No JV local arrays in array-param mode pass None for the JV context.
1743
+ if build_body_cfg(
1744
+ &mut ctx.func,
1745
+ &mut fbctx,
1746
+ chunk,
1747
+ arity,
1748
+ Some(self_ref),
1749
+ mask,
1750
+ recursive, // #187: array-mode recursion guard (the entry SP-bail keys off this)
1751
+ math_fref,
1752
+ None,
1753
+ &resolved,
1754
+ )
1755
+ .is_none()
1756
+ {
518
1757
  g.module.clear_context(&mut ctx);
519
1758
  return None;
520
1759
  }
@@ -532,13 +1771,20 @@ fn compile_chunk_arrays(g: &mut JitGlobal, chunk: &Chunk, arity: usize, mask: u8
532
1771
  arity: arity as u8,
533
1772
  result_bool: false,
534
1773
  array_param_mask: mask,
1774
+ array_writable_mask: writable_mask,
1775
+ array_bool_write_mask: bool_write_mask,
1776
+ recur_guarded: recursive, // #187: array-mode fn has a trailing RecurGuard param → call_arrays passes it
1777
+ jv: false,
1778
+ uses_xcall: !resolved.is_empty(),
1779
+ void_result: chunk_is_void(chunk),
535
1780
  })
536
1781
  }
537
1782
 
538
1783
  /// Outcome of trying to emit one *straight-line* numeric opcode.
539
1784
  enum SimpleOp {
540
1785
  /// Handled: bytecode consumed, IR emitted, `bool` flags a comparison/`!` result.
541
- #[allow(dead_code)] // reserved: the flag will carry a comparison/`!` result; currently always false
1786
+ #[allow(dead_code)]
1787
+ // reserved: the flag will carry a comparison/`!` result; currently always false
542
1788
  Handled(bool),
543
1789
  /// The opcode is control flow / `Return` / non-numeric — NOT consumed; caller decides.
544
1790
  NotSimple,
@@ -606,12 +1852,29 @@ fn emit_simple_op(
606
1852
  if stack.len() < 2 {
607
1853
  return SimpleOp::Unsupported;
608
1854
  }
609
- let (r, _) = stack.pop().unwrap();
610
- let (l, _) = stack.pop().unwrap();
611
- let is_cmp = matches!(
1855
+ let (r, r_bool) = stack.pop().unwrap();
1856
+ let (l, l_bool) = stack.pop().unwrap();
1857
+ // #187: a bool value (from a bool slot or a `LoadConst Bool`) can't take part in an
1858
+ // EQUALITY compare here — the JIT compares the `f64` 0/1 bits, but JS `===`/`!==` (and
1859
+ // strict `==`/`!=`) treat `0 === false` as FALSE across types. Bail so the interpreter
1860
+ // decides. Relational (`<`/`>`/…) coerces bool→0/1 in both, so those stay JIT'd.
1861
+ let is_eq = matches!(
612
1862
  bop,
613
1863
  BinOp::Eq | BinOp::Ne | BinOp::StrictEq | BinOp::StrictNe
614
- | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge
1864
+ );
1865
+ if is_eq && (l_bool || r_bool) {
1866
+ return SimpleOp::Unsupported;
1867
+ }
1868
+ let is_cmp = matches!(
1869
+ bop,
1870
+ BinOp::Eq
1871
+ | BinOp::Ne
1872
+ | BinOp::StrictEq
1873
+ | BinOp::StrictNe
1874
+ | BinOp::Lt
1875
+ | BinOp::Le
1876
+ | BinOp::Gt
1877
+ | BinOp::Ge
615
1878
  );
616
1879
  let v = match bop {
617
1880
  BinOp::Add => bcx.ins().fadd(l, r),
@@ -729,13 +1992,183 @@ fn falsy_flag(bcx: &mut FunctionBuilder, cond: ClifValue) -> ClifValue {
729
1992
  fn op_size(op: Opcode) -> Option<usize> {
730
1993
  use Opcode::*;
731
1994
  Some(match op {
732
- Nop | Pop | Dup | Return | LoopVarsEnd | EnterBlock | ExitBlock | GetIndex => 1,
1995
+ Nop | Pop | Dup | Return | LoopVarsEnd | EnterBlock | ExitBlock | GetIndex | SetIndex => 1,
733
1996
  LoadLocal | StoreLocal | LoadConst | BinOp | UnaryOp | Jump | JumpIfFalse | JumpBack
734
- | LoopVarsBegin | SelfCall => 3,
1997
+ | LoopVarsBegin | SelfCall | MathUnary => 3,
735
1998
  _ => return None,
736
1999
  })
737
2000
  }
738
2001
 
2002
+ /// Cheap pre-scan: does this chunk contain a `SelfCall`? Decided in `compile_chunk` (before the sig is
2003
+ /// built) so a self-recursive function gets the `RecurGuard` param. Walks by `op_size`; if a size is
2004
+ /// unknown it returns `false`, which is safe: `build_body_cfg` uses the same `op_size` and would itself
2005
+ /// bail on that opcode, so the function isn't JIT'd (it runs on the guarded VM instead). #381
2006
+ fn chunk_has_self_call(chunk: &Chunk) -> bool {
2007
+ let code = &chunk.code;
2008
+ let mut ip = 0;
2009
+ while ip < code.len() {
2010
+ let Some(op) = Opcode::from_u8(code[ip]) else {
2011
+ return false;
2012
+ };
2013
+ if op == Opcode::SelfCall {
2014
+ return true;
2015
+ }
2016
+ let Some(size) = op_size(op) else {
2017
+ return false;
2018
+ };
2019
+ ip += size;
2020
+ }
2021
+ false
2022
+ }
2023
+
2024
+ /// #189 — identify function-LOCAL slots that hold non-escaping `f64` arrays (JV slots). A slot
2025
+ /// qualifies iff its ONLY definition is an empty array literal (`NewArray 0` immediately followed by
2026
+ /// `StoreLocal slot`) and it is never stored from anything else (which would alias/escape it). Any
2027
+ /// `NewArray` with a non-empty literal, or one not immediately stored to a slot, bails the WHOLE
2028
+ /// function (`None`) — such an array can't be JV-compiled and the function has array opcodes the JIT
2029
+ /// can't handle anyway. `Some(empty)` = "no local arrays" (the normal numeric path).
2030
+ ///
2031
+ /// This is a cheap pass over STORES only; the [`build_body_cfg`] emission validates USES by bailing
2032
+ /// #187: slots that ever receive a BOOLEAN value, so the numeric JIT can represent them as `f64`
2033
+ /// `0.0`/`1.0` instead of bailing at `StoreLocal`. Purely syntactic: a slot is tagged when a
2034
+ /// `StoreLocal(slot)` is immediately preceded by an op that pushes a bool — `LoadConst(Bool)`, a
2035
+ /// comparison `BinOp` (`==`/`!=`/`===`/`!==`/`<`/`<=`/`>`/`>=`), or `UnaryOp !`. Over-tagging is safe:
2036
+ /// a tagged slot only makes its `LoadLocal`s carry `is_bool`, which at worst forces a diverging
2037
+ /// `bool === number` compare or a `return bool` to bail to the interpreter (never a miscompile). A
2038
+ /// bytecode decode failure stops the scan early (→ fewer tags → more conservative), never a panic.
2039
+ /// #187: is this a VOID function — one that only ever `return`s the implicit `null` (a side-effect
2040
+ /// function like `multiplyAv`, which writes an array param and falls through)? True iff EVERY `Return`
2041
+ /// is immediately preceded by `LoadConst Null`. The array-mode JIT can then emit a dummy `f64` result
2042
+ /// for such a function and its wrapper returns `Value::Null` (matching the interpreter). Conservative:
2043
+ /// a decode failure or any value-returning path makes it `false`.
2044
+ #[cfg(not(target_arch = "wasm32"))]
2045
+ fn chunk_is_void(chunk: &Chunk) -> bool {
2046
+ let code = &chunk.code;
2047
+ let mut ip = 0usize;
2048
+ let mut prev_null = false;
2049
+ let mut saw_return = false;
2050
+ while ip < code.len() {
2051
+ let op = match Opcode::from_u8(code[ip]) {
2052
+ Some(o) => o,
2053
+ None => return false,
2054
+ };
2055
+ if op == Opcode::Return {
2056
+ saw_return = true;
2057
+ if !prev_null {
2058
+ return false; // a value-returning path ⇒ not void
2059
+ }
2060
+ }
2061
+ prev_null = op == Opcode::LoadConst
2062
+ && matches!(
2063
+ peek_u16(code, ip + 1).and_then(|i| chunk.constants.get(i as usize)),
2064
+ Some(Constant::Null)
2065
+ );
2066
+ ip += match op.instruction_size(code, ip) {
2067
+ Some(s) => s,
2068
+ None => return false,
2069
+ };
2070
+ }
2071
+ saw_return
2072
+ }
2073
+
2074
+ fn classify_bool_slots(chunk: &Chunk) -> std::collections::HashSet<usize> {
2075
+ let code = &chunk.code;
2076
+ let mut set: std::collections::HashSet<usize> = std::collections::HashSet::new();
2077
+ let mut ip = 0usize;
2078
+ let mut prev_pushes_bool = false;
2079
+ while ip < code.len() {
2080
+ let op = match Opcode::from_u8(code[ip]) {
2081
+ Some(o) => o,
2082
+ None => break,
2083
+ };
2084
+ let size = match op.instruction_size(code, ip) {
2085
+ Some(s) => s,
2086
+ None => break,
2087
+ };
2088
+ if op == Opcode::StoreLocal && prev_pushes_bool {
2089
+ if let Some(s) = peek_u16(code, ip + 1) {
2090
+ set.insert(s as usize);
2091
+ }
2092
+ }
2093
+ prev_pushes_bool = match op {
2094
+ Opcode::LoadConst => matches!(
2095
+ peek_u16(code, ip + 1).and_then(|i| chunk.constants.get(i as usize)),
2096
+ Some(Constant::Bool(_))
2097
+ ),
2098
+ Opcode::BinOp => matches!(
2099
+ peek_u16(code, ip + 1)
2100
+ .map(|r| r as u8)
2101
+ .and_then(u8_to_binop),
2102
+ Some(
2103
+ BinOp::Eq
2104
+ | BinOp::Ne
2105
+ | BinOp::StrictEq
2106
+ | BinOp::StrictNe
2107
+ | BinOp::Lt
2108
+ | BinOp::Le
2109
+ | BinOp::Gt
2110
+ | BinOp::Ge
2111
+ )
2112
+ ),
2113
+ Opcode::UnaryOp => matches!(
2114
+ peek_u16(code, ip + 1)
2115
+ .map(|r| r as u8)
2116
+ .and_then(u8_to_unaryop),
2117
+ Some(UnaryOp::Not)
2118
+ ),
2119
+ _ => false,
2120
+ };
2121
+ ip += size;
2122
+ }
2123
+ set
2124
+ }
2125
+
2126
+ /// on any misuse of a JV ref (it reaching a numeric op, a return, a call arg, a block boundary, …),
2127
+ /// so a mis-shaped use never miscompiles — it just falls back to the interpreter.
2128
+ fn classify_jv_slots(chunk: &Chunk) -> Option<std::collections::HashSet<usize>> {
2129
+ let code = &chunk.code;
2130
+ let mut from_newarray: std::collections::HashSet<usize> = std::collections::HashSet::new();
2131
+ let mut from_other: std::collections::HashSet<usize> = std::collections::HashSet::new();
2132
+ let mut has_newarray = false;
2133
+ let mut ip = 0usize;
2134
+ let mut prev_newarray0 = false;
2135
+ while ip < code.len() {
2136
+ let op = Opcode::from_u8(*code.get(ip)?)?;
2137
+ let size = op.instruction_size(code, ip)?;
2138
+ let is_newarray0 = op == Opcode::NewArray && peek_u16(code, ip + 1)? == 0;
2139
+ if op == Opcode::NewArray {
2140
+ has_newarray = true;
2141
+ if peek_u16(code, ip + 1)? != 0 {
2142
+ return None; // non-empty array literal → not JV-compilable
2143
+ }
2144
+ // An empty `[]` must be stored straight into a slot (`let a = []`); anything else means
2145
+ // the fresh array is used inline / escapes → bail.
2146
+ if Opcode::from_u8(*code.get(ip + size)?)? != Opcode::StoreLocal {
2147
+ return None;
2148
+ }
2149
+ }
2150
+ if op == Opcode::StoreLocal {
2151
+ let slot = peek_u16(code, ip + 1)? as usize;
2152
+ if prev_newarray0 {
2153
+ from_newarray.insert(slot);
2154
+ } else {
2155
+ from_other.insert(slot);
2156
+ }
2157
+ }
2158
+ prev_newarray0 = is_newarray0;
2159
+ ip += size;
2160
+ }
2161
+ if !has_newarray {
2162
+ return Some(std::collections::HashSet::new());
2163
+ }
2164
+ // A NewArray-defined slot that is ALSO stored otherwise is aliased/polymorphic — bail the whole
2165
+ // function (its array can't be JV-compiled, and it has `NewArray` the JIT otherwise rejects).
2166
+ if from_newarray.iter().any(|s| from_other.contains(s)) {
2167
+ return None;
2168
+ }
2169
+ Some(from_newarray)
2170
+ }
2171
+
739
2172
  /// Read a big-endian u16 operand at `off` without advancing (matches [`read_u16`]).
740
2173
  #[inline]
741
2174
  fn peek_u16(code: &[u8], off: usize) -> Option<u16> {
@@ -744,6 +2177,35 @@ fn peek_u16(code: &[u8], off: usize) -> Option<u16> {
744
2177
  Some((a << 8) | b)
745
2178
  }
746
2179
 
2180
+ /// Read a "simple" operand at byte `at` — a `LoadLocal(slot)` (→ the slot's f64 Variable) or a
2181
+ /// `LoadConst(Number)` (→ an f64 const) — for the array read/write peepholes. `None` for any other
2182
+ /// opcode / a non-numeric const, which makes the caller bail. #187
2183
+ #[cfg(not(target_arch = "wasm32"))]
2184
+ fn read_simple_operand(
2185
+ bcx: &mut FunctionBuilder,
2186
+ code: &[u8],
2187
+ at: usize,
2188
+ chunk: &Chunk,
2189
+ vars: &[Variable],
2190
+ ) -> Option<ClifValue> {
2191
+ match Opcode::from_u8(*code.get(at)?)? {
2192
+ Opcode::LoadLocal => {
2193
+ let s = peek_u16(code, at + 1)? as usize;
2194
+ Some(bcx.use_var(*vars.get(s)?))
2195
+ }
2196
+ Opcode::LoadConst => {
2197
+ let ci = peek_u16(code, at + 1)? as usize;
2198
+ match chunk.constants.get(ci) {
2199
+ Some(Constant::Number(n)) => Some(bcx.ins().f64const(*n)),
2200
+ // #187: a boolean stored into a (bool) array element — `arr[i] = true` — as `f64` 0/1.
2201
+ Some(Constant::Bool(b)) => Some(bcx.ins().f64const(if *b { 1.0 } else { 0.0 })),
2202
+ _ => None,
2203
+ }
2204
+ }
2205
+ _ => None,
2206
+ }
2207
+ }
2208
+
747
2209
  /// Control-flow JIT: lower a slot-based numeric function WITH loops/branches to native code. Uses a
748
2210
  /// cranelift `Variable` per slot (loop-carried locals are mutable across blocks; cranelift inserts the
749
2211
  /// SSA phis at `seal_all_blocks`), and one block per bytecode jump target. Handles LoadLocal/StoreLocal,
@@ -754,6 +2216,7 @@ fn peek_u16(code: &[u8], off: usize) -> Option<u16> {
754
2216
  /// (→ caller → VM) on any other opcode, a non-empty operand stack at a block boundary, boolean slots,
755
2217
  /// or a `Call` whose callee is not in `callees`. ADDITIVE: a bail just runs the VM.
756
2218
  /// Returns `Some(false)` (Number result) on success.
2219
+ #[allow(clippy::too_many_arguments)] // each param is a distinct, documented lowering mode/handle
757
2220
  fn build_body_cfg(
758
2221
  func: &mut cranelift::codegen::ir::Function,
759
2222
  fbctx: &mut FunctionBuilderContext,
@@ -764,12 +2227,35 @@ fn build_body_cfg(
764
2227
  // existing caller passes 0, so that path is byte-identical). Nonzero ⇒ the 3-pointer array ABI:
765
2228
  // params are `[numeric_ptr, handles_ptr, deopt_ptr]` and `arr[i]` lowers to a bounds-checked load.
766
2229
  array_mask: u8,
2230
+ // #381: when true (self-recursive, plain-numeric path only) the signature has a trailing
2231
+ // `*mut RecurGuard` param, and the function entry emits a stack-pointer check that bails before
2232
+ // the native recursion overflows. `false` for array mode and every non-recursive function.
2233
+ recur_guard: bool,
2234
+ // #186: the imported `tish_math_call` host fn, for lowering `Math.<fn>` (`MathUnary`) intrinsics.
2235
+ math_fref: cranelift::codegen::ir::FuncRef,
2236
+ // #189: `Some` when the function has local `f64` arrays — carries the `tish_jv_*` `FuncRef`s + the
2237
+ // JV slot set. Those slots become `i64` handle Variables and their array ops lower to `tish_jv_*`
2238
+ // calls; an out-of-bounds index sets the per-thread deopt flag the wrapper re-interprets on.
2239
+ jv: Option<&JvCtx>,
2240
+ // #187: `name → (FuncRef, arity)` for directly-callable numeric callees imported into `func`. A
2241
+ // `LoadVar name ; args ; Call` whose name is here lowers to a native call; unresolved names bail.
2242
+ resolved: &HashMap<Arc<str>, (cranelift::codegen::ir::FuncRef, u8)>,
767
2243
  ) -> Option<bool> {
768
2244
  let code = &chunk.code;
769
2245
  let num_slots = chunk.num_slots as usize;
770
2246
  if num_slots == 0 || num_slots > 256 {
771
2247
  return None;
772
2248
  }
2249
+ // #187: slots that hold a boolean (represented as `f64` 0/1). A `LoadLocal` of one carries
2250
+ // `is_bool` so a diverging `bool === number` compare or a `return bool` bails to the interpreter.
2251
+ let bool_slots = if jit_bool_slots_enabled() {
2252
+ classify_bool_slots(chunk)
2253
+ } else {
2254
+ std::collections::HashSet::new()
2255
+ };
2256
+ // #187: a VOID array-mode function (only returns the implicit `null`, e.g. a side-effect writer)
2257
+ // gets a dummy `f64` result; `try_call_array_jit` returns `Value::Null` for it (matches interp).
2258
+ let is_void = array_mask != 0 && chunk_is_void(chunk);
773
2259
 
774
2260
  // 1. Validate every opcode is supported + collect block leaders (jump targets, the fall-through
775
2261
  // after each branch, entry). Bail on any unsupported opcode (so we never mis-size the scan).
@@ -780,7 +2266,18 @@ fn build_body_cfg(
780
2266
  let mut ip = 0;
781
2267
  while ip < code.len() {
782
2268
  let op = Opcode::from_u8(code[ip])?;
783
- let size = op_size(op)?;
2269
+ // For a JV function the array opcodes (NewArray/GetMember/GetIndex/SetIndex/Call) are valid
2270
+ // and must be sized via the full instruction table; the translate loop then validates each is
2271
+ // a real JV op (else it bails). Non-JV functions keep the strict `op_size` whitelist.
2272
+ let size = match op_size(op) {
2273
+ Some(s) => s,
2274
+ None if jv.is_some() => op.instruction_size(code, ip)?,
2275
+ // #187: `LoadVar`/`Call` are 3 bytes; the translate loop lowers them to a native call if the
2276
+ // callee resolved, else bails. Sizing them here lets a function with a resolvable call pass
2277
+ // the scan (an unresolvable one still bails in translation).
2278
+ None if matches!(op, Opcode::LoadVar | Opcode::Call) => 3,
2279
+ None => return None,
2280
+ };
784
2281
  // A SelfCall whose arity != this function's arity can't be a plain f64-ABI native call → bail.
785
2282
  if op == Opcode::SelfCall {
786
2283
  if self_ref.is_none() || peek_u16(code, ip + 1)? as usize != arity {
@@ -820,15 +2317,59 @@ fn build_body_cfg(
820
2317
  }
821
2318
 
822
2319
  let mut bcx = FunctionBuilder::new(func, fbctx);
823
- let blocks: std::collections::BTreeMap<usize, Block> =
2320
+ let mut blocks: std::collections::BTreeMap<usize, Block> =
824
2321
  leaders.iter().map(|&o| (o, bcx.create_block())).collect();
825
2322
  let entry = *blocks.get(&0)?;
826
2323
  bcx.append_block_params_for_function_params(entry);
827
2324
  bcx.switch_to_block(entry);
828
2325
  let params: Vec<ClifValue> = bcx.block_params(entry).to_vec();
829
2326
 
830
- // 2. A Variable per slot, all defined at entry so every path defines them.
831
- let vars: Vec<Variable> = (0..num_slots).map(|_| bcx.declare_var(types::F64)).collect();
2327
+ // #381: self-recursive numeric function `entry` is a stack-pointer bail check (tish's deopt
2328
+ // pattern, not V8's throw-from-JIT): compare SP to the caller-provided `stack_limit`; if the
2329
+ // recursion has driven SP past it, store `tripped = 1` into the RecurGuard and return a sentinel
2330
+ // instead of recursing further. The body then runs in `body_block`; `entry` is the function's real
2331
+ // entry point, so this check runs on every (including recursive) call. Loops jumping back to offset
2332
+ // 0 land in `body_block` (no re-check needed — a loop adds no native frame). Pointer type is I64
2333
+ // (the JIT targets — x86-64 / aarch64 — are all 64-bit; the module is not built on wasm32).
2334
+ let guard_ptr: Option<ClifValue> = if recur_guard {
2335
+ // #187: the guard pointer is the LAST param — after the `arity` f64s (register-f64 ABI), or
2336
+ // after `[numeric_ptr, handles_ptr, deopt_ptr]` (array-mode ABI, index 3). Array-mode
2337
+ // self-recursion (queens' `place`) recurses on the native stack just like `fib`, so it needs
2338
+ // the SAME SP-bail to stay a catchable RangeError instead of a SIGSEGV (#381).
2339
+ let gp_idx = if array_mask != 0 { 3 } else { arity };
2340
+ let gp = *params.get(gp_idx)?;
2341
+ let stack_limit = bcx.ins().load(types::I64, MemFlags::new(), gp, 0);
2342
+ let sp = bcx.ins().get_stack_pointer(types::I64);
2343
+ let below = bcx.ins().icmp(IntCC::UnsignedLessThan, sp, stack_limit);
2344
+ let bail = bcx.create_block();
2345
+ let body = bcx.create_block();
2346
+ bcx.ins().brif(below, bail, &[], body, &[]);
2347
+ bcx.switch_to_block(bail);
2348
+ bcx.seal_block(bail);
2349
+ let one = bcx.ins().iconst(types::I8, 1);
2350
+ bcx.ins().store(MemFlags::new(), one, gp, 8); // RecurGuard.tripped (offset 8)
2351
+ let nan = bcx.ins().f64const(f64::NAN);
2352
+ bcx.ins().return_(&[nan]);
2353
+ blocks.insert(0, body); // internal jumps to offset 0 → the body, not the SP check
2354
+ bcx.switch_to_block(body);
2355
+ Some(gp)
2356
+ } else {
2357
+ None
2358
+ };
2359
+ let body_start = *blocks.get(&0)?; // `entry` normally; `body_block` when guarded
2360
+
2361
+ // 2. A Variable per slot, all defined at entry so every path defines them. A JV slot (#189) holds
2362
+ // an arena HANDLE (a `u64`, 0 = null) so its Variable is `i64`, not `f64`.
2363
+ let vars: Vec<Variable> = (0..num_slots)
2364
+ .map(|i| {
2365
+ let ty = if jv.is_some_and(|j| j.slots.contains(&i)) {
2366
+ types::I64
2367
+ } else {
2368
+ types::F64
2369
+ };
2370
+ bcx.declare_var(ty)
2371
+ })
2372
+ .collect();
832
2373
  // Array-mode state: per-array-param `(ptr,len)` loaded from the handles array + the deopt pad.
833
2374
  let mut array_slots: HashMap<usize, (ClifValue, ClifValue)> = HashMap::new();
834
2375
  let mut deopt_block: Option<Block> = None;
@@ -841,7 +2382,8 @@ fn build_body_cfg(
841
2382
  deopt_ptr = Some(*params.get(2)?);
842
2383
  let mut numeric_i = 0i32;
843
2384
  let mut array_i = 0i64;
844
- #[allow(clippy::needless_range_loop)] // `slot` drives bit-mask math (`array_mask >> slot`) + map keys, not just indexing
2385
+ #[allow(clippy::needless_range_loop)]
2386
+ // `slot` drives bit-mask math (`array_mask >> slot`) + map keys, not just indexing
845
2387
  for slot in 0..num_slots {
846
2388
  let init = if slot < arity && (array_mask >> slot) & 1 == 1 {
847
2389
  let base = bcx.ins().iadd_imm(handles_ptr, array_i * 16);
@@ -864,18 +2406,37 @@ fn build_body_cfg(
864
2406
  deopt_block = Some(bcx.create_block());
865
2407
  } else {
866
2408
  for (i, &v) in vars.iter().enumerate() {
867
- let init = if i < arity {
868
- params[i]
2409
+ if jv.is_some_and(|j| j.slots.contains(&i)) {
2410
+ // A JV slot starts null (handle 0); its `[]` def stores the real handle. Null-init
2411
+ // makes the free-on-return safe even if a return precedes the def (`tish_jv_free`
2412
+ // treats handle 0 as a no-op).
2413
+ let null = bcx.ins().iconst(types::I64, 0);
2414
+ bcx.def_var(v, null);
869
2415
  } else {
870
- bcx.ins().f64const(0.0)
871
- };
872
- bcx.def_var(v, init);
2416
+ let init = if i < arity {
2417
+ params[i]
2418
+ } else {
2419
+ bcx.ins().f64const(0.0)
2420
+ };
2421
+ bcx.def_var(v, init);
2422
+ }
873
2423
  }
874
2424
  }
875
2425
 
876
2426
  // 3. Translate. The operand stack is empty at every block boundary (statement-level control flow).
877
2427
  let mut stack: Vec<(ClifValue, bool)> = Vec::new();
878
- let mut cur = entry;
2428
+ // #189: JV array handles "in flight" (pushed by `LoadLocal`/`NewArray` of a JV slot, consumed by
2429
+ // the very next `GetIndex`/`SetIndex`/`GetMember`), kept off the f64 `stack`; and a pending
2430
+ // `arr.push` awaiting its arg + `Call`. Both must be empty at every block boundary.
2431
+ let mut jv_pending: Vec<ClifValue> = Vec::new();
2432
+ let mut pending_push: Option<ClifValue> = None;
2433
+ // #187: a resolved callee awaiting its args + `Call` (set by `LoadVar name`, consumed by the very
2434
+ // next `Call`). Like the JV pending state, must be empty at every block boundary.
2435
+ let mut pending_callee: Option<(cranelift::codegen::ir::FuncRef, u8)> = None;
2436
+ // #187: array-param slots passed as arguments to a self-recursive array-mode call (queens' `place`),
2437
+ // in argument order — pushed by `LoadLocal(array param)`, consumed by the next `SelfCall`.
2438
+ let mut arr_call_pending: Vec<usize> = Vec::new();
2439
+ let mut cur = body_start;
879
2440
  let mut terminated = false;
880
2441
  let mut ip = 0usize;
881
2442
  while ip < code.len() {
@@ -887,6 +2448,15 @@ fn build_body_cfg(
887
2448
  }
888
2449
  bcx.ins().jump(blk, &[]);
889
2450
  }
2451
+ // A JV ref, a pending push, a pending callee, or a pending array self-call arg must
2452
+ // never span a statement boundary.
2453
+ if !jv_pending.is_empty()
2454
+ || pending_push.is_some()
2455
+ || pending_callee.is_some()
2456
+ || !arr_call_pending.is_empty()
2457
+ {
2458
+ return None;
2459
+ }
890
2460
  bcx.switch_to_block(blk);
891
2461
  cur = blk;
892
2462
  terminated = false;
@@ -894,37 +2464,61 @@ fn build_body_cfg(
894
2464
  }
895
2465
  }
896
2466
  if terminated {
897
- ip += op_size(Opcode::from_u8(code[ip])?)?; // skip unreachable tail before next leader
2467
+ // Skip the unreachable tail; a JV function may have array opcodes here (size via the full
2468
+ // table).
2469
+ let op = Opcode::from_u8(code[ip])?;
2470
+ ip += match op_size(op) {
2471
+ Some(s) => s,
2472
+ None if jv.is_some() => op.instruction_size(code, ip)?,
2473
+ None => return None,
2474
+ };
898
2475
  continue;
899
2476
  }
900
2477
  let op = Opcode::from_u8(code[ip])?;
901
2478
  match op {
902
2479
  Opcode::LoadLocal => {
903
2480
  let slot = peek_u16(code, ip + 1)? as usize;
904
- // Array-mode peephole: `LoadLocal(arrayparam) ; (LoadLocal|LoadConst) ; GetIndex`
905
- // a bounds-checked native f64 load; out-of-bounds branches to the deopt pad.
2481
+ // #189: a JV slot's value is its arena HANDLE — push it to `jv_pending` (the next
2482
+ // GetIndex/SetIndex/GetMember consumes it), never onto the f64 stack.
2483
+ if jv.is_some_and(|j| j.slots.contains(&slot)) {
2484
+ jv_pending.push(bcx.use_var(*vars.get(slot)?));
2485
+ ip += 3;
2486
+ continue;
2487
+ }
2488
+ // Array-mode peepholes on an array param, both bounds-checked (OOB → the deopt pad):
2489
+ // READ `LoadLocal(arr) ; (LoadLocal|LoadConst) idx ; GetIndex` → native load
2490
+ // WRITE `LoadLocal(arr) ; idx ; (LoadLocal|LoadConst) val ; Dup ; SetIndex` (#187) → store
2491
+ // ARG a bare `LoadLocal(arr)` → an argument to a self-recursive array call (#187)
906
2492
  if array_mask != 0 && slot < arity && (array_mask >> slot) & 1 == 1 {
907
2493
  let (aptr, alen) = *array_slots.get(&slot)?;
908
- let idx_op = Opcode::from_u8(*code.get(ip + 3)?)?;
909
- let idx_f64 = match idx_op {
910
- Opcode::LoadLocal => {
911
- let islot = peek_u16(code, ip + 4)? as usize;
912
- bcx.use_var(*vars.get(islot)?)
913
- }
914
- Opcode::LoadConst => {
915
- let ci = peek_u16(code, ip + 4)? as usize;
916
- match chunk.constants.get(ci) {
917
- Some(Constant::Number(n)) => bcx.ins().f64const(*n),
918
- _ => return None,
919
- }
920
- }
921
- _ => return None,
922
- };
923
- if Opcode::from_u8(*code.get(ip + 6)?)? != Opcode::GetIndex {
924
- return None;
2494
+ // Decide the shape by PEEKING (no cranelift values created yet).
2495
+ let at3 = code.get(ip + 3).copied().and_then(Opcode::from_u8);
2496
+ let idx_simple =
2497
+ matches!(at3, Some(Opcode::LoadLocal) | Some(Opcode::LoadConst));
2498
+ let at6 = code.get(ip + 6).copied().and_then(Opcode::from_u8);
2499
+ let is_read = idx_simple && at6 == Some(Opcode::GetIndex);
2500
+ let is_write = idx_simple
2501
+ && matches!(at6, Some(Opcode::LoadLocal) | Some(Opcode::LoadConst))
2502
+ && code.get(ip + 9).copied().and_then(Opcode::from_u8) == Some(Opcode::Dup)
2503
+ && code.get(ip + 10).copied().and_then(Opcode::from_u8)
2504
+ == Some(Opcode::SetIndex);
2505
+ if !is_read && !is_write {
2506
+ // Bare array-param load — stage it as a self-call argument (the next `SelfCall`
2507
+ // validates + consumes it). A genuine escape leaves it pending at a boundary → bail.
2508
+ arr_call_pending.push(slot);
2509
+ ip += 3;
2510
+ continue;
925
2511
  }
2512
+ let idx_f64 = read_simple_operand(&mut bcx, code, ip + 3, chunk, &vars)?;
926
2513
  // i = idx as usize (saturating: NaN→0, neg→0 — matches the VM's `n as usize`).
927
2514
  let i = bcx.ins().fcvt_to_uint_sat(types::I64, idx_f64);
2515
+ // Read `val` (write only) BEFORE the bounds-check split so its LoadLocal reads the
2516
+ // slot's current SSA value in `cur`, not the fresh `cont` block.
2517
+ let store_val = if is_write {
2518
+ Some(read_simple_operand(&mut bcx, code, ip + 6, chunk, &vars)?)
2519
+ } else {
2520
+ None
2521
+ };
928
2522
  let inb = bcx.ins().icmp(IntCC::UnsignedLessThan, i, alen);
929
2523
  let cont = bcx.create_block();
930
2524
  let db = deopt_block?;
@@ -933,20 +2527,40 @@ fn build_body_cfg(
933
2527
  cur = cont; // keep block-boundary tracking accurate after the mid-stream split
934
2528
  let off = bcx.ins().imul_imm(i, 8);
935
2529
  let addr = bcx.ins().iadd(aptr, off);
936
- let val = bcx.ins().load(types::F64, MemFlags::new(), addr, 0);
937
- stack.push((val, false));
938
- ip += 7; // LoadLocal(arr) + index op + GetIndex
2530
+ if let Some(val) = store_val {
2531
+ bcx.ins().store(MemFlags::new(), val, addr, 0);
2532
+ stack.push((val, false)); // assignment yields the value (a Pop usually discards it)
2533
+ ip += 11; // LoadLocal(arr) + idx + val + Dup + SetIndex
2534
+ } else {
2535
+ let val = bcx.ins().load(types::F64, MemFlags::new(), addr, 0);
2536
+ stack.push((val, false));
2537
+ ip += 7; // LoadLocal(arr) + idx + GetIndex
2538
+ }
939
2539
  continue;
940
2540
  }
941
2541
  let v = *vars.get(slot)?;
942
- stack.push((bcx.use_var(v), false));
2542
+ // #187: a bool slot's `f64` 0/1 value carries `is_bool` so downstream equality/return
2543
+ // guards fire; a plain numeric slot pushes `false`.
2544
+ stack.push((bcx.use_var(v), bool_slots.contains(&slot)));
943
2545
  ip += 3;
944
2546
  }
945
2547
  Opcode::StoreLocal => {
946
2548
  let slot = peek_u16(code, ip + 1)? as usize;
2549
+ // #189: storing a JV array (its `[]` def or a re-store) pops the handle from
2550
+ // `jv_pending` into the slot's i64 Variable.
2551
+ if jv.is_some_and(|j| j.slots.contains(&slot)) {
2552
+ let ptr = jv_pending.pop()?;
2553
+ bcx.def_var(*vars.get(slot)?, ptr);
2554
+ ip += 3;
2555
+ continue;
2556
+ }
947
2557
  let (val, is_bool) = stack.pop()?;
948
- if is_bool {
949
- return None; // no boolean slots keeps result boxing simple
2558
+ // #187: a boolean value may be stored only into a slot the pre-pass tagged as a bool
2559
+ // slot (represented as `f64` 0/1). Any other bool store → bail (keeps unknown shapes
2560
+ // on the interpreter). A numeric store into a bool-tagged slot is fine — the slot is
2561
+ // still an `f64`; its `LoadLocal`s just carry `is_bool` (conservatively).
2562
+ if is_bool && !bool_slots.contains(&slot) {
2563
+ return None;
950
2564
  }
951
2565
  let v = *vars.get(slot)?;
952
2566
  bcx.def_var(v, val);
@@ -970,6 +2584,15 @@ fn build_body_cfg(
970
2584
  if is_bool {
971
2585
  return None;
972
2586
  }
2587
+ // #189: free every JV array (return its arena slot to the free list) before leaving
2588
+ // the frame — handle 0 (a slot not yet allocated on this path) is a no-op. This is why
2589
+ // JV arrays must never escape.
2590
+ if let Some(jvc) = jv {
2591
+ for &slot in jvc.slots {
2592
+ let handle = bcx.use_var(*vars.get(slot)?);
2593
+ bcx.ins().call(jvc.free, &[handle]);
2594
+ }
2595
+ }
973
2596
  bcx.ins().return_(&[v]);
974
2597
  terminated = true;
975
2598
  ip += 1;
@@ -1007,6 +2630,46 @@ fn build_body_cfg(
1007
2630
  terminated = true;
1008
2631
  ip += 3;
1009
2632
  }
2633
+ Opcode::SelfCall if array_mask != 0 => {
2634
+ // #187: array-mode recursive self-call (queens' `place` recursing on the SAME arrays).
2635
+ // Array args were staged in `arr_call_pending` and MUST be exactly this function's own
2636
+ // array params in ABI order — then the recursion reuses `handles_ptr`/`deopt_ptr`
2637
+ // unchanged and only re-marshals the numeric args (a fresh `numeric_ptr` per level).
2638
+ let sref = self_ref?;
2639
+ let num_arrays = arr_call_pending.len();
2640
+ let num_numeric = arity.checked_sub(num_arrays)?;
2641
+ let expected: Vec<usize> =
2642
+ (0..arity).filter(|k| (array_mask >> k) & 1 == 1).collect();
2643
+ if arr_call_pending != expected || stack.len() < num_numeric {
2644
+ return None;
2645
+ }
2646
+ arr_call_pending.clear();
2647
+ // Marshal the numeric args (top of the stack, in ABI order) into a fresh stack slot.
2648
+ let slot = bcx.create_sized_stack_slot(cranelift::codegen::ir::StackSlotData::new(
2649
+ cranelift::codegen::ir::StackSlotKind::ExplicitSlot,
2650
+ (num_numeric * 8) as u32,
2651
+ 3, // 2^3 = 8-byte alignment for f64
2652
+ ));
2653
+ let arg_start = stack.len() - num_numeric;
2654
+ for (j, (v, is_bool)) in stack.drain(arg_start..).enumerate() {
2655
+ if is_bool {
2656
+ return None; // a bool numeric arg doesn't match the f64 ABI
2657
+ }
2658
+ bcx.ins().stack_store(v, slot, (j * 8) as i32);
2659
+ }
2660
+ let num_ptr = bcx.ins().stack_addr(types::I64, slot, 0);
2661
+ let handles_ptr = *params.get(1)?;
2662
+ let deopt_ptr = *params.get(2)?;
2663
+ // #187: thread the RecurGuard (if this recursive array fn was compiled with one) so every
2664
+ // level re-checks the native stack at entry — a catchable RangeError, not a SIGSEGV.
2665
+ let mut call_args = vec![num_ptr, handles_ptr, deopt_ptr];
2666
+ if let Some(gp) = guard_ptr {
2667
+ call_args.push(gp);
2668
+ }
2669
+ let call = bcx.ins().call(sref, &call_args);
2670
+ stack.push((bcx.inst_results(call)[0], false));
2671
+ ip += 3;
2672
+ }
1010
2673
  Opcode::SelfCall => {
1011
2674
  // Recursive self-call → a native cranelift call to this very function. Args are the
1012
2675
  // top `arity` f64 stack values; result is pushed. Validated above (self_ref present,
@@ -1023,11 +2686,170 @@ fn build_body_cfg(
1023
2686
  }
1024
2687
  call_args.push(v);
1025
2688
  }
2689
+ // #381: thread the RecurGuard pointer through the recursive call so every level
2690
+ // re-checks the stack at its entry. Present iff this function was compiled guarded.
2691
+ if let Some(gp) = guard_ptr {
2692
+ call_args.push(gp);
2693
+ }
1026
2694
  let call = bcx.ins().call(sref, &call_args);
1027
2695
  let result = bcx.inst_results(call)[0];
1028
2696
  stack.push((result, false));
1029
2697
  ip += 3;
1030
2698
  }
2699
+ Opcode::MathUnary => {
2700
+ // #186 — `Math.<fn>(x)`: pop the arg, emit native op / host call, push the result.
2701
+ let id = peek_u16(code, ip + 1)?;
2702
+ let mfn = MathUnaryFn::from_u16(id)?;
2703
+ let (x, _) = stack.pop()?;
2704
+ let r = emit_math_unary(&mut bcx, math_fref, mfn, x);
2705
+ stack.push((r, false));
2706
+ ip += 3;
2707
+ }
2708
+ // #189 — local-array ops, only for JV functions (`jv` = `Some`). Each consumes the array
2709
+ // HANDLE from `jv_pending` (its `LoadLocal`/`NewArray` pushed it) and calls `tish_jv_*`.
2710
+ Opcode::NewArray if jv.is_some() => {
2711
+ let jvc = jv?;
2712
+ if peek_u16(code, ip + 1)? != 0 {
2713
+ return None; // only empty `[]` is JV (classifier already guaranteed this)
2714
+ }
2715
+ let cap = bcx.ins().iconst(types::I64, 0);
2716
+ let call = bcx.ins().call(jvc.new, &[cap]);
2717
+ jv_pending.push(bcx.inst_results(call)[0]);
2718
+ ip += 3;
2719
+ }
2720
+ Opcode::GetIndex if !jv_pending.is_empty() => {
2721
+ let jvc = jv?;
2722
+ let idx = stack.pop()?.0;
2723
+ let handle = jv_pending.pop()?;
2724
+ // `idx as usize` (saturating: NaN/neg → 0), matching the VM's index coercion. OOB sets
2725
+ // the per-thread deopt flag inside `tish_jv_get` and returns NaN.
2726
+ let i = bcx.ins().fcvt_to_uint_sat(types::I64, idx);
2727
+ let call = bcx.ins().call(jvc.get, &[handle, i]);
2728
+ stack.push((bcx.inst_results(call)[0], false));
2729
+ ip += 1;
2730
+ }
2731
+ Opcode::SetIndex if !jv_pending.is_empty() => {
2732
+ let jvc = jv?;
2733
+ // Stack: [ (array→jv_pending), idx, val, dup_val ]. `Dup` left `dup_val` == `val`.
2734
+ let dup_val = stack.pop()?.0;
2735
+ let _val = stack.pop()?.0;
2736
+ let idx = stack.pop()?.0;
2737
+ let handle = jv_pending.pop()?;
2738
+ let i = bcx.ins().fcvt_to_uint_sat(types::I64, idx);
2739
+ bcx.ins().call(jvc.set, &[handle, i, dup_val]); // OOB → deopt flag inside tish_jv_set
2740
+ stack.push((dup_val, false)); // assignment yields the value
2741
+ ip += 1;
2742
+ }
2743
+ Opcode::GetMember if !jv_pending.is_empty() => {
2744
+ let jvc = jv?;
2745
+ let name = chunk.names.get(peek_u16(code, ip + 1)? as usize)?;
2746
+ let ptr = jv_pending.pop()?;
2747
+ match name.as_ref() {
2748
+ "length" => {
2749
+ let call = bcx.ins().call(jvc.len, &[ptr]);
2750
+ let len_i = bcx.inst_results(call)[0];
2751
+ stack.push((bcx.ins().fcvt_from_uint(types::F64, len_i), false));
2752
+ }
2753
+ "push" => pending_push = Some(ptr),
2754
+ _ => return None, // any other member of a JV array → bail
2755
+ }
2756
+ ip += 3;
2757
+ }
2758
+ Opcode::Call if pending_push.is_some() => {
2759
+ let jvc = jv?;
2760
+ if peek_u16(code, ip + 1)? != 1 {
2761
+ return None; // `push` takes exactly one arg in the JV fast path
2762
+ }
2763
+ let ptr = pending_push.take()?;
2764
+ let arg = stack.pop()?.0;
2765
+ bcx.ins().call(jvc.push, &[ptr, arg]);
2766
+ // `Array.push` returns the new length.
2767
+ let call = bcx.ins().call(jvc.len, &[ptr]);
2768
+ let len_i = bcx.inst_results(call)[0];
2769
+ stack.push((bcx.ins().fcvt_from_uint(types::F64, len_i), false));
2770
+ ip += 3;
2771
+ }
2772
+ // #187: `LoadVar name` where `name` is a resolved directly-callable callee — stage it for the
2773
+ // very next `Call`. Any other `LoadVar` (an unresolved global) bails to the interpreter.
2774
+ Opcode::LoadVar => {
2775
+ if pending_callee.is_some() {
2776
+ return None; // no nested resolved calls in v1
2777
+ }
2778
+ let name = chunk.names.get(peek_u16(code, ip + 1)? as usize)?;
2779
+ match resolved.get(name) {
2780
+ Some(&(fref, callee_arity)) => pending_callee = Some((fref, callee_arity)),
2781
+ None => return None,
2782
+ }
2783
+ ip += 3;
2784
+ }
2785
+ // #187: `Call argc` consuming a staged callee — a native cranelift call (like `SelfCall`,
2786
+ // minus the guard). Pops `argc` f64 args from the stack, pushes the f64 result.
2787
+ Opcode::Call if pending_callee.is_some() => {
2788
+ let (fref, callee_arity) = pending_callee.take()?;
2789
+ if peek_u16(code, ip + 1)? as u8 != callee_arity
2790
+ || stack.len() < callee_arity as usize
2791
+ {
2792
+ return None;
2793
+ }
2794
+ let arg_start = stack.len() - callee_arity as usize;
2795
+ let mut call_args = Vec::with_capacity(callee_arity as usize);
2796
+ for (v, is_bool) in stack.drain(arg_start..) {
2797
+ if is_bool {
2798
+ return None; // a bool arg doesn't match the callee's f64 ABI
2799
+ }
2800
+ call_args.push(v);
2801
+ }
2802
+ let call = bcx.ins().call(fref, &call_args);
2803
+ stack.push((bcx.inst_results(call)[0], false));
2804
+ ip += 3;
2805
+ }
2806
+ // #187: a VOID array-mode function's implicit `return null` (the fall-through of a
2807
+ // side-effect writer like `multiplyAv`). Emit a dummy `f64` result — the wrapper returns
2808
+ // `Value::Null` for a void function, matching the interpreter — so the whole native body
2809
+ // (the array reads/writes + any cross-calls) runs instead of bailing on the null. MUST be
2810
+ // an actual return-null (next op is `Return`): a MID-BODY `let x = null` is also a
2811
+ // non-numeric `LoadConst`, and terminating on it would drop the rest of the function.
2812
+ Opcode::LoadConst
2813
+ if is_void
2814
+ && Opcode::from_u8(*code.get(ip + 3)?)? == Opcode::Return
2815
+ && !matches!(
2816
+ chunk.constants.get(peek_u16(code, ip + 1)? as usize),
2817
+ Some(Constant::Number(_)) | Some(Constant::Bool(_))
2818
+ ) =>
2819
+ {
2820
+ let zero = bcx.ins().f64const(0.0);
2821
+ bcx.ins().return_(&[zero]);
2822
+ terminated = true; // the following `Return` (and any dead tail) is now skipped
2823
+ ip += 3;
2824
+ }
2825
+ // #189: a non-numeric constant load in a JV function — almost always the compiler's
2826
+ // trailing implicit `LoadConst Null; Return` epilogue after a `while (true)` loop. That
2827
+ // epilogue is the loop's never-taken exit edge: statically reachable (so it becomes a
2828
+ // cranelift block that must be filled), dynamically dead. A `null`/string can't live in
2829
+ // an f64 register, so instead of bailing the whole function we emit a deopt here — free
2830
+ // the live arrays, set the flag, and return. If the block truly never runs (infinite loop
2831
+ // with an inner `return`), cranelift keeps a dead branch and the hot path stays native;
2832
+ // if a non-numeric return is ever actually reached, the VM wrapper re-interprets and
2833
+ // produces the correct value. Sound either way, since the deopt path reproduces semantics.
2834
+ Opcode::LoadConst
2835
+ if jv.is_some()
2836
+ && Opcode::from_u8(*code.get(ip + 3)?)? == Opcode::Return
2837
+ && !matches!(
2838
+ chunk.constants.get(peek_u16(code, ip + 1)? as usize),
2839
+ Some(Constant::Number(_)) | Some(Constant::Bool(_))
2840
+ ) =>
2841
+ {
2842
+ let jvc = jv?;
2843
+ for &slot in jvc.slots {
2844
+ let handle = bcx.use_var(*vars.get(slot)?);
2845
+ bcx.ins().call(jvc.free, &[handle]);
2846
+ }
2847
+ bcx.ins().call(jvc.deopt, &[]);
2848
+ let zero = bcx.ins().f64const(0.0);
2849
+ bcx.ins().return_(&[zero]);
2850
+ terminated = true; // the following `Return` (and any dead tail) is now skipped
2851
+ ip += 3;
2852
+ }
1031
2853
  _ => match emit_simple_op(&mut bcx, chunk, code, &mut ip, &mut stack, &params, arity) {
1032
2854
  SimpleOp::Handled(_) => {}
1033
2855
  _ => return None, // LoadConst/BinOp/UnaryOp handled; anything else → VM
@@ -1098,8 +2920,9 @@ fn build_body(
1098
2920
  // THEN arm: straight-line ops until the trailing `Jump`.
1099
2921
  let mut tip = p;
1100
2922
  loop {
1101
- match emit_simple_op(&mut bcx, chunk, code, &mut tip, &mut stack, &params, arity)
1102
- {
2923
+ match emit_simple_op(
2924
+ &mut bcx, chunk, code, &mut tip, &mut stack, &params, arity,
2925
+ ) {
1103
2926
  SimpleOp::Handled(_) => continue,
1104
2927
  SimpleOp::Unsupported => return None,
1105
2928
  SimpleOp::NotSimple => break,
@@ -1120,8 +2943,9 @@ fn build_body(
1120
2943
  // ELSE arm: straight-line ops from `jp` up to the merge point.
1121
2944
  let mut eip = jp;
1122
2945
  while eip < merge_target {
1123
- match emit_simple_op(&mut bcx, chunk, code, &mut eip, &mut stack, &params, arity)
1124
- {
2946
+ match emit_simple_op(
2947
+ &mut bcx, chunk, code, &mut eip, &mut stack, &params, arity,
2948
+ ) {
1125
2949
  SimpleOp::Handled(_) => continue,
1126
2950
  _ => return None, // nested control flow / unsupported → VM
1127
2951
  }
@@ -1140,6 +2964,12 @@ fn build_body(
1140
2964
  stack.push((sel, then_b));
1141
2965
  ip = merge_target;
1142
2966
  }
2967
+ // #187: a `function name(x) { … }` block body wraps its statements in EnterBlock/ExitBlock
2968
+ // (+ loop-var markers) — pure scope bookkeeping with no runtime effect on a straight-line
2969
+ // numeric body. Skip them so leaf functions (e.g. a `sq(x)`/`evalA(i,j)` cross-call callee)
2970
+ // compile instead of bailing on the first marker. The real `Return` still ends the path.
2971
+ Opcode::EnterBlock | Opcode::ExitBlock | Opcode::LoopVarsEnd => ip += 1,
2972
+ Opcode::LoopVarsBegin => ip += 3,
1143
2973
  // Loops / member / index / call / array / object → VM.
1144
2974
  _ => return None,
1145
2975
  }
@@ -1212,6 +3042,471 @@ mod tests {
1212
3042
  find(&chunk).expect("the JIT must compile this arity-2 numeric fn (did it start bailing?)")
1213
3043
  }
1214
3044
 
3045
+ /// #189: compile the first JV (function-local `f64` array) nested fn in `src` via `compile_chunk`,
3046
+ /// bypassing the address-keyed cache (see [`jit_arity2`]). Panics if none compiles JV — so a change
3047
+ /// that makes the classifier or lowering silently stop accepting the target fails loudly.
3048
+ fn jit_jv(src: &str) -> NumericFn {
3049
+ let prog = tishlang_parser::parse(src).expect("parse");
3050
+ let opt = tishlang_opt::optimize(&prog);
3051
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
3052
+ fn compile_uncached(c: &Chunk) -> Option<NumericFn> {
3053
+ if !c.slot_based
3054
+ || c.rest_param_index != NO_REST_PARAM
3055
+ || c.param_count == 0
3056
+ || c.param_count > 8
3057
+ {
3058
+ return None;
3059
+ }
3060
+ let lock = jit()?;
3061
+ let mut g = lock.lock().ok()?;
3062
+ compile_chunk(&mut g, c)
3063
+ }
3064
+ fn find(c: &Chunk) -> Option<NumericFn> {
3065
+ for n in &c.nested {
3066
+ if let Some(f) = compile_uncached(n) {
3067
+ if f.is_jv() {
3068
+ return Some(f);
3069
+ }
3070
+ }
3071
+ if let Some(f) = find(n) {
3072
+ return Some(f);
3073
+ }
3074
+ }
3075
+ None
3076
+ }
3077
+ find(&chunk).expect("the JIT must JV-compile this local-array fn (did it start bailing?)")
3078
+ }
3079
+
3080
+ /// #189: the core JV path — a function-local array built with `push`, then read and written by
3081
+ /// index in a loop. `sum_{i=0}^{n-1}(2i+1) == n^2`, so the JIT'd result is checked against a
3082
+ /// closed form (independent of the interpreter) across sizes. `deopt` must stay clear (in-bounds).
3083
+ #[test]
3084
+ fn jit_jv_local_array_sum_matches_closed_form() {
3085
+ let f = jit_jv(
3086
+ "function build(n) {\n\
3087
+ let a = []\n\
3088
+ for (let i = 0; i < n; i = i + 1) { a.push(i * 2) }\n\
3089
+ let s = 0\n\
3090
+ let j = 0\n\
3091
+ while (j < n) { a[j] = a[j] + 1; s = s + a[j]; j = j + 1 }\n\
3092
+ return s\n\
3093
+ }\n\
3094
+ build(0)\n",
3095
+ );
3096
+ assert!(f.is_jv(), "expected a JV-compiled fn");
3097
+ for n in [1.0f64, 5.0, 20.0, 100.0] {
3098
+ jv_reset_deopt();
3099
+ let r = f.call(&[n]);
3100
+ assert!(!jv_take_deopt(), "no OOB expected for n={n}");
3101
+ assert_eq!(r, n * n, "JV local-array sum wrong for n={n}");
3102
+ }
3103
+ }
3104
+
3105
+ /// #189: a `while (true)` whose only exit is an inner `return` — the compiler still emits a trailing
3106
+ /// implicit `LoadConst Null; Return` epilogue (the loop's never-taken exit edge). That epilogue must
3107
+ /// NOT bail JV compilation; it's routed to a deopt. Without the fix this fn falls back to the VM.
3108
+ #[test]
3109
+ fn jit_jv_while_true_epilogue_compiles() {
3110
+ let f = jit_jv(
3111
+ "function f(n) {\n\
3112
+ let a = []\n\
3113
+ a.push(0)\n\
3114
+ let i = 0\n\
3115
+ while (true) {\n\
3116
+ a[0] = a[0] + 1\n\
3117
+ i = i + 1\n\
3118
+ if (i >= n) { return a[0] }\n\
3119
+ }\n\
3120
+ }\n\
3121
+ f(0)\n",
3122
+ );
3123
+ assert!(f.is_jv(), "while(true)+return JV fn must compile, not bail");
3124
+ jv_reset_deopt();
3125
+ assert_eq!(f.call(&[7.0]), 7.0);
3126
+ assert!(!jv_take_deopt());
3127
+ }
3128
+
3129
+ /// #189: an out-of-bounds index makes the host `tish_jv_get` set the deopt flag (and return a
3130
+ /// sentinel); the VM wrapper then discards the result and re-interprets. Sound because JV arrays
3131
+ /// never escape, so the partially-run native attempt mutated nothing observable.
3132
+ #[test]
3133
+ fn jit_jv_oob_read_sets_deopt_flag() {
3134
+ // A loop that reads `a[i]` for `i` in `0..n`, but the array only has 2 elements — so `n > 2`
3135
+ // reads out of bounds. (Needs a loop: the CFG JIT only engages on loop/self-call fns.)
3136
+ let f = jit_jv(
3137
+ "function f(n) {\n\
3138
+ let a = []\n\
3139
+ a.push(10)\n\
3140
+ a.push(20)\n\
3141
+ let s = 0\n\
3142
+ for (let i = 0; i < n; i = i + 1) { s = s + a[i] }\n\
3143
+ return s\n\
3144
+ }\n\
3145
+ f(0)\n",
3146
+ );
3147
+ jv_reset_deopt();
3148
+ assert_eq!(f.call(&[2.0]), 30.0, "a[0]+a[1] == 30");
3149
+ assert!(!jv_take_deopt(), "n=2 stays in bounds");
3150
+ jv_reset_deopt();
3151
+ let _ = f.call(&[5.0]); // reads a[2] (past the 2-element array) → OOB
3152
+ assert!(jv_take_deopt(), "OOB array access must set the deopt flag");
3153
+ }
3154
+
3155
+ /// #187: compile the first arity-1 plain-numeric (non-JV, non-array) fn in `src` via `compile_chunk`.
3156
+ fn jit_numeric1(src: &str) -> NumericFn {
3157
+ let prog = tishlang_parser::parse(src).expect("parse");
3158
+ let opt = tishlang_opt::optimize(&prog);
3159
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
3160
+ fn compile_uncached(c: &Chunk) -> Option<NumericFn> {
3161
+ if !c.slot_based || c.rest_param_index != NO_REST_PARAM || c.param_count != 1 {
3162
+ return None;
3163
+ }
3164
+ let lock = jit()?;
3165
+ let mut g = lock.lock().ok()?;
3166
+ compile_chunk(&mut g, c)
3167
+ }
3168
+ fn find(c: &Chunk) -> Option<NumericFn> {
3169
+ for n in &c.nested {
3170
+ if let Some(f) = compile_uncached(n) {
3171
+ if !f.is_jv() && f.array_param_mask() == 0 {
3172
+ return Some(f);
3173
+ }
3174
+ }
3175
+ if let Some(f) = find(n) {
3176
+ return Some(f);
3177
+ }
3178
+ }
3179
+ None
3180
+ }
3181
+ find(&chunk).expect("the JIT must compile this arity-1 numeric fn (did it start bailing?)")
3182
+ }
3183
+
3184
+ /// #187: a boolean SCALAR local (`let done = false; … done = true; if (done) …`) must JIT — the
3185
+ /// bool is represented as an `f64` 0/1 and drives `if (done)` via the falsy check. Result is the
3186
+ /// numeric accumulator, checked against a closed form (`sum_{i<n} i == n(n-1)/2`), independent of
3187
+ /// the interpreter. Without bool slots this fn bails at the `done = false` StoreLocal → interprets.
3188
+ #[test]
3189
+ fn jit_bool_scalar_slot_flips_and_matches() {
3190
+ // Bounded loop (not `while(true)`) so this exercises the bool slot in isolation — a bool slot
3191
+ // `done` set inside a branch, then driving `if (done)`. `sum_{i<n} i == n(n-1)/2` for all n≥0.
3192
+ let f = jit_numeric1(
3193
+ "function f(n) {\n\
3194
+ let done = false\n\
3195
+ let acc = 0\n\
3196
+ for (let i = 0; i < n; i = i + 1) {\n\
3197
+ acc = acc + i\n\
3198
+ if (i + 1 >= n) { done = true }\n\
3199
+ }\n\
3200
+ if (done) { return acc }\n\
3201
+ return 0\n\
3202
+ }\n\
3203
+ f(0)\n",
3204
+ );
3205
+ for n in [0i64, 1, 2, 10, 100, 1000] {
3206
+ let expect = (n * (n - 1) / 2) as f64;
3207
+ assert_eq!(
3208
+ f.call(&[n as f64]),
3209
+ expect,
3210
+ "bool-slot loop wrong for n={n}"
3211
+ );
3212
+ }
3213
+ }
3214
+
3215
+ /// #187 soundness: a `bool === number` compare must NOT be JIT'd to an `f64` compare (JS says
3216
+ /// `0 === false` / `true === 1` is FALSE across types, but the bits `1.0 === 1.0` are equal). The
3217
+ /// equality guard in `emit_simple_op` bails such a function to the interpreter. This uses a LOOP so
3218
+ /// it reaches the CFG JIT (+ the guard); WITHOUT the guard it would compile and wrongly count every
3219
+ /// iteration (returning n), so the closed-form assertion catches a regression. WITH the guard it
3220
+ /// bails (no `NumericFn`), and the assertion is vacuously satisfied — either way it never miscompiles.
3221
+ #[test]
3222
+ fn jit_bool_eq_number_never_miscompiles() {
3223
+ let src = "function g(n) {\n\
3224
+ let flag = false\n\
3225
+ let hits = 0\n\
3226
+ for (let i = 0; i < n; i = i + 1) {\n\
3227
+ flag = i >= 0\n\
3228
+ if (flag === 1) { hits = hits + 1 }\n\
3229
+ }\n\
3230
+ return hits\n\
3231
+ }\n\
3232
+ g(0)\n";
3233
+ let prog = tishlang_parser::parse(src).expect("parse");
3234
+ let opt = tishlang_opt::optimize(&prog);
3235
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
3236
+ fn first_num1(c: &Chunk) -> Option<NumericFn> {
3237
+ for n in &c.nested {
3238
+ if n.slot_based && n.param_count == 1 {
3239
+ if let Some(g) =
3240
+ jit().and_then(|l| l.lock().ok().and_then(|mut g| compile_chunk(&mut g, n)))
3241
+ {
3242
+ return Some(g);
3243
+ }
3244
+ }
3245
+ if let Some(g) = first_num1(n) {
3246
+ return Some(g);
3247
+ }
3248
+ }
3249
+ None
3250
+ }
3251
+ if let Some(g) = first_num1(&chunk) {
3252
+ // `(i>=0) === 1` is always FALSE in JS, so `hits` must stay 0 — never `n`.
3253
+ assert_eq!(
3254
+ g.call(&[5.0]),
3255
+ 0.0,
3256
+ "`bool === 1` must be false → hits stays 0"
3257
+ );
3258
+ }
3259
+ }
3260
+
3261
+ /// #187: compile the first array-mode fn (`array_param_mask != 0`) in `src` via `compile_chunk`.
3262
+ fn jit_arrays(src: &str) -> NumericFn {
3263
+ let prog = tishlang_parser::parse(src).expect("parse");
3264
+ let opt = tishlang_opt::optimize(&prog);
3265
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
3266
+ fn compile_uncached(c: &Chunk) -> Option<NumericFn> {
3267
+ if !c.slot_based || c.rest_param_index != NO_REST_PARAM || c.param_count == 0 {
3268
+ return None;
3269
+ }
3270
+ let lock = jit()?;
3271
+ let mut g = lock.lock().ok()?;
3272
+ compile_chunk(&mut g, c)
3273
+ }
3274
+ fn find(c: &Chunk) -> Option<NumericFn> {
3275
+ for n in &c.nested {
3276
+ if let Some(f) = compile_uncached(n) {
3277
+ if f.array_param_mask() != 0 {
3278
+ return Some(f);
3279
+ }
3280
+ }
3281
+ if let Some(f) = find(n) {
3282
+ return Some(f);
3283
+ }
3284
+ }
3285
+ None
3286
+ }
3287
+ find(&chunk)
3288
+ .expect("the JIT must array-compile this fn (did classify_params stop matching?)")
3289
+ }
3290
+
3291
+ /// #187: an array param written as `dst[i] = v` is stored into and copied back. Here `copy` reads
3292
+ /// `src[i]` into `dst[i]`; after `call_arrays` the caller's `dst` buffer must equal `src`, and the
3293
+ /// writable mask must flag `dst` (so [`try_call_array_jit`] knows to write it back).
3294
+ #[test]
3295
+ fn jit_array_param_write_and_readback() {
3296
+ let f = jit_arrays(
3297
+ "function copy(n, src, dst) {\n\
3298
+ let i = 0\n\
3299
+ while (i < n) { let x = src[i]; dst[i] = x; i = i + 1 }\n\
3300
+ return dst[0]\n\
3301
+ }\n\
3302
+ copy(0, [], [])\n",
3303
+ );
3304
+ assert!(f.array_param_mask() != 0, "src/dst are array params");
3305
+ assert_ne!(f.array_writable_mask(), 0, "dst must be flagged writable");
3306
+ let mut src = [10.0f64, 20.0, 30.0];
3307
+ let mut dst = [0.0f64; 3];
3308
+ // arity 3 = [n (numeric), src (array), dst (array)]. numeric = [n]; arrays in param order.
3309
+ let handles = [
3310
+ ArrayHandle {
3311
+ ptr: src.as_mut_ptr(),
3312
+ len: 3,
3313
+ },
3314
+ ArrayHandle {
3315
+ ptr: dst.as_mut_ptr(),
3316
+ len: 3,
3317
+ },
3318
+ ];
3319
+ let (res, deopt) = f.call_arrays(&[3.0], &handles);
3320
+ assert!(!deopt, "in-bounds writes never deopt");
3321
+ assert_eq!(dst, [10.0, 20.0, 30.0], "dst must be overwritten with src");
3322
+ assert_eq!(res, 10.0, "returns dst[0]");
3323
+ }
3324
+
3325
+ /// #187: an out-of-bounds array-param WRITE sets the deopt flag (the wrapper then discards the
3326
+ /// scratch — no partial writeback — and re-interprets).
3327
+ #[test]
3328
+ fn jit_array_param_write_oob_deopts() {
3329
+ let f = jit_arrays(
3330
+ "function copy(n, src, dst) {\n\
3331
+ let i = 0\n\
3332
+ while (i < n) { let x = src[i]; dst[i] = x; i = i + 1 }\n\
3333
+ return dst[0]\n\
3334
+ }\n\
3335
+ copy(0, [], [])\n",
3336
+ );
3337
+ let mut src = [1.0f64; 5];
3338
+ let mut dst = [0.0f64; 2]; // only 2 elements — a write to dst[2] is OOB
3339
+ let handles = [
3340
+ ArrayHandle {
3341
+ ptr: src.as_mut_ptr(),
3342
+ len: 5,
3343
+ },
3344
+ ArrayHandle {
3345
+ ptr: dst.as_mut_ptr(),
3346
+ len: 2,
3347
+ },
3348
+ ];
3349
+ let (_res, deopt) = f.call_arrays(&[5.0], &handles); // writes dst[0..5) into a len-2 array
3350
+ assert!(deopt, "OOB array-param write must deopt");
3351
+ }
3352
+
3353
+ /// #187: a caller lowers a `name(args)` call to a stable register-`f64` callee into a native
3354
+ /// cranelift call. Compiles `sq` first (registering it), then `sumSq` which calls it; the result
3355
+ /// (`sum_{i<n} i^2 == (n-1)n(2n-1)/6`) is checked against a closed form independent of the interp.
3356
+ #[test]
3357
+ fn jit_cross_function_call_matches_closed_form() {
3358
+ let src = "function sq(x) { return x * x }\n\
3359
+ function sumSq(n) {\n\
3360
+ let s = 0\n\
3361
+ let i = 0\n\
3362
+ while (i < n) { s = s + sq(i); i = i + 1 }\n\
3363
+ return s\n\
3364
+ }\n\
3365
+ sumSq(0)\n";
3366
+ let prog = tishlang_parser::parse(src).expect("parse");
3367
+ let opt = tishlang_opt::optimize(&prog);
3368
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
3369
+ // Compile every nested fn in source order (sq before sumSq) so sq registers before sumSq
3370
+ // resolves it. `compile_chunk` bypasses the address cache, and cross-call fns aren't cached.
3371
+ let lock = jit().expect("jit available");
3372
+ let mut sumsq: Option<NumericFn> = None;
3373
+ for n in &chunk.nested {
3374
+ let mut g = lock.lock().unwrap();
3375
+ if let Some(f) = compile_chunk(&mut g, n) {
3376
+ if n.global_name.as_deref() == Some("sumSq") {
3377
+ sumsq = Some(f);
3378
+ }
3379
+ }
3380
+ }
3381
+ let f =
3382
+ sumsq.expect("sumSq must compile with the resolved `sq` call (did resolution break?)");
3383
+ for n in [1i64, 5, 20, 50] {
3384
+ let expect = ((n - 1) * n * (2 * n - 1) / 6) as f64;
3385
+ assert_eq!(
3386
+ f.call(&[n as f64]),
3387
+ expect,
3388
+ "cross-call sumSq wrong for n={n}"
3389
+ );
3390
+ }
3391
+ }
3392
+
3393
+ /// #187: an array-mode function that is SELF-RECURSIVE and passes a BOOL array param back to itself
3394
+ /// (queens' `place` shape). Exercises: bool-array element read (`!cols[col]`), bool-array write
3395
+ /// (`cols[col] = true/false`), and the native array self-call (reusing `handles_ptr`, re-marshalling
3396
+ /// the numeric args). `place(n, 0, all-false)` counts permutations = n!.
3397
+ #[test]
3398
+ fn jit_array_mode_recursion_over_bool_array() {
3399
+ let f = jit_arrays(
3400
+ "function place(n, row, cols) {\n\
3401
+ if (row === n) { return 1 }\n\
3402
+ let count = 0\n\
3403
+ for (let col = 0; col < n; col = col + 1) {\n\
3404
+ if (!cols[col]) {\n\
3405
+ cols[col] = true\n\
3406
+ count = count + place(n, row + 1, cols)\n\
3407
+ cols[col] = false\n\
3408
+ }\n\
3409
+ }\n\
3410
+ return count\n\
3411
+ }\n\
3412
+ place(0, 0, [])\n",
3413
+ );
3414
+ assert!(f.array_param_mask() != 0, "cols is an array param");
3415
+ assert!(
3416
+ f.recur_guarded(),
3417
+ "place is self-recursive → compiled with a trailing RecurGuard param"
3418
+ );
3419
+ // n! for n = 0..6 (cols starts all-false = 0.0). Each call resets `cols` to all-false on return.
3420
+ let factorial = [1.0f64, 1.0, 2.0, 6.0, 24.0, 120.0, 720.0];
3421
+ for (n, &expect) in factorial.iter().enumerate() {
3422
+ let mut cols = vec![0.0f64; n.max(1)];
3423
+ let handles = [ArrayHandle {
3424
+ ptr: cols.as_mut_ptr(),
3425
+ len: n,
3426
+ }];
3427
+ // `stack_limit = 0` ⇒ the entry SP-check never trips (a real SP is always ≥ 0).
3428
+ let mut guard = RecurGuard {
3429
+ stack_limit: 0,
3430
+ tripped: 0,
3431
+ };
3432
+ let (res, deopt) = f.call_arrays_guarded(&[n as f64, 0.0], &handles, &mut guard);
3433
+ assert!(!deopt, "in-bounds, no deopt for n={n}");
3434
+ assert_eq!(
3435
+ guard.tripped, 0,
3436
+ "shallow recursion must not trip the guard"
3437
+ );
3438
+ assert_eq!(res, expect, "place({n},0,cols) = {n}! = {expect}");
3439
+ }
3440
+ }
3441
+
3442
+ /// #187: the write-KIND classification that pins the array-mode writeback's soundness. The JIT
3443
+ /// flattens every element to `f64`, so `try_call_array_jit` re-boxes each written element by the
3444
+ /// runtime array's element type — which is only sound when the statically written kind (bool vs
3445
+ /// number) matches. `classify_params` must therefore report, per writable array, whether it is
3446
+ /// written with a bool (a `LoadConst(Bool)` or a `LoadLocal(bool-slot)`), and reject a single array
3447
+ /// written with BOTH a bool and a number (unrepresentable as one flat re-boxing).
3448
+ #[test]
3449
+ fn classify_params_bool_write_kinds() {
3450
+ // The arity-2 array fn `f(a, n)` from `src` (already the nested chunk).
3451
+ fn arr_fn(src: &str) -> Chunk {
3452
+ fn_chunk(src)
3453
+ }
3454
+ let bit0 = |m: u8| m & 1;
3455
+
3456
+ // A bool CONST write ⇒ array bit, writable bit, AND bool-write bit all set.
3457
+ let c = arr_fn(
3458
+ "function f(a, n) { for (let i = 0; i < n; i = i + 1) { a[i] = true } return 0 }\nf([0], 1)\n",
3459
+ );
3460
+ let (am, wm, bm) = classify_params(&c, c.param_count as usize);
3461
+ assert_eq!(
3462
+ (bit0(am), bit0(wm), bit0(bm)),
3463
+ (1, 1, 1),
3464
+ "bool-const write"
3465
+ );
3466
+
3467
+ // A number CONST write ⇒ array + writable set, bool-write CLEAR.
3468
+ let c = arr_fn(
3469
+ "function f(a, n) { for (let i = 0; i < n; i = i + 1) { a[i] = 7 } return 0 }\nf([0], 1)\n",
3470
+ );
3471
+ let (am, wm, bm) = classify_params(&c, c.param_count as usize);
3472
+ assert_eq!(
3473
+ (bit0(am), bit0(wm), bit0(bm)),
3474
+ (1, 1, 0),
3475
+ "number-const write"
3476
+ );
3477
+
3478
+ // A bool LOCAL write ⇒ the whole fn bails: `classify_bool_slots` is a superset (a bool-tagged
3479
+ // slot may be reassigned a number), so a `LoadLocal(bool-slot)` written value can't be typed for
3480
+ // the flat writeback. Reject rather than risk re-boxing wrong.
3481
+ let c = arr_fn(
3482
+ "function f(a, n) { let b = true; for (let i = 0; i < n; i = i + 1) { a[i] = b } return 0 }\nf([0], 1)\n",
3483
+ );
3484
+ assert_eq!(
3485
+ classify_params(&c, c.param_count as usize),
3486
+ (0, 0, 0),
3487
+ "bool-local write must bail (bool-tagged slot may hold a number)"
3488
+ );
3489
+
3490
+ // A number LOCAL write ⇒ bool-write CLEAR.
3491
+ let c = arr_fn(
3492
+ "function f(a, n) { let x = n + 1; for (let i = 0; i < n; i = i + 1) { a[i] = x } return 0 }\nf([0], 1)\n",
3493
+ );
3494
+ let (am, wm, bm) = classify_params(&c, c.param_count as usize);
3495
+ assert_eq!(
3496
+ (bit0(am), bit0(wm), bit0(bm)),
3497
+ (1, 1, 0),
3498
+ "number-local write"
3499
+ );
3500
+
3501
+ // Mixed bool + number writes to the SAME array ⇒ the whole function is rejected.
3502
+ let c = arr_fn("function f(a, n) { a[0] = true; a[1] = 5; return 0 }\nf([0, 0], 1)\n");
3503
+ assert_eq!(
3504
+ classify_params(&c, c.param_count as usize),
3505
+ (0, 0, 0),
3506
+ "mixed bool+number writes must bail"
3507
+ );
3508
+ }
3509
+
1215
3510
  /// Regression for the address-reuse stale hit (#247): compile one function, then overwrite the SAME
1216
3511
  /// heap `Chunk` (same address = the cache key) with a *different* function — what a long-lived
1217
3512
  /// process (REPL / multi-script embedder) does when a freed chunk address is reused. Before the
@@ -1239,14 +3534,116 @@ mod tests {
1239
3534
  let shr = jit_arity2("const f = (a, b) => a >> b\nf(0, 0)\n");
1240
3535
  let ushr = jit_arity2("const f = (a, b) => a >>> b\nf(0, 0)\n");
1241
3536
  let cases = [
1242
- (1.0, 4.0), (1.0, 32.0), (1.0, 33.0), (1.0, -1.0), (-8.0, 1.0), (-1.0, 0.0),
1243
- (-2.0, 1.0), (4294967295.0, 0.0), (3.9, 0.0), (4294967297.0, 0.0),
1244
- (-123456789.0, 5.0), (65535.0, 16.0),
3537
+ (1.0, 4.0),
3538
+ (1.0, 32.0),
3539
+ (1.0, 33.0),
3540
+ (1.0, -1.0),
3541
+ (-8.0, 1.0),
3542
+ (-1.0, 0.0),
3543
+ (-2.0, 1.0),
3544
+ (4294967295.0, 0.0),
3545
+ (3.9, 0.0),
3546
+ (4294967297.0, 0.0),
3547
+ (-123456789.0, 5.0),
3548
+ (65535.0, 16.0),
1245
3549
  ];
1246
3550
  for (a, b) in cases {
1247
- assert_eq!(shl.call(&[a, b]), to_int32(a).wrapping_shl(to_uint32(b)) as f64, "<< {a} {b}");
1248
- assert_eq!(shr.call(&[a, b]), to_int32(a).wrapping_shr(to_uint32(b)) as f64, ">> {a} {b}");
1249
- assert_eq!(ushr.call(&[a, b]), to_uint32(a).wrapping_shr(to_uint32(b)) as f64, ">>> {a} {b}");
3551
+ assert_eq!(
3552
+ shl.call(&[a, b]),
3553
+ to_int32(a).wrapping_shl(to_uint32(b)) as f64,
3554
+ "<< {a} {b}"
3555
+ );
3556
+ assert_eq!(
3557
+ shr.call(&[a, b]),
3558
+ to_int32(a).wrapping_shr(to_uint32(b)) as f64,
3559
+ ">> {a} {b}"
3560
+ );
3561
+ assert_eq!(
3562
+ ushr.call(&[a, b]),
3563
+ to_uint32(a).wrapping_shr(to_uint32(b)) as f64,
3564
+ ">>> {a} {b}"
3565
+ );
3566
+ }
3567
+ }
3568
+
3569
+ /// Top-level chunk of compiled `src` (where hot loops live) — #190 OSR targets these.
3570
+ fn top_chunk(src: &str) -> Chunk {
3571
+ let prog = tishlang_parser::parse(src).expect("parse");
3572
+ let opt = tishlang_opt::optimize(&prog);
3573
+ tishlang_bytecode::compile(&opt).expect("compile")
3574
+ }
3575
+
3576
+ /// `(header_ip, region_end)` of the FIRST loop in `chunk` — its first `JumpBack` names the region
3577
+ /// the OSR trigger would compile.
3578
+ fn first_region(chunk: &Chunk) -> (usize, usize) {
3579
+ let code = &chunk.code;
3580
+ let mut ip = 0;
3581
+ while ip < code.len() {
3582
+ let op = Opcode::from_u8(code[ip]).expect("op");
3583
+ if op == Opcode::JumpBack {
3584
+ let dist = peek_u16(code, ip + 1).unwrap() as usize;
3585
+ let region_end = ip + 3;
3586
+ return (region_end - dist, region_end);
3587
+ }
3588
+ ip += op.instruction_size(code, ip).unwrap_or(1);
1250
3589
  }
3590
+ panic!("no JumpBack (loop) in chunk");
3591
+ }
3592
+
3593
+ /// #190 — the region compiler + `LoopFn` ABI run a real numeric loop end-to-end (independent of
3594
+ /// the VM dispatch loop): `while (i < 10) { s = s + i; i = i + 1 }` from all-zero live-ins must
3595
+ /// leave `s = 45`, `i = 10` in the live slots and return an in-range exit id, without deopting.
3596
+ #[test]
3597
+ fn osr_region_runs_numeric_loop() {
3598
+ let chunk =
3599
+ top_chunk("let s = 0.0\nlet i = 0.0\nwhile (i < 10.0) { s = s + i; i = i + 1.0 }\n");
3600
+ let (header, end) = first_region(&chunk);
3601
+ let lf = try_compile_loop(&chunk, header, end).expect("pure-numeric loop must OSR-compile");
3602
+ assert!(!lf.used_slots.is_empty() && !lf.exits.is_empty());
3603
+ let mut buf = vec![0.0f64; lf.used_slots.len()];
3604
+ let mut deopt = 0u8;
3605
+ let exit = lf.call(&mut buf, &mut deopt);
3606
+ assert!((exit as usize) < lf.exits.len(), "exit id in range");
3607
+ assert_eq!(deopt, 0, "v1 region never sets the deopt flag");
3608
+ let mut got = buf.clone();
3609
+ got.sort_by(|a, b| a.partial_cmp(b).unwrap());
3610
+ assert_eq!(
3611
+ got,
3612
+ vec![10.0, 45.0],
3613
+ "s=45 (sum 0..9), i=10 after the loop"
3614
+ );
3615
+ }
3616
+
3617
+ /// #190 — a loop that touches a non-slot value (a general call) is not pure-numeric slot math, so
3618
+ /// the region must be rejected (negative-cached) and the VM keeps interpreting. `Math.max` is a
3619
+ /// 2-arg call (NOT the #186 unary intrinsic), so it stays a `Call` the region must reject.
3620
+ #[test]
3621
+ fn osr_region_rejects_calls() {
3622
+ let chunk = top_chunk(
3623
+ "let a = 0.0\nfor (let i = 0; i < 100; i = i + 1) { a = a + Math.max(i, 2.0) }\n",
3624
+ );
3625
+ let (header, end) = first_region(&chunk);
3626
+ assert!(
3627
+ try_compile_loop(&chunk, header, end).is_none(),
3628
+ "a loop containing a general call must not OSR-compile"
3629
+ );
3630
+ }
3631
+
3632
+ /// #190 — a loop with nested branches compiles and computes correctly through multiple blocks:
3633
+ /// `while (i < 20) { if (i % 2 == 0) s = s + i; i = i + 1 }` → s = sum of evens in 0..19 = 90.
3634
+ #[test]
3635
+ fn osr_region_handles_branches() {
3636
+ let chunk = top_chunk(
3637
+ "let s = 0.0\nlet i = 0.0\nwhile (i < 20.0) { if (i % 2.0 == 0.0) { s = s + i }; i = i + 1.0 }\n",
3638
+ );
3639
+ let (header, end) = first_region(&chunk);
3640
+ let lf =
3641
+ try_compile_loop(&chunk, header, end).expect("branchy numeric loop must OSR-compile");
3642
+ let mut buf = vec![0.0f64; lf.used_slots.len()];
3643
+ let mut deopt = 0u8;
3644
+ lf.call(&mut buf, &mut deopt);
3645
+ let mut got = buf.clone();
3646
+ got.sort_by(|a, b| a.partial_cmp(b).unwrap());
3647
+ assert_eq!(got, vec![20.0, 90.0], "s=90 (0+2+…+18), i=20");
1251
3648
  }
1252
3649
  }