Skip to main content

agentos_v8_runtime/
timeout.rs

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