Skip to content

Commit ac53a33

Browse files
committed
feat: add guest-call-stats feature with ExecutionStats API
Add per-call execution statistics (wall clock, CPU time, termination info) accessible via LoadedJSSandbox::last_call_stats() behind the guest-call-stats feature flag. Rust API: - New ExecutionStats struct with wall_clock, cpu_time, terminated_by fields - last_call_stats() getter on LoadedJSSandbox (feature-gated) - Stats captured on every handle_event/handle_event_with_monitor call - Stats available even when calls error (monitor termination, guest abort) - MonitorSet::to_race() now returns winner name for stats integration Node.js (NAPI) API: - CallStats object with wallClockMs, cpuTimeMs, terminatedBy properties - lastCallStats getter on LoadedJSSandbox wrapper - guest-call-stats enabled by default in js-host-api Includes: - execution_stats example (Rust) with 6 test scenarios - 10 integration tests covering all stats paths - Updated cpu-timeout.js example with printCallStats helper - Documentation updates (execution-monitors.md, READMEs) - Justfile: test-monitors includes guest-call-stats, new example recipe
1 parent 754139c commit ac53a33

16 files changed

Lines changed: 913 additions & 24 deletions

File tree

Justfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export HYPERLIGHT_CFLAGS := \
1818
export CMAKE_GENERATOR := if os() == "windows" { "Ninja" } else { "" }
1919

2020
ensure-tools:
21-
cargo install cargo-hyperlight --locked --version 0.1.3
21+
cargo install cargo-hyperlight --locked --version 0.1.7
2222

2323
# Check if npm is installed, install automatically if missing (Linux)
2424
[private]
@@ -127,6 +127,7 @@ run-examples target=default-target features="": (build target)
127127
cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example run_handler regex
128128
cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --example metrics
129129
cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example metrics
130+
cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features guest-call-stats,monitor-wall-clock,monitor-cpu-time'} else if features=="no-default-features" {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time" } else {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time," + features } }} --example execution_stats
130131

131132
run-examples-tracing target=default-target features="": (build target)
132133
cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features function_call_metrics'} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example tracing fmt
@@ -152,10 +153,10 @@ test target=default-target features="": (build target)
152153
cd src/hyperlight-js && cargo test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} test_metrics --profile={{ if target == "debug" {"dev"} else { target } }} -- --ignored --nocapture
153154
cargo test --manifest-path=./src/hyperlight-js-runtime/Cargo.toml --test=native_cli --profile={{ if target == "debug" {"dev"} else { target } }}
154155

155-
# Test with monitor features enabled (wall-clock and CPU time monitors)
156+
# Test with monitor features enabled (wall-clock, CPU time, and guest-call-stats)
156157
# Note: We exclude test_metrics as it requires process isolation and is already run by `test` recipe
157158
test-monitors target=default-target:
158-
cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics
159+
cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time,guest-call-stats --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics
159160

160161
test-js-host-api target=default-target features="": (build-js-host-api target features)
161162
cd src/js-host-api && npm test

