Skip to content

Commit a892a0a

Browse files
authored
feat: add guest-call-stats feature with ExecutionStats API (#33)
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 Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 764646b commit a892a0a

File tree

17 files changed

+974
-23
lines changed

17 files changed

+974
-23
lines changed

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Justfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ run-examples target=default-target features="": (build target)
126126
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
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 function_call_metrics," + features } }} --example metrics
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 " + features } }} --example metrics
129+
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
129130

130131
run-examples-tracing target=default-target features="": (build target)
131132
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
@@ -151,10 +152,10 @@ test target=default-target features="": (build target)
151152
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
152153
cargo test --manifest-path=./src/hyperlight-js-runtime/Cargo.toml --test=native_cli --profile={{ if target == "debug" {"dev"} else { target } }}
153154

154-
# Test with monitor features enabled (wall-clock and CPU time monitors)
155+
# Test with monitor features enabled (wall-clock, CPU time, and guest-call-stats)
155156
# Note: We exclude test_metrics as it requires process isolation and is already run by `test` recipe
156157
test-monitors target=default-target:
157-
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
158+
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
158159

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

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)