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