Skip to main content

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}