Skip to main content

agentos_execution/
v8_host.rs

1//! V8 runtime host — manages a shared embedded V8 runtime with session multiplexing.
2
3use crate::v8_ipc::{self, BinaryFrame};
4use agentos_bridge::queue_tracker::{tracked_sync_channel, TrackedLimit, TrackedReceiver};
5use agentos_v8_runtime::embedded_runtime::{
6    shared_embedded_runtime, EmbeddedV8Runtime, EmbeddedV8SessionHandle,
7};
8use agentos_v8_runtime::runtime_protocol::{RuntimeCommand, RuntimeEvent, WarmSessionHint};
9use std::io::{self, Cursor};
10use std::sync::{Arc, OnceLock};
11use std::thread;
12
13const V8_SESSION_FRAME_CHANNEL_CAPACITY: usize = 1024;
14
15/// V8 polyfill bridge code generated by `build.rs`.
16const V8_BRIDGE_CODE: &str = concat!(
17    include_str!(concat!(env!("OUT_DIR"), "/v8-bridge.js")),
18    "\n",
19    include_str!(concat!(env!("OUT_DIR"), "/v8-bridge-zlib.js"))
20);
21
22/// Manages an embedded V8 runtime with session multiplexing.
23pub struct V8RuntimeHost {
24    shared: Arc<SharedEmbeddedRuntimeClient>,
25}
26
27struct SharedEmbeddedRuntimeClient {
28    runtime: Arc<EmbeddedV8Runtime>,
29}
30
31impl V8RuntimeHost {
32    /// Connect to the process-global embedded V8 runtime client.
33    pub fn spawn() -> io::Result<Self> {
34        Ok(V8RuntimeHost {
35            shared: shared_embedded_runtime_client()?,
36        })
37    }
38
39    /// Register a session and return a receiver for its frames.
40    pub fn register_session(&self, session_id: &str) -> io::Result<TrackedReceiver<BinaryFrame>> {
41        let (runtime_receiver, registration) = self
42            .shared
43            .runtime
44            .register_session_with_output_registration(session_id)?;
45        let (sender, receiver) = tracked_sync_channel(
46            TrackedLimit::V8SessionFrames,
47            V8_SESSION_FRAME_CHANNEL_CAPACITY,
48        );
49        let thread_name = format!("secure-exec-v8-session-{session_id}");
50        let runtime = Arc::clone(&self.shared.runtime);
51        let runtime_for_thread = Arc::clone(&runtime);
52
53        let spawn_result = thread::Builder::new().name(thread_name).spawn(move || {
54            while let Ok(frame) = runtime_receiver.recv() {
55                // Apply backpressure instead of destroying the session when
56                // the downstream consumer is slow. This thread is dedicated
57                // to one session, so a blocking send safely parks it — and,
58                // in turn, backpressures the V8 runtime — until a slot frees.
59                // Previously a `try_send` that hit the full channel called
60                // destroy_session_if_output_current(), tearing the session
61                // down on a transient backlog (the same anti-pattern fixed
62                // for the event/stdout queues). Only a dropped receiver
63                // (the consumer is gone for good) is terminal.
64                if sender.send(from_runtime_event(frame)).is_err() {
65                    let _ = runtime_for_thread.destroy_session_if_output_current(&registration);
66                    break;
67                }
68            }
69        });
70        if let Err(error) = spawn_result {
71            runtime.unregister_session(session_id);
72            return Err(error);
73        }
74
75        Ok(receiver)
76    }
77
78    /// Unregister a session.
79    pub fn unregister_session(&self, session_id: &str) {
80        self.shared.runtime.unregister_session(session_id);
81    }
82
83    pub fn create_session(
84        &self,
85        session_id: String,
86        heap_limit_mb: u32,
87        cpu_time_limit_ms: u32,
88        wall_clock_limit_ms: u32,
89        warm_hint: Option<WarmSessionHint>,
90    ) -> io::Result<()> {
91        self.shared.runtime.dispatch(RuntimeCommand::CreateSession {
92            session_id,
93            heap_limit_mb: non_zero_option(heap_limit_mb),
94            cpu_time_limit_ms: non_zero_option(cpu_time_limit_ms),
95            wall_clock_limit_ms: non_zero_option(wall_clock_limit_ms),
96            warm_hint,
97        })
98    }
99
100    pub fn create_session_from_command(&self, command: RuntimeCommand) -> io::Result<()> {
101        self.shared.runtime.dispatch(command)
102    }
103
104    /// Send a frame to the V8 runtime.
105    pub fn send_frame(&self, frame: &BinaryFrame) -> io::Result<()> {
106        self.shared.runtime.dispatch(to_runtime_command(frame)?)
107    }
108
109    /// Get the pre-bundled bridge code (polyfills).
110    pub fn bridge_code() -> &'static str {
111        V8_BRIDGE_CODE
112    }
113
114    /// Pre-build the per-sidecar snapshot for an agent-SDK `userland_code` bundle
115    /// into the process-wide cache, so the FIRST session that uses it is already
116    /// warm (no cold-build penalty on the session-create path). Blocks until the
117    /// snapshot is built; idempotent (a cache hit returns immediately). A no-op for
118    /// empty `userland_code`.
119    pub fn pre_warm_snapshot(&self, userland_code: &str) -> io::Result<()> {
120        if userland_code.is_empty() {
121            return Ok(());
122        }
123        self.shared.runtime.dispatch(RuntimeCommand::WarmSnapshot {
124            bridge_code: Self::bridge_code().to_owned(),
125            userland_code: userland_code.to_owned(),
126        })
127    }
128
129    pub fn pre_warm_workers(&self, userland_code: &str, heap_limit_mb: u32, count: usize) {
130        self.shared.runtime.pre_warm_workers(
131            Self::bridge_code().to_owned(),
132            userland_code.to_owned(),
133            non_zero_option(heap_limit_mb),
134            count,
135        );
136    }
137
138    pub fn seed_default_warm_workers_async(&self) {
139        static DEFAULT_WARM_STARTED: OnceLock<()> = OnceLock::new();
140        let runtime = Arc::clone(&self.shared.runtime);
141        let _ = DEFAULT_WARM_STARTED.get_or_init(|| {
142            let _ = thread::Builder::new()
143                .name(String::from("secure-exec-v8-default-warm"))
144                .spawn(move || {
145                    runtime.pre_warm_workers(
146                        V8_BRIDGE_CODE.to_owned(),
147                        String::new(),
148                        None,
149                        warm_worker_count(),
150                    );
151                });
152        });
153    }
154
155    /// True when the process-wide snapshot cache already has this userland
156    /// bundle. This is a lookup only; it never creates a snapshot.
157    pub fn snapshot_ready(&self, userland_code: &str) -> bool {
158        self.shared
159            .runtime
160            .snapshot_ready(Self::bridge_code(), userland_code)
161    }
162
163    /// Kick a process-wide async warm for the wasm runner snapshot. At most one
164    /// warm thread is spawned per process; blocking callers should use
165    /// [`pre_warm_snapshot`](Self::pre_warm_snapshot).
166    pub fn warm_snapshot_async(userland_code: String) {
167        if userland_code.is_empty() {
168            return;
169        }
170        static WASM_RUNNER_WARM_STARTED: OnceLock<()> = OnceLock::new();
171        let _ = WASM_RUNNER_WARM_STARTED.get_or_init(|| {
172            let _ = thread::Builder::new()
173                .name(String::from("secure-exec-wasm-snapshot-warm"))
174                .spawn(move || {
175                    let Ok(host) = V8RuntimeHost::spawn() else {
176                        return;
177                    };
178                    if let Err(error) = host.pre_warm_snapshot(&userland_code) {
179                        eprintln!("agentos-v8-runtime: wasm runner snapshot warm failed: {error}");
180                    }
181                });
182        });
183    }
184
185    /// Create a session handle for sending session-scoped frames and cleanup.
186    pub fn session_handle(&self, session_id: String) -> V8SessionHandle {
187        V8SessionHandle::new(session_id, Arc::clone(&self.shared.runtime))
188    }
189
190    pub fn child_pid(&self) -> u32 {
191        0
192    }
193
194    pub fn is_alive(&mut self) -> io::Result<bool> {
195        Ok(self.shared.runtime.is_alive())
196    }
197
198    #[cfg(test)]
199    fn runtime_ptr(&self) -> usize {
200        Arc::as_ptr(&self.shared.runtime) as usize
201    }
202}
203
204fn non_zero_option(value: u32) -> Option<u32> {
205    (value > 0).then_some(value)
206}
207
208fn warm_worker_count() -> usize {
209    std::env::var("AGENTOS_V8_WARM_ISOLATES")
210        .ok()
211        .and_then(|value| value.parse::<usize>().ok())
212        .unwrap_or(2)
213}
214
215/// A handle to a single V8 session within the shared runtime.
216/// Provides methods for sending frames specific to this session.
217pub struct V8SessionHandle {
218    inner: EmbeddedV8SessionHandle,
219}
220
221impl std::fmt::Debug for V8SessionHandle {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        f.debug_struct("V8SessionHandle")
224            .field("session_id", &self.inner.session_id())
225            .finish()
226    }
227}
228
229impl V8SessionHandle {
230    pub fn new(session_id: String, runtime: Arc<EmbeddedV8Runtime>) -> Self {
231        Self {
232            inner: runtime.session_handle(session_id),
233        }
234    }
235
236    /// Send a bridge response back to the V8 isolate.
237    pub fn send_bridge_response(
238        &self,
239        call_id: u64,
240        status: u8,
241        payload: Vec<u8>,
242    ) -> io::Result<()> {
243        self.inner.send_bridge_response(call_id, status, payload)
244    }
245
246    /// Send a stream event to the V8 isolate (stdin data, timer, etc.).
247    pub fn send_stream_event(&self, event_type: &str, payload: Vec<u8>) -> io::Result<()> {
248        self.inner.send_stream_event(event_type, payload)
249    }
250
251    /// Install a direct module-source reader on this session's V8 thread so module
252    /// loads read source directly instead of round-tripping the bridge.
253    pub fn set_module_reader(
254        &self,
255        reader: Box<dyn agentos_v8_runtime::execution::GuestModuleReader>,
256    ) -> io::Result<()> {
257        self.inner.set_module_reader(reader)
258    }
259
260    /// Execute bridge code + user code in this V8 session without routing through
261    /// the legacy binary frame encode/decode path.
262    #[allow(clippy::too_many_arguments)] // mirrors the CreateSession frame
263    pub fn execute(
264        &self,
265        mode: u8,
266        file_path: String,
267        bridge_code: String,
268        post_restore_script: String,
269        userland_code: String,
270        high_resolution_time: bool,
271        user_code: String,
272        wasm_module_bytes: Option<Arc<Vec<u8>>>,
273    ) -> io::Result<()> {
274        self.inner.execute(
275            mode,
276            file_path,
277            bridge_code,
278            post_restore_script,
279            userland_code,
280            high_resolution_time,
281            user_code,
282            wasm_module_bytes,
283        )
284    }
285
286    /// Terminate execution in this session.
287    pub fn terminate(&self) -> io::Result<()> {
288        self.inner.terminate()
289    }
290
291    /// Destroy this session in the embedded runtime and remove its receiver.
292    pub fn destroy(&self) -> io::Result<()> {
293        let _ = self.inner.terminate();
294        self.inner.destroy()
295    }
296
297    pub fn session_id(&self) -> &str {
298        self.inner.session_id()
299    }
300}
301
302impl Clone for V8SessionHandle {
303    fn clone(&self) -> Self {
304        Self {
305            inner: self.inner.clone(),
306        }
307    }
308}
309
310/// Pre-build the per-sidecar snapshot for an agent-SDK `userland_code` bundle into
311/// the process-wide cache, so the FIRST session that uses it is already warm. Uses
312/// the shared embedded runtime directly (no per-call host lifecycle). Blocks until
313/// built; idempotent (cache hit returns immediately); no-op for empty input.
314/// Eagerly initialize the process-wide embedded V8 runtime (and the V8 platform)
315/// on the calling thread. Call this once on a long-lived thread (the sidecar main
316/// thread) at startup so V8 is NOT first initialized on a transient worker thread
317/// (e.g. a VM-create pre-warm thread that then exits, which corrupts the platform).
318pub fn ensure_runtime_initialized() -> io::Result<()> {
319    shared_embedded_runtime_client().map(|_| ())
320}
321
322pub fn pre_warm_agent_snapshot(userland_code: &str) -> io::Result<()> {
323    if userland_code.is_empty() {
324        return Ok(());
325    }
326    let userland = userland_code.to_owned();
327    // Build the SnapshotCreator on a dedicated, fully-joined std::thread rather than
328    // the caller's (tokio blocking-pool) thread. V8 SnapshotCreator isolates are
329    // thread-sensitive; a reused pool thread leaves per-thread V8 state that corrupts
330    // later isolate creation. A spawned+joined thread tears down cleanly, mirroring
331    // how per-session snapshot builds run on their own dedicated session threads.
332    let handle = std::thread::Builder::new()
333        .name("agentos-snapshot-prewarm".to_owned())
334        .spawn(move || -> io::Result<()> {
335            let client = shared_embedded_runtime_client()?;
336            client.runtime.dispatch(RuntimeCommand::WarmSnapshot {
337                bridge_code: V8_BRIDGE_CODE.to_owned(),
338                userland_code: userland,
339            })
340        })?;
341    handle
342        .join()
343        .map_err(|_| io::Error::other("snapshot pre-warm thread panicked"))?
344}
345
346fn shared_embedded_runtime_client() -> io::Result<Arc<SharedEmbeddedRuntimeClient>> {
347    static SHARED_RUNTIME: OnceLock<Arc<SharedEmbeddedRuntimeClient>> = OnceLock::new();
348    static SHARED_RUNTIME_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
349
350    if let Some(shared) = SHARED_RUNTIME.get() {
351        return Ok(Arc::clone(shared));
352    }
353
354    let _guard = SHARED_RUNTIME_INIT_LOCK
355        .lock()
356        .expect("shared embedded runtime init lock poisoned");
357    if let Some(shared) = SHARED_RUNTIME.get() {
358        return Ok(Arc::clone(shared));
359    }
360
361    let shared = Arc::new(SharedEmbeddedRuntimeClient {
362        runtime: shared_embedded_runtime()?,
363    });
364    let _ = SHARED_RUNTIME.set(Arc::clone(&shared));
365    Ok(shared)
366}
367
368fn to_runtime_command(frame: &BinaryFrame) -> io::Result<RuntimeCommand> {
369    let bytes = v8_ipc::encode_frame(frame)?;
370    let runtime_frame = agentos_v8_runtime::ipc_binary::read_frame(&mut Cursor::new(bytes))?;
371    RuntimeCommand::try_from(runtime_frame)
372}
373
374fn from_runtime_event(event: RuntimeEvent) -> BinaryFrame {
375    match event {
376        RuntimeEvent::BridgeCall {
377            session_id,
378            call_id,
379            method,
380            payload,
381        } => BinaryFrame::BridgeCall {
382            session_id,
383            call_id,
384            method,
385            payload,
386        },
387        RuntimeEvent::ExecutionResult {
388            session_id,
389            exit_code,
390            exports,
391            error,
392        } => BinaryFrame::ExecutionResult {
393            session_id,
394            exit_code,
395            exports,
396            error: error.map(from_runtime_execution_error),
397        },
398        RuntimeEvent::Log {
399            session_id,
400            channel,
401            message,
402        } => BinaryFrame::Log {
403            session_id,
404            channel,
405            message,
406        },
407        RuntimeEvent::StreamCallback {
408            session_id,
409            callback_type,
410            payload,
411        } => BinaryFrame::StreamCallback {
412            session_id,
413            callback_type,
414            payload,
415        },
416    }
417}
418
419fn from_runtime_execution_error(
420    error: agentos_v8_runtime::ipc_binary::ExecutionErrorBin,
421) -> v8_ipc::ExecutionErrorBin {
422    v8_ipc::ExecutionErrorBin {
423        error_type: error.error_type,
424        message: error.message,
425        stack: error.stack,
426        code: error.code,
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use std::sync::atomic::{AtomicU64, Ordering};
434
435    static NEXT_TEST_SESSION_ID: AtomicU64 = AtomicU64::new(1);
436
437    fn next_session_id() -> String {
438        format!(
439            "embedded-runtime-host-{}",
440            NEXT_TEST_SESSION_ID.fetch_add(1, Ordering::Relaxed)
441        )
442    }
443
444    #[test]
445    fn embedded_runtime_host_reuses_shared_runtime_service() {
446        let first = V8RuntimeHost::spawn().expect("spawn V8 runtime host");
447        let second = V8RuntimeHost::spawn().expect("spawn V8 runtime host");
448        assert_eq!(
449            first.runtime_ptr(),
450            second.runtime_ptr(),
451            "V8 runtime hosts should reuse the same embedded runtime service"
452        );
453    }
454
455    #[test]
456    fn embedded_runtime_host_create_destroy_recycles_session_ids() {
457        let host = V8RuntimeHost::spawn().expect("spawn V8 runtime host");
458        let session_id = next_session_id();
459
460        let _first_receiver = host
461            .register_session(&session_id)
462            .expect("register session output");
463        host.send_frame(&BinaryFrame::CreateSession {
464            session_id: session_id.clone(),
465            heap_limit_mb: 0,
466            cpu_time_limit_ms: 0,
467            wall_clock_limit_ms: 0,
468        })
469        .expect("create embedded runtime session");
470
471        let duplicate_error = host
472            .send_frame(&BinaryFrame::CreateSession {
473                session_id: session_id.clone(),
474                heap_limit_mb: 0,
475                cpu_time_limit_ms: 0,
476                wall_clock_limit_ms: 0,
477            })
478            .expect_err("duplicate session ids should be rejected");
479        assert_eq!(duplicate_error.kind(), io::ErrorKind::Other);
480
481        host.session_handle(session_id.clone())
482            .destroy()
483            .expect("destroy embedded runtime session");
484
485        let _second_receiver = host
486            .register_session(&session_id)
487            .expect("re-register session output");
488        host.send_frame(&BinaryFrame::CreateSession {
489            session_id: session_id.clone(),
490            heap_limit_mb: 0,
491            cpu_time_limit_ms: 0,
492            wall_clock_limit_ms: 0,
493        })
494        .expect("recreate embedded runtime session");
495
496        host.session_handle(session_id)
497            .destroy()
498            .expect("destroy recreated session");
499    }
500}