1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
//! Strict numeric mode (`VM::set_numeric_hook`) and the block-JIT float result.
//!
//! Both exist for frontends whose language is not awk: elisp signals on a
//! non-numeric operand and promotes integer overflow to a bignum, where the
//! default policy coerces (`"a"` → `0.0`) and wraps. The tests below pin:
//!
//! 1. a chunk whose result is a float still returns a float once the block-JIT
//! cache is warm (it used to truncate to an integer from the second run on);
//! 2. the default policy still coerces and wraps — zshrs/awkrs/stryke semantics
//! are untouched by the hook's existence;
//! 3. with a hook installed, a non-numeric operand and an overflowing integer
//! op both reach the host, *including* after the JIT has compiled the chunk,
//! which is the case native code would otherwise silently get wrong.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use fusevm::{Chunk, ChunkBuilder, NumOp, Op, VMResult, Value, VM};
/// Mirrors how elisprs drives fusevm: a fresh VM per chunk, tracing JIT on,
/// every chunk run on the same thread (so the JIT caches are shared).
fn run(chunk: Chunk, hook: Option<fusevm::NumericHook>) -> Result<Value, String> {
let mut vm = VM::new(chunk);
vm.enable_tracing_jit();
if let Some(h) = hook {
vm.set_numeric_hook(h);
}
match vm.run() {
VMResult::Ok(v) => Ok(v),
VMResult::Halted => Ok(vm.stack.last().cloned().unwrap_or(Value::Undef)),
VMResult::Error(e) => Err(e),
}
}
fn float_chunk(f: f64) -> Chunk {
let mut b = ChunkBuilder::new();
b.emit(Op::LoadFloat(f), 0);
b.build()
}
/// `a OP b` as a two-constant chunk.
fn binop_chunk(a: Value, b: Value, op: Op) -> Chunk {
let mut bd = ChunkBuilder::new();
match a {
Value::Int(n) => bd.emit(Op::LoadInt(n), 0),
Value::Float(f) => bd.emit(Op::LoadFloat(f), 0),
other => {
let idx = bd.add_constant(other);
bd.emit(Op::LoadConst(idx), 0)
}
};
match b {
Value::Int(n) => bd.emit(Op::LoadInt(n), 0),
Value::Float(f) => bd.emit(Op::LoadFloat(f), 0),
other => {
let idx = bd.add_constant(other);
bd.emit(Op::LoadConst(idx), 0)
}
};
bd.emit(op, 0);
bd.build()
}
/// A float chunk must keep returning a float no matter how many times it runs.
///
/// Regression: `VM::run` decoded the block tier's `i64` return register as
/// `Value::Int` unconditionally, so once the block cache warmed up (run 2), a
/// chunk loading `2.5` returned `Int(2)`. elisprs hit this as `(eval 2.5 t)`
/// evaluating to `2` the second time the same form was evaluated.
#[test]
fn block_jit_preserves_a_float_chunk_result_across_runs() {
for i in 1..=12 {
let v = run(float_chunk(2.5), None).expect("float chunk runs");
assert_eq!(v, Value::Float(2.5), "run {i} lost the float");
}
// A different float, to prove the cache is keyed and decoded per chunk.
assert_eq!(run(float_chunk(-0.5), None).unwrap(), Value::Float(-0.5));
}
/// No hook installed → the awk/shell contract is exactly as before: a string
/// operand coerces through `to_float`, and integer overflow wraps.
#[test]
fn default_policy_still_coerces_and_wraps() {
let v = run(
binop_chunk(Value::Int(1), Value::str("a".to_string()), Op::Add),
None,
)
.expect("coercing add of a string does not error");
assert_eq!(v, Value::Float(1.0));
let v = run(
binop_chunk(Value::Int(i64::MAX), Value::Int(1), Op::Add),
None,
)
.expect("coercing add does not error on overflow");
assert_eq!(v, Value::Int(i64::MIN), "overflow must wrap, not trap");
}
/// With a hook installed, a non-numeric operand is the host's decision.
#[test]
fn strict_mode_delegates_a_non_numeric_operand() {
let hook: fusevm::NumericHook = Arc::new(|_op, a, b| {
let bad = if matches!(a, Value::Int(_) | Value::Float(_)) {
b
} else {
a
};
Err(format!("wrong-type-argument: number-or-marker-p {bad:?}"))
});
let err = run(
binop_chunk(Value::Int(1), Value::str("a".to_string()), Op::Add),
Some(hook),
)
.expect_err("strict add of a string must signal");
assert!(
err.starts_with("wrong-type-argument"),
"hook's error must propagate verbatim, got: {err}"
);
}
/// With a hook installed, integer overflow reaches the host so it can widen —
/// and it keeps reaching the host after the block JIT has compiled the chunk,
/// which is the case the overflow-checked lowering exists for. Without the
/// checked lowering this test fails from the warmup threshold on (native `iadd`
/// wraps silently and never calls back).
#[test]
fn strict_mode_delegates_integer_overflow_even_once_jit_compiled() {
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let hook: fusevm::NumericHook = Arc::new(move |op, a, b| {
seen.fetch_add(1, Ordering::Relaxed);
assert_eq!(op, NumOp::Add);
assert_eq!((a, b), (&Value::Int(i64::MAX), &Value::Int(1)));
// Stand in for a bignum: the host returns a value fusevm never could.
Ok(Value::str("BIGNUM".to_string()))
});
// Well past the block-JIT warmup threshold (10), so the later iterations run
// as native code.
for i in 1..=25 {
let v = run(
binop_chunk(Value::Int(i64::MAX), Value::Int(1), Op::Add),
Some(hook.clone()),
)
.expect("overflow is delegated, not an error");
assert_eq!(
v,
Value::str("BIGNUM".to_string()),
"run {i}: overflow escaped the hook (wrapped in native code?)"
);
}
assert_eq!(
calls.load(Ordering::Relaxed),
25,
"every run must delegate, including the JIT-compiled ones"
);
}
/// Multiplication and negation overflow the same way, and a non-overflowing
/// strict chunk still returns the plain fixnum result (the checked lowering must
/// not change results, only catch the cases i64 cannot represent).
#[test]
fn strict_mode_checked_ops_are_exact_when_they_fit() {
let hook: fusevm::NumericHook = Arc::new(|_, _, _| Ok(Value::str("BIG".to_string())));
for i in 1..=15 {
let v = run(
binop_chunk(Value::Int(6), Value::Int(7), Op::Mul),
Some(hook.clone()),
)
.unwrap();
assert_eq!(v, Value::Int(42), "run {i}: checked mul changed the result");
}
let v = run(
binop_chunk(Value::Int(i64::MAX), Value::Int(2), Op::Mul),
Some(hook.clone()),
)
.unwrap();
assert_eq!(
v,
Value::str("BIG".to_string()),
"mul overflow must delegate"
);
}
/// A host with tagged fixnums (Emacs: 62-bit) must see results that still fit an
/// `i64` but leave its fixnum range — `most-positive-fixnum + 1` is a bignum in
/// Emacs even though 2^61 fits an i64 twice over. The bounds check rides in the
/// same accumulator as the overflow bit, so it must survive JIT compilation too.
#[test]
fn strict_mode_delegates_results_outside_a_narrowed_fixnum_range() {
const MOST_POSITIVE_FIXNUM: i64 = 2_305_843_009_213_693_951; // 2^61 - 1
const MOST_NEGATIVE_FIXNUM: i64 = -2_305_843_009_213_693_952;
let calls = Arc::new(AtomicUsize::new(0));
let seen = calls.clone();
let hook: fusevm::NumericHook = Arc::new(move |_op, _a, _b| {
seen.fetch_add(1, Ordering::Relaxed);
Ok(Value::str("BIGNUM".to_string()))
});
let run_ranged = |chunk: Chunk| -> Value {
let mut vm = VM::new(chunk);
vm.enable_tracing_jit();
vm.set_numeric_hook(hook.clone());
vm.set_fixnum_range(MOST_NEGATIVE_FIXNUM, MOST_POSITIVE_FIXNUM);
match vm.run() {
VMResult::Ok(v) => v,
VMResult::Halted => vm.stack.last().cloned().unwrap_or(Value::Undef),
VMResult::Error(e) => panic!("vm error: {e}"),
}
};
// Past the block-JIT threshold, so the tail of this loop is native code.
for i in 1..=25 {
let v = run_ranged(binop_chunk(
Value::Int(MOST_POSITIVE_FIXNUM),
Value::Int(1),
Op::Add,
));
assert_eq!(
v,
Value::str("BIGNUM".to_string()),
"run {i}: 2^61 escaped as a fixnum"
);
}
assert_eq!(calls.load(Ordering::Relaxed), 25);
// In range: still an exact native fixnum, no delegation.
let v = run_ranged(binop_chunk(
Value::Int(MOST_POSITIVE_FIXNUM - 1),
Value::Int(1),
Op::Add,
));
assert_eq!(v, Value::Int(MOST_POSITIVE_FIXNUM));
assert_eq!(
calls.load(Ordering::Relaxed),
25,
"in-range must not delegate"
);
}