agentos_v8_runtime/timeout.rs
1// Execution budget enforcement via dedicated watchdog threads.
2//
3// Two INDEPENDENT mechanisms live here:
4//
5// * `TimeoutGuard` — a WALL-CLOCK timer. It counts elapsed real time
6// INCLUDING idle/await, so it can cap a guest that blocks or awaits
7// indefinitely. It is an INDEPENDENT, opt-in backstop armed only when the
8// operator sets `limits.jsRuntime.wallClockLimitMs` (off by default so
9// long-lived ACP adapters are never killed by a default).
10//
11// * `CpuBudgetGuard` — a TRUE CPU-TIME budget. It samples the EXECUTION
12// thread's per-thread CPU clock (`pthread_getcpuclockid` +
13// `clock_gettime`). Because a thread's CPU clock does not advance while the
14// thread is parked/awaiting I/O, this counts ONLY active JS CPU time and
15// EXCLUDES idle/await. V8 has no native budget primitive, so this poll +
16// `terminate_execution()` approach is the standard embedder pattern. Armed
17// when the caller passes a nonzero `limits.jsRuntime.cpuTimeLimitMs`.
18// secure-exec sidecar VM executions supply a bounded default; lower-level
19// embedders may pass `None`/`0` to leave the guard disabled.
20//
21// The two guards are independent: setting one typed limit arms only that guard,
22// and when both are set whichever fires first terminates execution.
23
24use agentos_bridge::queue_tracker::{register_limit, TrackedLimit};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27use std::thread;
28use std::time::Duration;
29
30pub(crate) const TIMEOUT_GUARD_START_ERROR_CODE: &str = "ERR_TIMEOUT_GUARD_START";
31#[cfg_attr(test, allow(dead_code))]
32pub(crate) const CPU_BUDGET_GUARD_START_ERROR_CODE: &str = "ERR_CPU_BUDGET_GUARD_START";
33
34/// How often the CPU-budget watchdog samples the execution thread's CPU clock.
35#[cfg_attr(test, allow(dead_code))]
36const CPU_BUDGET_POLL_INTERVAL: Duration = Duration::from_millis(50);
37
38/// An opaque handle to a specific thread's CPU-time clock, captured ON that
39/// thread and safe to read from another (watchdog) thread.
40///
41/// The POSIX per-thread CPU clock id is derived from the thread's `pthread_t`
42/// and remains valid for the lifetime of that thread, so the watchdog can poll
43/// it via `clock_gettime` without running on the execution thread itself.
44///
45/// macOS has no `pthread_getcpuclockid`/per-thread POSIX clock, so it uses a
46/// separate Mach-based implementation below (`pthread_mach_thread_np` +
47/// `thread_info`) that exposes the same opaque `ThreadCpuClock` interface.
48#[cfg(all(unix, not(target_os = "macos")))]
49#[cfg_attr(test, allow(dead_code))]
50#[derive(Clone, Copy)]
51pub(crate) struct ThreadCpuClock {
52 clockid: libc::clockid_t,
53}
54
55/// Capture the CALLING thread's CPU-time clock. Must be invoked on the thread
56/// whose CPU time should be measured (i.e. the execution thread).
57///
58/// Returns `None` if the platform refuses to expose a per-thread CPU clock, in
59/// which case no CPU budget can be enforced.
60#[cfg(all(unix, not(target_os = "macos")))]
61#[cfg_attr(test, allow(dead_code))]
62pub(crate) fn current_thread_cpu_clock() -> Option<ThreadCpuClock> {
63 // SAFETY: `pthread_self` is always callable; `pthread_getcpuclockid` writes
64 // a valid clockid into `clockid` on success (return 0).
65 unsafe {
66 let mut clockid: libc::clockid_t = 0;
67 let rc = libc::pthread_getcpuclockid(libc::pthread_self(), &mut clockid);
68 if rc == 0 {
69 Some(ThreadCpuClock { clockid })
70 } else {
71 None
72 }
73 }
74}
75
76#[cfg(all(unix, not(target_os = "macos")))]
77impl ThreadCpuClock {
78 /// Read accumulated CPU time for the captured thread, in milliseconds.
79 /// Returns `None` if the clock read fails.
80 #[cfg_attr(test, allow(dead_code))]
81 fn elapsed_ms(self) -> Option<u64> {
82 // SAFETY: `clockid` came from a successful `pthread_getcpuclockid`; the
83 // timespec is fully written by `clock_gettime` on success.
84 unsafe {
85 let mut ts: libc::timespec = std::mem::zeroed();
86 if libc::clock_gettime(self.clockid, &mut ts) == 0 {
87 let ms = (ts.tv_sec as i128) * 1_000 + (ts.tv_nsec as i128) / 1_000_000;
88 Some(ms.max(0) as u64)
89 } else {
90 None
91 }
92 }
93 }
94}
95
96/// macOS per-thread CPU clock. There is no `pthread_getcpuclockid` on Apple
97/// platforms, so the thread's CPU time is read through the Mach
98/// `thread_info(THREAD_BASIC_INFO)` call. The Mach thread port obtained via
99/// `pthread_mach_thread_np` stays valid for the thread's lifetime and may be
100/// inspected from another (watchdog) thread, matching the opaque-handle
101/// contract above.
102#[cfg(target_os = "macos")]
103#[cfg_attr(test, allow(dead_code))]
104#[derive(Clone, Copy)]
105pub(crate) struct ThreadCpuClock {
106 port: libc::mach_port_t,
107}
108
109#[cfg(target_os = "macos")]
110#[cfg_attr(test, allow(dead_code))]
111pub(crate) fn current_thread_cpu_clock() -> Option<ThreadCpuClock> {
112 // SAFETY: `pthread_mach_thread_np` returns the Mach thread port for the
113 // calling pthread; the port is valid for the thread's lifetime.
114 let port = unsafe { libc::pthread_mach_thread_np(libc::pthread_self()) };
115 // MACH_PORT_NULL is 0.
116 if port == 0 {
117 None
118 } else {
119 Some(ThreadCpuClock { port })
120 }
121}
122
123#[cfg(target_os = "macos")]
124impl ThreadCpuClock {
125 /// Read accumulated CPU time (user + system) for the captured thread, in
126 /// milliseconds. Returns `None` if the Mach query fails.
127 #[cfg_attr(test, allow(dead_code))]
128 fn elapsed_ms(self) -> Option<u64> {
129 // SAFETY: `thread_info` fully initialises `info` when it returns
130 // KERN_SUCCESS; the count is the documented THREAD_BASIC_INFO length.
131 unsafe {
132 let mut info = std::mem::MaybeUninit::<libc::thread_basic_info>::zeroed();
133 let mut count = (std::mem::size_of::<libc::thread_basic_info>()
134 / std::mem::size_of::<libc::integer_t>())
135 as libc::mach_msg_type_number_t;
136 let rc = libc::thread_info(
137 self.port,
138 libc::THREAD_BASIC_INFO as libc::thread_flavor_t,
139 info.as_mut_ptr() as libc::thread_info_t,
140 &mut count,
141 );
142 if rc != libc::KERN_SUCCESS {
143 return None;
144 }
145 let info = info.assume_init();
146 let ms = (info.user_time.seconds as i128 + info.system_time.seconds as i128) * 1_000
147 + (info.user_time.microseconds as i128 + info.system_time.microseconds as i128)
148 / 1_000;
149 Some(ms.max(0) as u64)
150 }
151 }
152}
153
154/// Guard for per-execution TRUE CPU-time budget enforcement.
155///
156/// Spawns a watchdog thread that polls the execution thread's CPU clock every
157/// [`CPU_BUDGET_POLL_INTERVAL`]. When accumulated active-JS CPU time exceeds the
158/// budget, it calls `v8::Isolate::terminate_execution()` and signals the
159/// execution abort with [`crate::session::ExecutionAbortReason::CpuBudgetExceeded`].
160/// A guest that mostly awaits/idles accrues little CPU time and is NOT killed.
161///
162/// Drop or call `cancel()` to stop the watchdog (execution completed normally).
163pub(crate) struct CpuBudgetGuard {
164 cancel_tx: Option<crossbeam_channel::Sender<()>>,
165 fired: Arc<AtomicBool>,
166 join_handle: Option<thread::JoinHandle<()>>,
167}
168
169#[cfg(unix)]
170impl CpuBudgetGuard {
171 /// Spawn the CPU-budget watchdog.
172 ///
173 /// - `budget_ms`: TRUE CPU-time budget in milliseconds (active JS only)
174 /// - `cpu_clock`: the execution thread's CPU clock (captured on that thread)
175 /// - `isolate_handle`: V8 isolate handle for `terminate_execution()`
176 /// - `execution_abort`: signalled with `CpuBudgetExceeded` when the budget is exhausted
177 #[cfg_attr(test, allow(dead_code))]
178 pub(crate) fn new(
179 budget_ms: u32,
180 cpu_clock: ThreadCpuClock,
181 isolate_handle: v8::IsolateHandle,
182 execution_abort: crate::session::SharedExecutionAbort,
183 ) -> Result<Self, String> {
184 let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
185 let fired = Arc::new(AtomicBool::new(false));
186 let fired_clone = Arc::clone(&fired);
187
188 // Snapshot the thread's CPU time at arm so the budget measures CPU
189 // consumed DURING this execution, not cumulative thread lifetime.
190 let baseline_ms = cpu_clock.elapsed_ms().unwrap_or(0);
191 let budget_ms = budget_ms as u64;
192
193 // Sample CPU consumption into a registry gauge every poll so the operator
194 // gets an edge-triggered ~80% approach warning BEFORE the budget is
195 // exhausted and the isolate is terminated (the terminal edge is reported
196 // separately by `warn_limit_exhausted`).
197 let cpu_gauge = register_limit(TrackedLimit::V8CpuTimeMs, budget_ms as usize);
198
199 let handle = thread::Builder::new()
200 .name("cpu-budget".into())
201 .spawn(move || {
202 let ticker = crossbeam_channel::tick(CPU_BUDGET_POLL_INTERVAL);
203 loop {
204 crossbeam_channel::select! {
205 recv(cancel_rx) -> _ => {
206 // Cancelled — execution completed normally.
207 return;
208 }
209 recv(ticker) -> _ => {
210 let used = cpu_clock
211 .elapsed_ms()
212 .unwrap_or(baseline_ms)
213 .saturating_sub(baseline_ms);
214 cpu_gauge.observe_depth(used as usize);
215 if used >= budget_ms {
216 fired_clone.store(true, Ordering::SeqCst);
217 isolate_handle.terminate_execution();
218 crate::session::signal_execution_abort(
219 &execution_abort,
220 crate::session::ExecutionAbortReason::CpuBudgetExceeded,
221 );
222 return;
223 }
224 }
225 }
226 }
227 })
228 .map_err(|error| {
229 format!(
230 "{CPU_BUDGET_GUARD_START_ERROR_CODE}: failed to spawn cpu-budget thread: {error}"
231 )
232 })?;
233
234 Ok(CpuBudgetGuard {
235 cancel_tx: Some(cancel_tx),
236 fired,
237 join_handle: Some(handle),
238 })
239 }
240
241 /// Cancel the watchdog (execution completed normally). Blocks until the
242 /// watchdog thread exits.
243 pub(crate) fn cancel(&mut self) {
244 self.cancel_tx.take();
245 if let Some(h) = self.join_handle.take() {
246 let _ = h.join();
247 }
248 }
249
250 /// Check whether the CPU budget was exhausted.
251 #[cfg_attr(test, allow(dead_code))]
252 pub(crate) fn exceeded(&self) -> bool {
253 self.fired.load(Ordering::SeqCst)
254 }
255}
256
257#[cfg(unix)]
258impl Drop for CpuBudgetGuard {
259 fn drop(&mut self) {
260 self.cancel();
261 }
262}
263
264// Non-unix fallback: there is no portable per-thread CPU clock, so the
265// CPU-budget watchdog cannot be enforced. `current_thread_cpu_clock` returns
266// `None`, which makes the session surface a clear "cannot enforce" error if a
267// CPU budget is requested, rather than silently running uncapped.
268#[cfg(not(unix))]
269#[derive(Clone, Copy)]
270pub(crate) struct ThreadCpuClock;
271
272#[cfg(not(unix))]
273pub(crate) fn current_thread_cpu_clock() -> Option<ThreadCpuClock> {
274 None
275}
276
277#[cfg(not(unix))]
278impl CpuBudgetGuard {
279 pub(crate) fn new(
280 _budget_ms: u32,
281 _cpu_clock: ThreadCpuClock,
282 _isolate_handle: v8::IsolateHandle,
283 _execution_abort: crate::session::SharedExecutionAbort,
284 ) -> Result<Self, String> {
285 Err(format!(
286 "{CPU_BUDGET_GUARD_START_ERROR_CODE}: per-thread CPU clock not supported on this platform"
287 ))
288 }
289
290 pub(crate) fn cancel(&mut self) {}
291
292 #[cfg_attr(test, allow(dead_code))]
293 pub(crate) fn exceeded(&self) -> bool {
294 self.fired.load(Ordering::SeqCst)
295 }
296}
297
298/// Guard for per-session CPU timeout enforcement.
299///
300/// Spawns a timer thread that calls `v8::Isolate::terminate_execution()`
301/// and closes the active execution abort channel to unblock any channel-based
302/// readers when the timeout elapses. Drop or call `cancel()` to prevent firing.
303pub struct TimeoutGuard {
304 /// Sender side of cancellation channel — dropped to cancel the timer
305 cancel_tx: Option<crossbeam_channel::Sender<()>>,
306 /// Set to true when the timeout fired
307 fired: Arc<AtomicBool>,
308 /// Timer thread handle
309 join_handle: Option<thread::JoinHandle<()>>,
310}
311
312impl TimeoutGuard {
313 /// Spawn a timeout timer thread.
314 ///
315 /// - `timeout_ms`: wall-clock time limit in milliseconds
316 /// - `isolate_handle`: V8 isolate handle for `terminate_execution()`
317 /// - `abort_tx`: dropped on timeout to unblock channel readers via `select!`
318 pub(crate) fn new(
319 timeout_ms: u32,
320 isolate_handle: v8::IsolateHandle,
321 abort_tx: crossbeam_channel::Sender<()>,
322 ) -> Result<Self, String> {
323 Self::spawn(timeout_ms, isolate_handle, move || {
324 drop(abort_tx);
325 })
326 }
327
328 /// Spawn a wall-clock backstop that signals the execution abort with
329 /// [`crate::session::ExecutionAbortReason::WallClockTimedOut`] when the limit
330 /// elapses. Unlike the CPU budget, this counts elapsed real time INCLUDING
331 /// idle/await. Armed only when the operator opts in via
332 /// `limits.jsRuntime.wallClockLimitMs`.
333 #[cfg_attr(test, allow(dead_code))]
334 pub(crate) fn with_execution_abort(
335 timeout_ms: u32,
336 isolate_handle: v8::IsolateHandle,
337 execution_abort: crate::session::SharedExecutionAbort,
338 ) -> Result<Self, String> {
339 Self::spawn(timeout_ms, isolate_handle, move || {
340 crate::session::signal_execution_abort(
341 &execution_abort,
342 crate::session::ExecutionAbortReason::WallClockTimedOut,
343 );
344 })
345 }
346
347 fn spawn(
348 timeout_ms: u32,
349 isolate_handle: v8::IsolateHandle,
350 on_timeout: impl FnOnce() + Send + 'static,
351 ) -> Result<Self, String> {
352 let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
353 let fired = Arc::new(AtomicBool::new(false));
354 let fired_clone = Arc::clone(&fired);
355
356 // Emit an edge-triggered ~80% approach warning before the wall-clock
357 // budget is exhausted and the isolate is terminated. Observing the gauge
358 // once at the threshold reuses the registry's warn + host-forward path.
359 let wall_gauge = register_limit(TrackedLimit::V8WallClockMs, timeout_ms as usize);
360 let warn_at_ms =
361 timeout_ms as u64 * agentos_bridge::queue_tracker::WARN_FILL_PERCENT as u64 / 100;
362
363 let handle = thread::Builder::new()
364 .name("timeout".into())
365 .spawn(move || {
366 let warn_timer = crossbeam_channel::after(Duration::from_millis(warn_at_ms));
367 let timer = crossbeam_channel::after(Duration::from_millis(timeout_ms as u64));
368
369 loop {
370 crossbeam_channel::select! {
371 recv(warn_timer) -> _ => {
372 // Crossed the approach threshold — warn once. The full
373 // timer (and cancel) are still pending; `after` fires
374 // only once, so the next select waits on those.
375 wall_gauge.observe_depth(warn_at_ms as usize);
376 }
377 recv(timer) -> _ => {
378 // Timeout elapsed — terminate V8 execution
379 fired_clone.store(true, Ordering::SeqCst);
380 isolate_handle.terminate_execution();
381 on_timeout();
382 return;
383 }
384 recv(cancel_rx) -> _ => {
385 // Cancelled — execution completed normally
386 return;
387 }
388 }
389 }
390 })
391 .map_err(|error| {
392 format!("{TIMEOUT_GUARD_START_ERROR_CODE}: failed to spawn timeout thread: {error}")
393 })?;
394
395 Ok(TimeoutGuard {
396 cancel_tx: Some(cancel_tx),
397 fired,
398 join_handle: Some(handle),
399 })
400 }
401
402 /// Cancel the timeout (execution completed normally).
403 /// Blocks until the timer thread exits.
404 pub fn cancel(&mut self) {
405 // Drop the cancel sender to unblock the timer thread's select!
406 self.cancel_tx.take();
407 if let Some(h) = self.join_handle.take() {
408 let _ = h.join();
409 }
410 }
411
412 /// Check if the timeout fired.
413 pub fn timed_out(&self) -> bool {
414 self.fired.load(Ordering::SeqCst)
415 }
416}
417
418impl Drop for TimeoutGuard {
419 fn drop(&mut self) {
420 self.cancel();
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 #[test]
427 fn timeout_guard_cancel_before_fire() {
428 // Timer set to 5 seconds, cancelled immediately — should not fire
429 let (abort_tx, abort_rx) = crossbeam_channel::bounded::<()>(0);
430
431 // Create a minimal V8 platform + isolate just for the handle
432 // We avoid actual V8 in tests — use a different approach
433 // Instead, test the cancellation logic without V8
434
435 // We can't easily get a v8::IsolateHandle without V8 init,
436 // so we test the TimeoutGuard flow via integration in execution::tests
437 drop(abort_tx);
438 drop(abort_rx);
439 }
440
441 #[test]
442 fn timeout_guard_fires_on_expiry() {
443 // Tested via V8 integration tests in execution::tests
444 // This placeholder confirms the module compiles correctly
445 }
446}