Skip to main content

agentos_v8_runtime/
isolate.rs

1// V8 isolate lifecycle: platform init, create, configure, destroy
2
3use std::collections::HashMap;
4use std::ffi::c_void;
5use std::sync::{Mutex, Once};
6
7use crate::ipc::ExecutionError;
8use agentos_bridge::queue_tracker::{warn_limit_exhausted, TrackedLimit};
9
10static V8_INIT: Once = Once::new();
11static V8_ISOLATE_LIFECYCLE: Mutex<()> = Mutex::new(());
12const MAX_UNHANDLED_PROMISE_REJECTIONS: usize = 1024;
13
14#[repr(align(16))]
15struct AlignedBytes<const N: usize>([u8; N]);
16
17static ICU_COMMON_DATA: AlignedBytes<
18    { include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")).len() },
19> = AlignedBytes(*include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")));
20
21#[derive(Default)]
22pub struct PromiseRejectState {
23    pub unhandled: HashMap<i32, ExecutionError>,
24    overflow_count: usize,
25}
26
27impl PromiseRejectState {
28    fn record_unhandled(&mut self, promise_id: i32, error: ExecutionError) {
29        use std::collections::hash_map::Entry;
30        // Cache the length before taking the entry, since `Entry` borrows the
31        // map mutably and we cannot read `len()` while it is held.
32        let under_limit = self.unhandled.len() < MAX_UNHANDLED_PROMISE_REJECTIONS;
33        match self.unhandled.entry(promise_id) {
34            // Existing rejection for this promise — overwrite with latest error.
35            Entry::Occupied(mut entry) => {
36                entry.insert(error);
37            }
38            // New rejection: store it if under the cap, otherwise count overflow.
39            Entry::Vacant(entry) => {
40                if under_limit {
41                    entry.insert(error);
42                } else {
43                    self.overflow_count = self.overflow_count.saturating_add(1);
44                }
45            }
46        }
47    }
48
49    fn mark_handled(&mut self, promise_id: i32) {
50        if self.unhandled.remove(&promise_id).is_none() && self.overflow_count > 0 {
51            self.overflow_count -= 1;
52        }
53    }
54
55    pub fn take_next_unhandled(&mut self) -> Option<ExecutionError> {
56        if self.overflow_count > 0 {
57            self.overflow_count = 0;
58            self.unhandled.clear();
59            return Some(ExecutionError {
60                error_type: "Error".into(),
61                message: format!(
62                    "unhandled promise rejection registry exceeded limit of {MAX_UNHANDLED_PROMISE_REJECTIONS} rejections"
63                ),
64                stack: String::new(),
65                code: Some("ERR_AGENTOS_UNHANDLED_REJECTION_LIMIT".into()),
66            });
67        }
68        self.unhandled.drain().next().map(|(_, err)| err)
69    }
70}
71
72extern "C" fn promise_reject_callback(msg: v8::PromiseRejectMessage) {
73    let scope = &mut unsafe { v8::CallbackScope::new(&msg) };
74    let promise_id = msg.get_promise().get_identity_hash().get();
75    match msg.get_event() {
76        v8::PromiseRejectEvent::PromiseRejectWithNoHandler => {
77            let error = {
78                let scope = &mut v8::HandleScope::new(scope);
79                let value = msg
80                    .get_value()
81                    .unwrap_or_else(|| v8::undefined(scope).into());
82                crate::execution::extract_error_info(scope, value)
83            };
84            if let Some(state) = scope.get_slot_mut::<PromiseRejectState>() {
85                state.record_unhandled(promise_id, error);
86            }
87        }
88        v8::PromiseRejectEvent::PromiseHandlerAddedAfterReject => {
89            if let Some(state) = scope.get_slot_mut::<PromiseRejectState>() {
90                state.mark_handled(promise_id);
91            }
92        }
93        _ => {}
94    }
95}
96
97pub fn configure_isolate(isolate: &mut v8::OwnedIsolate) {
98    isolate.set_slot(PromiseRejectState::default());
99    isolate.set_promise_reject_callback(promise_reject_callback);
100}
101
102/// Initialize the V8 platform (once per process).
103/// Safe to call multiple times; only the first call takes effect.
104pub fn init_v8_platform() {
105    V8_INIT.call_once(|| {
106        v8::icu::set_common_data_74(&ICU_COMMON_DATA.0)
107            .expect("failed to initialize V8 ICU common data");
108        let platform = v8::new_default_platform(0, false).make_shared();
109        v8::V8::initialize_platform(platform);
110        v8::V8::initialize();
111    });
112}
113
114// Headroom granted to V8 when the near-heap-limit callback fires. V8 fatal-aborts
115// the whole process (SIGTRAP) if the callback does not raise the limit, so we must
116// hand back a larger limit to give the engine room to unwind. Termination has
117// already been requested, so this extra budget only covers propagation of the
118// uncatchable termination exception, not continued guest allocation.
119const NEAR_HEAP_LIMIT_HEADROOM_BYTES: usize = 16 * 1024 * 1024;
120
121/// Default per-isolate heap cap applied when the caller passes no explicit limit.
122///
123/// Resource limits must be bounded by default (never unbounded for memory): a
124/// guest with no configured `heap_limit_mb` must NOT be able to grow the heap until
125/// V8 fatal-aborts the process-global runtime and takes down every co-tenant
126/// isolate. 128 MiB matches the Cloudflare Workers per-isolate budget we mirror for
127/// isolation semantics; operators may raise it via the configured limit.
128pub const DEFAULT_HEAP_LIMIT_MB: u32 = 128;
129
130/// Invoked by V8 when heap usage approaches the configured limit. Instead of
131/// letting V8 fatal-abort the (process-global) runtime, request termination of the
132/// offending isolate and return a raised limit so V8 can propagate the uncatchable
133/// termination exception cleanly. `data` is a leaked `Box<v8::IsolateHandle>` for
134/// the isolate this callback was registered on.
135extern "C" fn near_heap_limit_callback(
136    data: *mut c_void,
137    current_heap_limit: usize,
138    initial_heap_limit: usize,
139) -> usize {
140    if !data.is_null() {
141        // Safety: `data` is the pointer produced by `Box::into_raw` in
142        // `install_heap_limit_guard` and lives for the entire lifetime of the
143        // isolate.
144        let handle = unsafe { &*(data as *const v8::IsolateHandle) };
145        // Terminate any JS currently running on this isolate. This unwinds the
146        // guest with an uncatchable exception rather than crashing the process.
147        handle.terminate_execution();
148    }
149    warn_limit_exhausted(
150        TrackedLimit::V8HeapBytes,
151        current_heap_limit,
152        initial_heap_limit.max(1),
153    );
154    // Grant headroom so V8 does not immediately fatal-abort before the termination
155    // takes effect. We never shrink below the current limit.
156    current_heap_limit
157        .max(initial_heap_limit)
158        .saturating_add(NEAR_HEAP_LIMIT_HEADROOM_BYTES)
159}
160
161/// Register the near-heap-limit OOM guard on an isolate that was created with a
162/// configured heap cap. Without this guard, V8 fatal-aborts the whole (process-
163/// global) runtime with a SIGTRAP when the cap is reached, taking down every
164/// concurrent tenant; with it, the offending isolate is terminated instead.
165///
166/// Must be called for every isolate created with a non-`None` heap limit,
167/// regardless of whether it was built fresh or restored from a snapshot.
168pub fn install_heap_limit_guard(isolate: &mut v8::OwnedIsolate) {
169    // The callback needs a thread-safe handle to request termination of this very
170    // isolate. The handle is leaked so it outlives the callback registration; the
171    // number of isolates per process is bounded, so this is not an unbounded leak,
172    // and the memory is reclaimed when the process exits.
173    let handle = Box::new(isolate.thread_safe_handle());
174    let data = Box::into_raw(handle) as *mut c_void;
175    isolate.add_near_heap_limit_callback(near_heap_limit_callback, data);
176}
177
178/// Create a new V8 isolate with an optional heap limit in MB. `None` applies the
179/// bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`) — an isolate is NEVER created
180/// with an unbounded heap, so a guest heap bomb terminates its own isolate rather
181/// than fatal-aborting the shared process.
182pub fn create_isolate(heap_limit_mb: Option<u32>) -> v8::OwnedIsolate {
183    let limit = heap_limit_mb.unwrap_or(DEFAULT_HEAP_LIMIT_MB);
184    let mut params = v8::CreateParams::default();
185    let limit_bytes = (limit as usize) * 1024 * 1024;
186    params = params.heap_limits(0, limit_bytes);
187    let mut isolate = with_isolate_lifecycle_lock(|| v8::Isolate::new(params));
188    configure_isolate(&mut isolate);
189    install_heap_limit_guard(&mut isolate);
190    isolate
191}
192
193/// Run V8 isolate create/drop work under a process-wide lifecycle lock.
194///
195/// rusty_v8 130.0.7 embeds a V8 13.0-era process-wide WebAssembly code pointer
196/// table. Isolate construction allocates wasm builtin handles from that table and
197/// isolate destruction frees them again, so create/drop must not overlap across
198/// session threads.
199pub fn with_isolate_lifecycle_lock<T>(f: impl FnOnce() -> T) -> T {
200    let _guard = V8_ISOLATE_LIFECYCLE
201        .lock()
202        .expect("V8 isolate lifecycle lock poisoned");
203    f()
204}
205
206pub fn drop_isolate(isolate: Option<v8::OwnedIsolate>) {
207    if let Some(isolate) = isolate {
208        with_isolate_lifecycle_lock(|| drop(isolate));
209    }
210}
211
212/// Create a new V8 context on the given isolate.
213/// Returns a Global handle so the context can be reused across scopes.
214pub fn create_context(isolate: &mut v8::OwnedIsolate) -> v8::Global<v8::Context> {
215    let scope = &mut v8::HandleScope::new(isolate);
216    let context = v8::Context::new(scope, Default::default());
217    v8::Global::new(scope, context)
218}
219
220// V8 lifecycle tests are consolidated in execution::tests to avoid
221// inter-test SIGSEGV from V8 global state issues.