Skip to content

Commit 827dde3

Browse files
committed
fix: serialize whole-number floats as integers in extract_binaries
QuickJS stores JSON-parsed numbers as doubles internally, so value_to_json_with_binaries was emitting 42.0 instead of 42 for whole numbers. This caused serde deserialization failures on the host side when typed host functions expected i32/i64 args. Fix: when a float has no fractional part and fits in i64, emit it as an integer to match JSON.stringify behaviour. Also adds 7 numeric type tests covering i32, i64, f64, bool, mixed types, negative integers, and zero — all passing through the binary host function path with event data (JSON-parsed → float → host).
1 parent db7d6ed commit 827dde3

2 files changed

Lines changed: 204 additions & 4 deletions

File tree

src/hyperlight-js-runtime/src/host_fn.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,23 @@ fn value_to_json_with_binaries<'js>(
135135
// QuickJS stores numbers as doubles internally but optimises small
136136
// integers into SMIs. We check as_int() first for integer fidelity,
137137
// falling back to as_float() for all other numeric values.
138+
// For floats that represent whole numbers (e.g. 42.0 from JSON.parse),
139+
// we emit them as integers to match JSON.stringify behaviour and
140+
// preserve serde integer deserialization on the host side.
138141
if let Some(n) = value.as_int() {
139142
return Ok(serde_json::Value::Number(n.into()));
140143
}
141144
if let Some(n) = value.as_float() {
142145
// Handle NaN and Infinity as null (like JSON.stringify)
143-
if n.is_finite()
144-
&& let Some(num) = serde_json::Number::from_f64(n)
145-
{
146-
return Ok(serde_json::Value::Number(num));
146+
if n.is_finite() {
147+
// If the float is a whole number that fits in i64, emit as integer
148+
// to match JSON.stringify behaviour (42.0 → 42, not 42.0)
149+
if n == (n as i64) as f64 && n >= i64::MIN as f64 && n <= i64::MAX as f64 {
150+
return Ok(serde_json::Value::Number((n as i64).into()));
151+
}
152+
if let Some(num) = serde_json::Number::from_f64(n) {
153+
return Ok(serde_json::Value::Number(num));
154+
}
147155
}
148156
return Ok(serde_json::Value::Null);
149157
}

src/hyperlight-js/tests/host_functions.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,195 @@ fn register_js_binary_in_nested_object() {
513513

514514
assert_eq!(res, r#"{"result":"test-3"}"#);
515515
}
516+
517+
// ── Numeric type tests ───────────────────────────────────────────────
518+
// QuickJS stores JSON-parsed numbers as doubles internally. The binary
519+
// host function path (extract_binaries → value_to_json_with_binaries)
520+
// must serialize whole-number floats as integers to preserve serde
521+
// deserialization on the host side.
522+
523+
#[test]
524+
fn host_fn_with_i32_arg_from_event_data() {
525+
// event.x is parsed from JSON → stored as f64 in QuickJS → must
526+
// arrive at the host as an integer, not 42.0
527+
let handler = Script::from_content(
528+
r#"
529+
import * as math from "math";
530+
function handler(event) {
531+
return { result: math.double(event.x) };
532+
}
533+
"#,
534+
);
535+
536+
let mut proto = SandboxBuilder::new().build().unwrap();
537+
proto.register("math", "double", |x: i32| x * 2).unwrap();
538+
539+
let mut sandbox = proto.load_runtime().unwrap();
540+
sandbox.add_handler("handler", handler).unwrap();
541+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
542+
543+
let res = loaded
544+
.handle_event("handler", r#"{"x": 42}"#.to_string(), None)
545+
.unwrap();
546+
assert_eq!(res, r#"{"result":84}"#);
547+
}
548+
549+
#[test]
550+
fn host_fn_with_i64_arg_from_event_data() {
551+
let handler = Script::from_content(
552+
r#"
553+
import * as math from "math";
554+
function handler(event) {
555+
return { result: math.negate(event.x) };
556+
}
557+
"#,
558+
);
559+
560+
let mut proto = SandboxBuilder::new().build().unwrap();
561+
proto.register("math", "negate", |x: i64| -x).unwrap();
562+
563+
let mut sandbox = proto.load_runtime().unwrap();
564+
sandbox.add_handler("handler", handler).unwrap();
565+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
566+
567+
let res = loaded
568+
.handle_event("handler", r#"{"x": 100}"#.to_string(), None)
569+
.unwrap();
570+
assert_eq!(res, r#"{"result":-100}"#);
571+
}
572+
573+
#[test]
574+
fn host_fn_with_f64_arg_preserves_fractional() {
575+
// Actual floats (3.14) must remain as floats, not be truncated
576+
let handler = Script::from_content(
577+
r#"
578+
import * as math from "math";
579+
function handler(event) {
580+
return { result: math.half(event.x) };
581+
}
582+
"#,
583+
);
584+
585+
let mut proto = SandboxBuilder::new().build().unwrap();
586+
proto.register("math", "half", |x: f64| x / 2.0).unwrap();
587+
588+
let mut sandbox = proto.load_runtime().unwrap();
589+
sandbox.add_handler("handler", handler).unwrap();
590+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
591+
592+
let res = loaded
593+
.handle_event("handler", r#"{"x": 3.14}"#.to_string(), None)
594+
.unwrap();
595+
596+
let json: serde_json::Value = serde_json::from_str(&res).unwrap();
597+
let result = json["result"].as_f64().unwrap();
598+
assert!(
599+
(result - 1.57).abs() < 0.001,
600+
"Expected ~1.57, got {result}"
601+
);
602+
}
603+
604+
#[test]
605+
fn host_fn_with_bool_arg() {
606+
let handler = Script::from_content(
607+
r#"
608+
import * as logic from "logic";
609+
function handler(event) {
610+
return { result: logic.flip(event.flag) };
611+
}
612+
"#,
613+
);
614+
615+
let mut proto = SandboxBuilder::new().build().unwrap();
616+
proto.register("logic", "flip", |b: bool| !b).unwrap();
617+
618+
let mut sandbox = proto.load_runtime().unwrap();
619+
sandbox.add_handler("handler", handler).unwrap();
620+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
621+
622+
let res = loaded
623+
.handle_event("handler", r#"{"flag": true}"#.to_string(), None)
624+
.unwrap();
625+
assert_eq!(res, r#"{"result":false}"#);
626+
}
627+
628+
#[test]
629+
fn host_fn_with_mixed_numeric_types() {
630+
// i32 + f64 mix in the same call
631+
let handler = Script::from_content(
632+
r#"
633+
import * as math from "math";
634+
function handler(event) {
635+
return { result: math.weighted_add(event.a, event.b, event.weight) };
636+
}
637+
"#,
638+
);
639+
640+
let mut proto = SandboxBuilder::new().build().unwrap();
641+
proto
642+
.register("math", "weighted_add", |a: i32, b: i32, w: f64| {
643+
(a as f64 * w + b as f64 * (1.0 - w)) as i32
644+
})
645+
.unwrap();
646+
647+
let mut sandbox = proto.load_runtime().unwrap();
648+
sandbox.add_handler("handler", handler).unwrap();
649+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
650+
651+
let res = loaded
652+
.handle_event(
653+
"handler",
654+
r#"{"a": 100, "b": 200, "weight": 0.75}"#.to_string(),
655+
None,
656+
)
657+
.unwrap();
658+
assert_eq!(res, r#"{"result":125}"#);
659+
}
660+
661+
#[test]
662+
fn host_fn_with_negative_integer() {
663+
let handler = Script::from_content(
664+
r#"
665+
import * as math from "math";
666+
function handler(event) {
667+
return { result: math.abs(event.x) };
668+
}
669+
"#,
670+
);
671+
672+
let mut proto = SandboxBuilder::new().build().unwrap();
673+
proto.register("math", "abs", |x: i32| x.abs()).unwrap();
674+
675+
let mut sandbox = proto.load_runtime().unwrap();
676+
sandbox.add_handler("handler", handler).unwrap();
677+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
678+
679+
let res = loaded
680+
.handle_event("handler", r#"{"x": -42}"#.to_string(), None)
681+
.unwrap();
682+
assert_eq!(res, r#"{"result":42}"#);
683+
}
684+
685+
#[test]
686+
fn host_fn_with_zero() {
687+
let handler = Script::from_content(
688+
r#"
689+
import * as math from "math";
690+
function handler(event) {
691+
return { result: math.inc(event.x) };
692+
}
693+
"#,
694+
);
695+
696+
let mut proto = SandboxBuilder::new().build().unwrap();
697+
proto.register("math", "inc", |x: i32| x + 1).unwrap();
698+
699+
let mut sandbox = proto.load_runtime().unwrap();
700+
sandbox.add_handler("handler", handler).unwrap();
701+
let mut loaded = sandbox.get_loaded_sandbox().unwrap();
702+
703+
let res = loaded
704+
.handle_event("handler", r#"{"x": 0}"#.to_string(), None)
705+
.unwrap();
706+
assert_eq!(res, r#"{"result":1}"#);
707+
}

0 commit comments

Comments
 (0)