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::atomic::{AtomicBool, Ordering};
6use std::sync::{mpsc, Arc, Mutex, Once};
7use std::thread;
8
9use crate::ipc::ExecutionError;
10use agentos_bridge::queue_tracker::{warn_limit_exhausted, TrackedLimit};
11
12static V8_INIT: Once = Once::new();
13static V8_ISOLATE_LIFECYCLE: Mutex<()> = Mutex::new(());
14const MAX_UNHANDLED_PROMISE_REJECTIONS: usize = 1024;
15
16unsafe extern "C" {
17    // rusty_v8 130 does not expose these public V8 embedder hooks in Rust. The
18    // build-script C++ shim is compiled against the pinned V8 headers so this
19    // boundary does not depend on platform-specific C++ mangled names.
20    fn agentos_v8_initialize_sandbox_hardware_before_thread_creation();
21    fn agentos_v8_set_default_thread_isolation_permissions();
22}
23
24#[repr(align(16))]
25struct AlignedBytes<const N: usize>([u8; N]);
26
27static ICU_COMMON_DATA: AlignedBytes<
28    { include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")).len() },
29> = AlignedBytes(*include_bytes!(concat!(env!("OUT_DIR"), "/icudtl.dat")));
30
31#[derive(Default)]
32pub struct PromiseRejectState {
33    pub unhandled: HashMap<i32, ExecutionError>,
34    overflow_count: usize,
35}
36
37impl PromiseRejectState {
38    fn record_unhandled(&mut self, promise_id: i32, error: ExecutionError) {
39        use std::collections::hash_map::Entry;
40        // Cache the length before taking the entry, since `Entry` borrows the
41        // map mutably and we cannot read `len()` while it is held.
42        let under_limit = self.unhandled.len() < MAX_UNHANDLED_PROMISE_REJECTIONS;
43        match self.unhandled.entry(promise_id) {
44            // Existing rejection for this promise — overwrite with latest error.
45            Entry::Occupied(mut entry) => {
46                entry.insert(error);
47            }
48            // New rejection: store it if under the cap, otherwise count overflow.
49            Entry::Vacant(entry) => {
50                if under_limit {
51                    entry.insert(error);
52                } else {
53                    self.overflow_count = self.overflow_count.saturating_add(1);
54                }
55            }
56        }
57    }
58
59    fn mark_handled(&mut self, promise_id: i32) {
60        if self.unhandled.remove(&promise_id).is_none() && self.overflow_count > 0 {
61            self.overflow_count -= 1;
62        }
63    }
64
65    pub fn take_next_unhandled(&mut self) -> Option<ExecutionError> {
66        if self.overflow_count > 0 {
67            self.overflow_count = 0;
68            self.unhandled.clear();
69            return Some(ExecutionError {
70                error_type: "Error".into(),
71                message: format!(
72                    "unhandled promise rejection registry exceeded limit of {MAX_UNHANDLED_PROMISE_REJECTIONS} rejections"
73                ),
74                stack: String::new(),
75                code: Some("ERR_AGENTOS_UNHANDLED_REJECTION_LIMIT".into()),
76            });
77        }
78        self.unhandled.drain().next().map(|(_, err)| err)
79    }
80}
81
82extern "C" fn promise_reject_callback(msg: v8::PromiseRejectMessage) {
83    let scope = &mut unsafe { v8::CallbackScope::new(&msg) };
84    let promise_id = msg.get_promise().get_identity_hash().get();
85    match msg.get_event() {
86        v8::PromiseRejectEvent::PromiseRejectWithNoHandler => {
87            let error = {
88                let scope = &mut v8::HandleScope::new(scope);
89                let value = msg
90                    .get_value()
91                    .unwrap_or_else(|| v8::undefined(scope).into());
92                crate::execution::extract_error_info(scope, value)
93            };
94            if let Some(state) = scope.get_slot_mut::<PromiseRejectState>() {
95                state.record_unhandled(promise_id, error);
96            }
97        }
98        v8::PromiseRejectEvent::PromiseHandlerAddedAfterReject => {
99            if let Some(state) = scope.get_slot_mut::<PromiseRejectState>() {
100                state.mark_handled(promise_id);
101            }
102        }
103        _ => {}
104    }
105}
106
107pub fn configure_isolate(isolate: &mut v8::OwnedIsolate) {
108    isolate.set_slot(PromiseRejectState::default());
109    isolate.set_promise_reject_callback(promise_reject_callback);
110}
111
112/// V8's process-global background worker pool is constant topology, not an
113/// implicit function of host CPU count. Four preserves useful parallelism for
114/// background compilation while keeping the trusted thread census bounded.
115const V8_PLATFORM_WORKER_THREADS: u32 = 4;
116
117/// Initialize the V8 platform (once per process).
118/// Safe to call multiple times; only the first call takes effect.
119pub fn init_v8_platform() {
120    V8_INIT.call_once(|| {
121        // V8 requires sandbox hardware keys to be allocated before any thread
122        // which may access its sandbox is created. This call precedes both the
123        // platform owner below and V8's own default-platform workers.
124        unsafe { agentos_v8_initialize_sandbox_hardware_before_thread_creation() };
125        let (ready_tx, ready_rx) = mpsc::sync_channel(1);
126        // V8 binds process-global isolate-group state to its initialization
127        // thread. A library caller can be a short-lived Rust test, request, or
128        // maintenance thread, so initializing inline leaves later executor
129        // threads using torn-down process-global WebAssembly tables. Keep one
130        // constant owner alive for the process lifetime instead.
131        // AGENTOS_THREAD_SITE: constant-v8-platform-owner
132        thread::Builder::new()
133            .name(String::from("agentos-v8-platform"))
134            .spawn(move || {
135                v8::icu::set_common_data_74(&ICU_COMMON_DATA.0)
136                    .expect("failed to initialize V8 ICU common data");
137                let platform =
138                    v8::new_default_platform(V8_PLATFORM_WORKER_THREADS, false).make_shared();
139                v8::V8::initialize_platform(platform);
140                v8::V8::initialize();
141                ready_tx
142                    .send(())
143                    .expect("V8 platform initializer lost its caller");
144
145                // V8 is intentionally process-global and is never disposed
146                // while the sidecar is alive. Parking preserves the thread-local
147                // isolate-group owner without consuming CPU or a Tokio worker.
148                loop {
149                    thread::park();
150                }
151            })
152            .expect("failed to spawn V8 platform owner");
153        ready_rx
154            .recv()
155            .expect("V8 platform owner exited during initialization");
156    });
157}
158
159/// Restore V8's read-only protection-key defaults on the current thread.
160///
161/// Executor and maintenance threads may descend from fixed host workers that
162/// existed before V8 allocated its pkeys. Linux preserves each parent's PKRU
163/// value across clone, so such descendants can otherwise fault merely reading
164/// V8's process-wide code-pointer tables. Call this after platform init and
165/// before the thread first enters V8.
166pub fn prepare_current_thread() {
167    init_v8_platform();
168    unsafe { agentos_v8_set_default_thread_isolation_permissions() };
169}
170
171// Headroom granted to V8 above the current heap size when the near-heap-limit
172// callback fires. V8 fatal-aborts the whole process (SIGTRAP) if the returned
173// limit is still below the heap size, so we must hand back a larger limit to give
174// the engine room to unwind. Termination has already been requested, so this
175// extra budget only covers propagation of the uncatchable termination exception,
176// not continued guest allocation.
177const NEAR_HEAP_LIMIT_HEADROOM_BYTES: usize = 16 * 1024 * 1024;
178
179/// State the near-heap-limit callback needs for one isolate.
180struct HeapLimitGuard {
181    handle: v8::IsolateHandle,
182    isolate: *mut v8::Isolate,
183    exceeded: Arc<AtomicBool>,
184}
185
186/// Isolate slot that records whether the near-heap-limit callback terminated the
187/// running script, so the session can report the heap limit as the cause.
188struct HeapLimitExceeded(Arc<AtomicBool>);
189
190/// Returns true once if the near-heap-limit callback terminated the script that
191/// last ran on this isolate, and clears the flag for the next execution.
192pub fn take_heap_limit_exceeded(isolate: &v8::Isolate) -> bool {
193    isolate
194        .get_slot::<HeapLimitExceeded>()
195        .is_some_and(|flag| flag.0.swap(false, Ordering::AcqRel))
196}
197
198/// Default per-isolate heap cap applied when the caller passes no explicit limit.
199///
200/// Resource limits must be bounded by default (never unbounded for memory): a
201/// guest with no configured `heap_limit_mb` must NOT be able to grow the heap until
202/// V8 fatal-aborts the process-global runtime and takes down every co-tenant
203/// isolate. 128 MiB matches the Cloudflare Workers per-isolate budget we mirror for
204/// isolation semantics; operators may raise it via the configured limit.
205pub const DEFAULT_HEAP_LIMIT_MB: u32 = 128;
206
207/// Invoked by V8 when heap usage approaches the configured limit. Instead of
208/// letting V8 fatal-abort the (process-global) runtime, request termination of the
209/// offending isolate and return a raised limit so V8 can propagate the uncatchable
210/// termination exception cleanly. `data` is a leaked `Box<HeapLimitGuard>` for the
211/// isolate this callback was registered on.
212extern "C" fn near_heap_limit_callback(
213    data: *mut c_void,
214    current_heap_limit: usize,
215    initial_heap_limit: usize,
216) -> usize {
217    let mut heap_size = 0;
218    if !data.is_null() {
219        // Safety: `data` is the pointer produced by `Box::into_raw` in
220        // `install_heap_limit_guard` and lives for the entire lifetime of the
221        // isolate. V8 runs this callback on the isolate's own thread.
222        let guard = unsafe { &*(data as *const HeapLimitGuard) };
223        // Terminate any JS currently running on this isolate. This unwinds the
224        // guest with an uncatchable exception rather than crashing the process.
225        guard.handle.terminate_execution();
226        guard.exceeded.store(true, Ordering::Release);
227        // One large allocation (for example a 256 MiB string) can take the heap
228        // far past the limit before this callback runs, so size the new limit
229        // from the heap, not from the old limit.
230        let mut statistics = v8::HeapStatistics::default();
231        unsafe { (*guard.isolate).get_heap_statistics(&mut statistics) };
232        heap_size = statistics.total_heap_size();
233    }
234    warn_limit_exhausted(
235        TrackedLimit::V8HeapBytes,
236        current_heap_limit,
237        initial_heap_limit.max(1),
238    );
239    // Grant headroom so V8 does not immediately fatal-abort before the termination
240    // takes effect. We never shrink below the current limit.
241    current_heap_limit
242        .max(initial_heap_limit)
243        .max(heap_size)
244        .saturating_add(NEAR_HEAP_LIMIT_HEADROOM_BYTES)
245}
246
247/// Register the near-heap-limit OOM guard on an isolate that was created with a
248/// configured heap cap. Without this guard, V8 fatal-aborts the whole (process-
249/// global) runtime with a SIGTRAP when the cap is reached, taking down every
250/// concurrent tenant; with it, the offending isolate is terminated instead.
251///
252/// Must be called for every isolate created with a non-`None` heap limit,
253/// regardless of whether it was built fresh or restored from a snapshot.
254pub fn install_heap_limit_guard(isolate: &mut v8::OwnedIsolate) {
255    // The callback needs a thread-safe handle to request termination of this very
256    // isolate and the isolate itself to read its heap size. The guard is leaked so
257    // it outlives the callback registration; the number of isolates per process is
258    // bounded, so this is not an unbounded leak, and the memory is reclaimed when
259    // the process exits.
260    let exceeded = Arc::new(AtomicBool::new(false));
261    isolate.set_slot(HeapLimitExceeded(Arc::clone(&exceeded)));
262    let guard = Box::new(HeapLimitGuard {
263        handle: isolate.thread_safe_handle(),
264        isolate: &mut **isolate,
265        exceeded,
266    });
267    let data = Box::into_raw(guard) as *mut c_void;
268    isolate.add_near_heap_limit_callback(near_heap_limit_callback, data);
269}
270
271/// Create a new V8 isolate with an optional heap limit in MB. `None` applies the
272/// bounded-by-default cap (`DEFAULT_HEAP_LIMIT_MB`) — an isolate is NEVER created
273/// with an unbounded heap, so a guest heap bomb terminates its own isolate rather
274/// than fatal-aborting the shared process.
275pub fn create_isolate(heap_limit_mb: Option<u32>) -> v8::OwnedIsolate {
276    prepare_current_thread();
277    let limit = heap_limit_mb.unwrap_or(DEFAULT_HEAP_LIMIT_MB);
278    let mut params = v8::CreateParams::default();
279    let limit_bytes = (limit as usize) * 1024 * 1024;
280    params = params.heap_limits(0, limit_bytes);
281    let mut isolate = with_isolate_lifecycle_lock(|| v8::Isolate::new(params));
282    configure_isolate(&mut isolate);
283    install_heap_limit_guard(&mut isolate);
284    isolate
285}
286
287/// Run V8 isolate create/drop work under a process-wide lifecycle lock.
288///
289/// rusty_v8 130.0.7 embeds a V8 13.0-era process-wide WebAssembly code pointer
290/// table. Isolate construction allocates wasm builtin handles from that table and
291/// isolate destruction frees them again, so create/drop must not overlap across
292/// session threads.
293pub fn with_isolate_lifecycle_lock<T>(f: impl FnOnce() -> T) -> T {
294    let _guard = V8_ISOLATE_LIFECYCLE
295        .lock()
296        .expect("V8 isolate lifecycle lock poisoned");
297    f()
298}
299
300pub fn drop_isolate(isolate: Option<v8::OwnedIsolate>) {
301    if let Some(isolate) = isolate {
302        with_isolate_lifecycle_lock(|| drop(isolate));
303    }
304}
305
306/// Create a new V8 context on the given isolate.
307/// Returns a Global handle so the context can be reused across scopes.
308pub fn create_context(isolate: &mut v8::OwnedIsolate) -> v8::Global<v8::Context> {
309    let scope = &mut v8::HandleScope::new(isolate);
310    let context = v8::Context::new(scope, Default::default());
311    v8::Global::new(scope, context)
312}
313
314// V8 lifecycle tests are consolidated in execution::tests to avoid
315// inter-test SIGSEGV from V8 global state issues.