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}