docs/execution-monitors.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,54 @@ hyperlight-js = { version = "0.17", features = ["monitor-wall-clock", "monitor-c
112112
|---------|--------------|-------------|
113113
| `monitor-wall-clock` | (none) | Wall-clock time monitor |
114114
| `monitor-cpu-time` | `libc` (Linux), `windows-sys` (Windows) | CPU time monitor with OS-native APIs |
115+
| `guest-call-stats` | (none) | Execution statistics (`last_call_stats()`) — see [Execution Statistics](#execution-statistics-) below |
116+
117+
## Execution Statistics 📊
118+
119+
When the `guest-call-stats` feature is enabled, `LoadedJSSandbox` records timing
120+
and termination information after every `handle_event` / `handle_event_with_monitor`
121+
call, accessible via `last_call_stats()`.
122+
123+
```rust
124+
let _ = loaded.handle_event("handler", "{}".to_string(), None)?;
125+
if let Some(stats) = loaded.last_call_stats() {
126+
println!("Wall clock: {:?}", stats.wall_clock);
127+
println!("CPU time: {:?}", stats.cpu_time); // Some when monitor-cpu-time is also enabled
128+
println!("Terminated: {:?}", stats.terminated_by); // Some("wall-clock") or Some("cpu-time") when a monitor fired
129+
}
130+
```
131+
132+
### `ExecutionStats` fields
133+
134+
| Field | Type | Description |
135+
|-------|------|-------------|
136+
| `wall_clock` | `Duration` | Wall-clock elapsed time of the guest call (always available) |
137+
| `cpu_time` | `Option<Duration>` | CPU time consumed by the calling thread during the guest call. `Some` only when `monitor-cpu-time` is also enabled |
138+
| `terminated_by` | `Option<&'static str>` | Name of the monitor that terminated execution (e.g. `"wall-clock"`, `"cpu-time"`), or `None` if the call completed normally |
139+
140+
### Key behaviours
141+
142+
- **Stats are per-call** — each call replaces the previous stats (not cumulative)
143+
- **Stats are captured even on error** — if the guest is killed by a monitor, timing is still recorded
144+
- **`None` before any call**`last_call_stats()` returns `None` until the first `handle_event` or `handle_event_with_monitor` call
145+
- **Feature-gated** — the entire API (struct, field, getter) disappears when `guest-call-stats` is not enabled
146+
147+
### Node.js (NAPI) usage
148+
149+
In the Node.js bindings, stats are always available (the feature is enabled by default in `js-host-api`):
150+
151+
```javascript
152+
await loaded.callHandler('handler', { data: 'value' });
153+
154+
const stats = loaded.lastCallStats;
155+
if (stats) {
156+
console.log(`Wall clock: ${stats.wallClockMs}ms`);
157+
console.log(`CPU time: ${stats.cpuTimeMs}ms`); // null if monitor-cpu-time not enabled
158+
console.log(`Terminated by: ${stats.terminatedBy}`); // null for normal completion
159+
}
160+
```
161+
162+
See the [JS Host API README](../src/js-host-api/README.md) for full API details.
115163

116164
## Environment Variables
117165

@@ -258,5 +306,6 @@ if result.is_err() && loaded_sandbox.poisoned() {
258306
## See Also
259307

260308
- [Examples README](../src/js-host-api/examples/README.md) - interrupt.js and cpu-timeout.js examples
261-
- [JS Host API README](../src/js-host-api/README.md) - Node.js bindings with `callHandler()`
309+
- [Rust execution_stats example](../src/hyperlight-js/examples/execution_stats/main.rs) - Demonstrates `last_call_stats()` API
310+
- [JS Host API README](../src/js-host-api/README.md) - Node.js bindings with `callHandler()` and `lastCallStats`
262311
- [Observability](./observability.md) - Metrics including `monitor_terminations_total`

src/hyperlight-js/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ kvm = ["hyperlight-host/kvm"]
7575
mshv3 = ["hyperlight-host/mshv3"]
7676
print_debug = ["hyperlight-host/print_debug"]
7777
trace_guest = ["hyperlight-host/trace_guest"]
78+
guest-call-stats = []
7879
monitor-wall-clock = []
7980
monitor-cpu-time = ["dep:libc", "dep:windows-sys"]
8081

@@ -111,6 +112,12 @@ name = "runtime_debugging"
111112
path = "examples/runtime_debugging/main.rs"
112113
test = false
113114

115+
[[example]]
116+
name = "execution_stats"
117+
path = "examples/execution_stats/main.rs"
118+
required-features = ["guest-call-stats"]
119+
test = false
120+
114121
[[bench]]
115122
name = "benchmarks"
116123
harness = false
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//! Execution Stats Example: Demonstrates guest call statistics
2+
//!
3+
//! This example shows how to inspect timing and termination information
4+
//! after each guest function call using the `guest-call-stats` feature.
5+
//!
6+
//! Features demonstrated:
7+
//! 1. Wall-clock timing after a normal `handle_event` call
8+
//! 2. CPU time measurement (when `monitor-cpu-time` is enabled)
9+
//! 3. Stats with execution monitors — including which monitor fired
10+
//! 4. Stats update on every call (not cumulative)
11+
//!
12+
//! Run with:
13+
//! cargo run --example execution_stats --features guest-call-stats,monitor-wall-clock,monitor-cpu-time
14+
//!
15+
//! Or via Just:
16+
//! just run-examples
17+
18+
#![allow(clippy::disallowed_macros)]
19+
20+
use std::time::Duration;
21+
22+
use anyhow::Result;
23+
use hyperlight_js::{SandboxBuilder, Script};
24+
25+
fn main() -> Result<()> {
26+
println!("📊 Execution Stats Example: Guest Call Statistics\n");
27+
28+
// ── Setup ────────────────────────────────────────────────────────
29+
let proto = SandboxBuilder::new().build()?;
30+
let mut sandbox = proto.load_runtime()?;
31+
32+
// A fast handler that returns immediately
33+
let fast_handler = Script::from_content(
34+
r#"
35+
function handler(event) {
36+
event.message = "Hello from the guest!";
37+
return event;
38+
}
39+
"#,
40+
);
41+
42+
// A CPU-intensive handler that burns for a configurable duration
43+
let slow_handler = Script::from_content(
44+
r#"
45+
function handler(event) {
46+
const startTime = Date.now();
47+
const runtime = event.runtime || 200;
48+
let counter = 0;
49+
while (Date.now() - startTime < runtime) {
50+
counter++;
51+
}
52+
event.counter = counter;
53+
return event;
54+
}
55+
"#,
56+
);
57+
58+
// ── Test 1: Basic wall-clock timing ──────────────────────────────
59+
println!("📊 Test 1: Basic wall-clock timing (fast handler)");
60+
61+
sandbox.add_handler("fast", fast_handler)?;
62+
let mut loaded = sandbox.get_loaded_sandbox()?;
63+
64+
// Before any call, stats are None
65+
assert!(loaded.last_call_stats().is_none());
66+
println!(" Before call: stats = None ✅");
67+
68+
let result = loaded.handle_event("fast", r#"{"name": "World"}"#.to_string(), None)?;
69+
println!(" Result: {result}");
70+
71+
let stats = loaded
72+
.last_call_stats()
73+
.expect("Stats should be populated after a call");
74+
println!(" ⏱️ Wall clock: {:?}", stats.wall_clock);
75+
print_cpu_time(stats);
76+
println!(
77+
" 🏁 Terminated by: {}",
78+
stats.terminated_by.unwrap_or("(none — completed normally)")
79+
);
80+
81+
// ── Test 2: CPU-intensive handler ────────────────────────────────
82+
println!("\n📊 Test 2: CPU-intensive handler (200ms burn)");
83+
84+
let mut sandbox = loaded.unload()?;
85+
sandbox.clear_handlers();
86+
sandbox.add_handler("slow", slow_handler)?;
87+
let mut loaded = sandbox.get_loaded_sandbox()?;
88+
89+
let _ = loaded.handle_event("slow", r#"{"runtime": 200}"#.to_string(), None)?;
90+
91+
let stats = loaded.last_call_stats().unwrap();
92+
println!(" ⏱️ Wall clock: {:?}", stats.wall_clock);
93+
print_cpu_time(stats);
94+
println!(" 🏁 Terminated by: (none — completed normally)");
95+
96+
// ── Test 3: Stats update on each call ────────────────────────────
97+
println!("\n📊 Test 3: Stats update on each call (50ms then 150ms)");
98+
99+
let _ = loaded.handle_event("slow", r#"{"runtime": 50}"#.to_string(), None)?;
100+
let stats1 = loaded.last_call_stats().unwrap().clone();
101+
println!(" Call 1 wall clock: {:?}", stats1.wall_clock);
102+
103+
let _ = loaded.handle_event("slow", r#"{"runtime": 150}"#.to_string(), None)?;
104+
let stats2 = loaded.last_call_stats().unwrap().clone();
105+
println!(" Call 2 wall clock: {:?}", stats2.wall_clock);
106+
println!(
107+
" Stats replaced (not cumulative): call2 > call1 = {} ✅",
108+
stats2.wall_clock > stats1.wall_clock
109+
);
110+
111+
// ── Test 4: With monitors — successful completion ────────────────
112+
#[cfg(feature = "monitor-wall-clock")]
113+
{
114+
use hyperlight_js::WallClockMonitor;
115+
116+
println!("\n📊 Test 4: Monitored call — completes within limit");
117+
118+
let monitor = WallClockMonitor::new(Duration::from_secs(5))?;
119+
let _ = loaded.handle_event_with_monitor(
120+
"slow",
121+
r#"{"runtime": 50}"#.to_string(),
122+
&monitor,
123+
None,
124+
)?;
125+
126+
let stats = loaded.last_call_stats().unwrap();
127+
println!(" ⏱️ Wall clock: {:?}", stats.wall_clock);
128+
print_cpu_time(stats);
129+
println!(
130+
" 🏁 Terminated by: {} ✅",
131+
stats.terminated_by.unwrap_or("(none — completed normally)")
132+
);
133+
}
134+
135+
// ── Test 5: With monitors — timeout fires ────────────────────────
136+
#[cfg(feature = "monitor-wall-clock")]
137+
{
138+
use hyperlight_js::WallClockMonitor;
139+
140+
println!("\n📊 Test 5: Monitored call — wall-clock timeout fires");
141+
142+
let snapshot = loaded.snapshot()?;
143+
let monitor = WallClockMonitor::new(Duration::from_millis(50))?;
144+
let result = loaded.handle_event_with_monitor(
145+
"slow",
146+
r#"{"runtime": 5000}"#.to_string(),
147+
&monitor,
148+
None,
149+
);
150+
151+
match result {
152+
Ok(_) => println!(" ❌ Unexpected: handler completed"),
153+
Err(_) => {
154+
let stats = loaded.last_call_stats().unwrap();
155+
println!(" ⏱️ Wall clock: {:?}", stats.wall_clock);
156+
print_cpu_time(stats);
157+
println!(
158+
" 💀 Terminated by: {} ✅",
159+
stats.terminated_by.unwrap_or("(unknown)")
160+
);
161+
println!(" 🔒 Poisoned: {}", loaded.poisoned());
162+
}
163+
}
164+
165+
// Recover from poisoned state
166+
loaded.restore(snapshot.clone())?;
167+
println!(
168+
" 📸 Restored from snapshot — poisoned: {}",
169+
loaded.poisoned()
170+
);
171+
}
172+
173+
// ── Test 6: Combined monitors — CPU monitor wins ─────────────────
174+
#[cfg(all(feature = "monitor-wall-clock", feature = "monitor-cpu-time"))]
175+
{
176+
use hyperlight_js::{CpuTimeMonitor, WallClockMonitor};
177+
178+
println!("\n📊 Test 6: Combined monitors — CPU monitor fires first");
179+
180+
let snapshot = loaded.snapshot()?;
181+
182+
// CPU limit is tight (30ms), wall-clock is generous (5s)
183+
let monitor = (
184+
WallClockMonitor::new(Duration::from_secs(5))?,
185+
CpuTimeMonitor::new(Duration::from_millis(30))?,
186+
);
187+
let result = loaded.handle_event_with_monitor(
188+
"slow",
189+
r#"{"runtime": 5000}"#.to_string(),
190+
&monitor,
191+
None,
192+
);
193+
194+
match result {
195+
Ok(_) => println!(" ❌ Unexpected: handler completed"),
196+
Err(_) => {
197+
let stats = loaded.last_call_stats().unwrap();
198+
println!(" ⏱️ Wall clock: {:?}", stats.wall_clock);
199+
print_cpu_time(stats);
200+
println!(
201+
" 💀 Terminated by: {} ✅",
202+
stats.terminated_by.unwrap_or("(unknown)")
203+
);
204+
}
205+
}
206+
207+
loaded.restore(snapshot.clone())?;
208+
println!(" 📸 Restored from snapshot");
209+
}
210+
211+
println!("\n🎉 Execution stats example complete!");
212+
println!("\n💡 Key Points:");
213+
println!(" - last_call_stats() returns None before any call");
214+
println!(" - Stats are replaced (not accumulated) on each call");
215+
println!(" - wall_clock is always available");
216+
println!(" - cpu_time requires the monitor-cpu-time feature");
217+
println!(" - terminated_by shows which monitor fired (or None for normal completion)");
218+
println!(" - Stats are captured even when the call returns Err");
219+
220+
Ok(())
221+
}
222+
223+
/// Helper to print CPU time, handling the feature-gated `Option`.
224+
fn print_cpu_time(stats: &hyperlight_js::ExecutionStats) {
225+
match stats.cpu_time {
226+
Some(cpu) => println!(" 🖥️ CPU time: {:?}", cpu),
227+
None => println!(" 🖥️ CPU time: (not available — enable monitor-cpu-time feature)"),
228+
}
229+
}

src/hyperlight-js/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ pub use hyperlight_host::sandbox::snapshot::Snapshot;
6464
pub use hyperlight_host::sandbox::SandboxConfiguration;
6565
/// Module resolution and loading functionality.
6666
pub use resolver::{FileMetadata, FileSystem, FileSystemEmbedded, ResolveError};
67+
/// Execution statistics from the most recent guest function call.
68+
#[cfg(feature = "guest-call-stats")]
69+
pub use sandbox::execution_stats::ExecutionStats;
6770
/// The monitor module — re-exports `sleep` so custom monitors don't couple to tokio directly.
6871
pub use sandbox::monitor;
6972
/// CPU time based execution monitor.

0 commit comments

Comments
 (0)