Skip to main content

agentos_v8_runtime/
embedded_runtime.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::io::{self, Write};
4use std::net::Shutdown;
5use std::os::unix::net::UnixStream;
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7use std::sync::{mpsc, Arc, Mutex, OnceLock, Weak};
8use std::thread;
9use std::time::Instant;
10
11use crate::host_call::{
12    record_sync_bridge_host_phase, record_sync_bridge_response_channel_send_start, CallIdRouter,
13};
14use crate::ipc_binary::BinaryFrame;
15use crate::runtime_protocol::{
16    validate_bridge_response_status, BridgeResponse, ModuleReaderHandle, RuntimeCommand,
17    RuntimeEvent, SessionMessage, StreamEvent,
18};
19use crate::session::{RuntimeEventEnvelope, SessionCommand, SessionManager};
20use crate::snapshot::SnapshotCache;
21use crate::{bridge, isolate};
22
23static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);
24const SESSION_OUTPUT_CHANNEL_CAPACITY: usize = 1024;
25
26pub struct EmbeddedV8Runtime {
27    session_mgr: Arc<Mutex<SessionManager>>,
28    session_outputs: Arc<Mutex<HashMap<String, SessionOutput>>>,
29    snapshot_cache: Arc<SnapshotCache>,
30    alive: Arc<AtomicBool>,
31    dispatch_shutdown_tx: crossbeam_channel::Sender<()>,
32    dispatch_thread: Mutex<Option<thread::JoinHandle<()>>>,
33    next_output_generation: AtomicU64,
34}
35
36#[derive(Clone)]
37struct SessionOutput {
38    generation: u64,
39    sender: mpsc::SyncSender<RuntimeEvent>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EmbeddedV8SessionOutputRegistration {
44    session_id: String,
45    generation: u64,
46}
47
48impl EmbeddedV8Runtime {
49    pub fn new(max_concurrency: Option<usize>) -> io::Result<Self> {
50        bridge::init_codec();
51        bridge::acquire_embedded_cbor_codec();
52        isolate::init_v8_platform();
53
54        // Keep bridge-only, agent-SDK, and wasm-runner userland variants warm
55        // without immediately evicting each other.
56        let snapshot_cache = Arc::new(SnapshotCache::new(8));
57        let (event_tx, event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(1024);
58        let (dispatch_shutdown_tx, dispatch_shutdown_rx) = crossbeam_channel::bounded::<()>(1);
59        let call_id_router: CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
60        let session_mgr = Arc::new(Mutex::new(SessionManager::new(
61            max_concurrency.unwrap_or_else(default_max_concurrency),
62            event_tx,
63            call_id_router,
64            Arc::clone(&snapshot_cache),
65        )));
66        let session_outputs = Arc::new(Mutex::new(HashMap::new()));
67        let alive = Arc::new(AtomicBool::new(true));
68        let alive_for_thread = Arc::clone(&alive);
69        let session_outputs_for_thread = Arc::clone(&session_outputs);
70        let session_mgr_for_thread = Arc::clone(&session_mgr);
71
72        let dispatch_thread = thread::Builder::new()
73            .name(String::from("agentos-v8-runtime-dispatch"))
74            .spawn(move || {
75                loop {
76                    crossbeam_channel::select! {
77                        recv(event_rx) -> event => {
78                            let Ok(event) = event else {
79                                break;
80                            };
81                            route_outbound_event(
82                                event,
83                                &session_outputs_for_thread,
84                                &session_mgr_for_thread,
85                            );
86                        }
87                        recv(dispatch_shutdown_rx) -> _ => {
88                            break;
89                        }
90                    }
91                }
92                alive_for_thread.store(false, Ordering::Release);
93            })
94            .inspect_err(|_| bridge::release_embedded_cbor_codec())?;
95
96        Ok(Self {
97            session_mgr,
98            session_outputs,
99            snapshot_cache,
100            alive,
101            dispatch_shutdown_tx,
102            dispatch_thread: Mutex::new(Some(dispatch_thread)),
103            next_output_generation: AtomicU64::new(1),
104        })
105    }
106
107    pub fn is_alive(&self) -> bool {
108        self.alive.load(Ordering::Acquire)
109    }
110
111    pub fn snapshot_ready(&self, bridge_code: &str, userland_code: &str) -> bool {
112        if userland_code.is_empty() {
113            return true;
114        }
115        self.snapshot_cache
116            .try_get_with_userland(bridge_code, Some(userland_code))
117            .is_some()
118    }
119
120    pub fn pre_warm_workers(
121        &self,
122        bridge_code: String,
123        userland_code: String,
124        heap_limit_mb: Option<u32>,
125        count: usize,
126    ) {
127        self.session_mgr
128            .lock()
129            .expect("embedded runtime session manager lock poisoned")
130            .pre_warm_workers(bridge_code, userland_code, heap_limit_mb, count);
131    }
132
133    pub fn register_session(&self, session_id: &str) -> io::Result<mpsc::Receiver<RuntimeEvent>> {
134        self.register_session_with_output_registration(session_id)
135            .map(|(receiver, _registration)| receiver)
136    }
137
138    pub fn register_session_with_output_registration(
139        &self,
140        session_id: &str,
141    ) -> io::Result<(
142        mpsc::Receiver<RuntimeEvent>,
143        EmbeddedV8SessionOutputRegistration,
144    )> {
145        self.register_session_with_capacity(session_id, SESSION_OUTPUT_CHANNEL_CAPACITY)
146    }
147
148    fn register_session_with_capacity(
149        &self,
150        session_id: &str,
151        capacity: usize,
152    ) -> io::Result<(
153        mpsc::Receiver<RuntimeEvent>,
154        EmbeddedV8SessionOutputRegistration,
155    )> {
156        let (sender, receiver) = mpsc::sync_channel(capacity);
157        let mut outputs = self
158            .session_outputs
159            .lock()
160            .expect("embedded runtime session outputs lock poisoned");
161        if outputs.contains_key(session_id) {
162            return Err(io::Error::new(
163                io::ErrorKind::AlreadyExists,
164                format!("session output {session_id} already exists"),
165            ));
166        }
167        let generation = self.next_output_generation.fetch_add(1, Ordering::Relaxed);
168        outputs.insert(session_id.to_owned(), SessionOutput { generation, sender });
169        Ok((
170            receiver,
171            EmbeddedV8SessionOutputRegistration {
172                session_id: session_id.to_owned(),
173                generation,
174            },
175        ))
176    }
177
178    pub fn unregister_session(&self, session_id: &str) {
179        self.session_outputs
180            .lock()
181            .expect("embedded runtime session outputs lock poisoned")
182            .remove(session_id);
183    }
184
185    pub fn destroy_session_if_output_current(
186        &self,
187        registration: &EmbeddedV8SessionOutputRegistration,
188    ) -> io::Result<bool> {
189        if !remove_session_output_if_current(
190            &self.session_outputs,
191            &registration.session_id,
192            registration.generation,
193        ) {
194            return Ok(false);
195        }
196
197        let shutdown = {
198            let mut mgr = self
199                .session_mgr
200                .lock()
201                .expect("session manager lock poisoned");
202            mgr.begin_destroy_session_if_output_generation(
203                &registration.session_id,
204                registration.generation,
205            )
206            .map_err(other_io_error)?
207        };
208        match shutdown {
209            Some(shutdown) => {
210                shutdown.finish();
211                Ok(true)
212            }
213            None => Ok(false),
214        }
215    }
216
217    pub fn session_handle(self: &Arc<Self>, session_id: String) -> EmbeddedV8SessionHandle {
218        EmbeddedV8SessionHandle {
219            session_id,
220            runtime: Arc::clone(self),
221        }
222    }
223
224    pub fn dispatch(&self, command: RuntimeCommand) -> io::Result<()> {
225        match command {
226            RuntimeCommand::CreateSession {
227                session_id,
228                heap_limit_mb,
229                cpu_time_limit_ms,
230                wall_clock_limit_ms,
231                warm_hint,
232            } => {
233                let output_generation = self
234                    .session_outputs
235                    .lock()
236                    .expect("embedded runtime session outputs lock poisoned")
237                    .get(&session_id)
238                    .map(|output| output.generation);
239                let mut mgr = self
240                    .session_mgr
241                    .lock()
242                    .expect("session manager lock poisoned");
243                mgr.create_session_with_output_generation(
244                    session_id,
245                    heap_limit_mb,
246                    cpu_time_limit_ms,
247                    wall_clock_limit_ms,
248                    output_generation,
249                    warm_hint,
250                )
251                .map_err(other_io_error)
252            }
253            command => dispatch_runtime_command(&self.session_mgr, &self.snapshot_cache, command),
254        }
255    }
256
257    pub fn session_count(&self) -> usize {
258        self.session_mgr
259            .lock()
260            .expect("embedded runtime session manager lock poisoned")
261            .session_count()
262    }
263
264    pub fn active_slot_count(&self) -> usize {
265        self.session_mgr
266            .lock()
267            .expect("embedded runtime session manager lock poisoned")
268            .active_slot_count()
269    }
270}
271
272impl Drop for EmbeddedV8Runtime {
273    fn drop(&mut self) {
274        let session_handles = self
275            .session_mgr
276            .lock()
277            .map(|mut mgr| mgr.take_session_shutdown_handles())
278            .unwrap_or_default();
279        for handle in session_handles {
280            let _ = handle.join();
281        }
282        if let Ok(mut outputs) = self.session_outputs.lock() {
283            outputs.clear();
284        }
285        let _ = self.dispatch_shutdown_tx.try_send(());
286        if let Some(handle) = self.dispatch_thread.get_mut().ok().and_then(Option::take) {
287            let _ = handle.join();
288        }
289        bridge::release_embedded_cbor_codec();
290    }
291}
292
293pub struct EmbeddedV8SessionHandle {
294    session_id: String,
295    runtime: Arc<EmbeddedV8Runtime>,
296}
297
298impl EmbeddedV8SessionHandle {
299    // In-process runtime protocol call; the arg list mirrors the Execute frame.
300    #[allow(clippy::too_many_arguments)]
301    pub fn execute(
302        &self,
303        mode: u8,
304        file_path: String,
305        bridge_code: String,
306        post_restore_script: String,
307        userland_code: String,
308        high_resolution_time: bool,
309        user_code: String,
310        wasm_module_bytes: Option<Arc<Vec<u8>>>,
311    ) -> io::Result<()> {
312        validate_execute_mode(mode)?;
313        self.runtime.dispatch(RuntimeCommand::SendToSession {
314            session_id: self.session_id.clone(),
315            message: SessionMessage::Execute {
316                mode,
317                file_path,
318                bridge_code,
319                post_restore_script,
320                userland_code,
321                high_resolution_time,
322                user_code,
323                wasm_module_bytes,
324            },
325        })
326    }
327
328    pub fn send_bridge_response(
329        &self,
330        call_id: u64,
331        status: u8,
332        payload: Vec<u8>,
333    ) -> io::Result<()> {
334        validate_bridge_response_status(status)?;
335        self.runtime.dispatch(RuntimeCommand::SendToSession {
336            session_id: self.session_id.clone(),
337            message: SessionMessage::BridgeResponse(BridgeResponse {
338                call_id,
339                status,
340                payload,
341            }),
342        })
343    }
344
345    pub fn send_stream_event(&self, event_type: &str, payload: Vec<u8>) -> io::Result<()> {
346        self.runtime.dispatch(RuntimeCommand::SendToSession {
347            session_id: self.session_id.clone(),
348            message: SessionMessage::StreamEvent(StreamEvent {
349                event_type: event_type.to_owned(),
350                payload,
351            }),
352        })
353    }
354
355    /// Install a direct module-source reader on this session's thread so module
356    /// loads read source directly instead of round-tripping the bridge. Routed
357    /// through the dispatch thread (which owns the session manager).
358    pub fn set_module_reader(
359        &self,
360        reader: Box<dyn crate::execution::GuestModuleReader>,
361    ) -> io::Result<()> {
362        self.runtime
363            .dispatch(RuntimeCommand::SetSessionModuleReader {
364                session_id: self.session_id.clone(),
365                reader: ModuleReaderHandle::new(reader),
366            })
367    }
368
369    pub fn terminate(&self) -> io::Result<()> {
370        self.runtime.dispatch(RuntimeCommand::SendToSession {
371            session_id: self.session_id.clone(),
372            message: SessionMessage::TerminateExecution,
373        })
374    }
375
376    pub fn destroy(&self) -> io::Result<()> {
377        self.runtime.unregister_session(&self.session_id);
378        self.runtime.dispatch(RuntimeCommand::DestroySession {
379            session_id: self.session_id.clone(),
380        })
381    }
382
383    pub fn session_id(&self) -> &str {
384        &self.session_id
385    }
386}
387
388fn validate_execute_mode(mode: u8) -> io::Result<()> {
389    if mode > 1 {
390        return Err(io::Error::new(
391            io::ErrorKind::InvalidInput,
392            format!("unknown Execute mode: {mode}"),
393        ));
394    }
395    Ok(())
396}
397
398impl Clone for EmbeddedV8SessionHandle {
399    fn clone(&self) -> Self {
400        Self {
401            session_id: self.session_id.clone(),
402            runtime: Arc::clone(&self.runtime),
403        }
404    }
405}
406
407pub fn shared_embedded_runtime() -> io::Result<Arc<EmbeddedV8Runtime>> {
408    static SHARED_RUNTIME: OnceLock<Mutex<Weak<EmbeddedV8Runtime>>> = OnceLock::new();
409
410    let shared_slot = SHARED_RUNTIME.get_or_init(|| Mutex::new(Weak::new()));
411    let mut shared_guard = shared_slot
412        .lock()
413        .expect("shared embedded runtime init lock poisoned");
414    if let Some(shared) = shared_guard.upgrade() {
415        return Ok(shared);
416    }
417
418    let shared = Arc::new(EmbeddedV8Runtime::new(None)?);
419    *shared_guard = Arc::downgrade(&shared);
420    Ok(shared)
421}
422
423pub struct EmbeddedRuntimeHandle {
424    alive: Arc<AtomicBool>,
425    codec_released: AtomicBool,
426    shutdown_stream: UnixStream,
427    join_handle: Mutex<Option<thread::JoinHandle<()>>>,
428}
429
430impl EmbeddedRuntimeHandle {
431    pub fn is_alive(&self) -> bool {
432        self.alive.load(Ordering::Acquire)
433    }
434
435    pub fn shutdown(&self) {
436        let _ = self.shutdown_stream.shutdown(Shutdown::Both);
437        if let Ok(mut guard) = self.join_handle.lock() {
438            if let Some(handle) = guard.take() {
439                let _ = handle.join();
440            }
441        }
442        self.release_codec();
443    }
444
445    fn release_codec(&self) {
446        if !self.codec_released.swap(true, Ordering::AcqRel) {
447            bridge::release_embedded_cbor_codec();
448        }
449    }
450}
451
452impl Drop for EmbeddedRuntimeHandle {
453    fn drop(&mut self) {
454        let _ = self.shutdown_stream.shutdown(Shutdown::Both);
455        if let Some(handle) = self.join_handle.get_mut().ok().and_then(Option::take) {
456            let _ = handle.join();
457        }
458        self.release_codec();
459    }
460}
461
462pub fn spawn_embedded_runtime_ipc(
463    max_concurrency: Option<usize>,
464) -> io::Result<(UnixStream, EmbeddedRuntimeHandle)> {
465    bridge::init_codec();
466    bridge::acquire_embedded_cbor_codec();
467    isolate::init_v8_platform();
468
469    let (host_stream, runtime_stream) = UnixStream::pair()?;
470    let shutdown_stream = host_stream.try_clone()?;
471    let alive = Arc::new(AtomicBool::new(true));
472    let alive_for_thread = Arc::clone(&alive);
473    let max_concurrency = max_concurrency.unwrap_or_else(default_max_concurrency);
474
475    let join_handle = thread::Builder::new()
476        .name(String::from("agentos-v8-runtime"))
477        .spawn(move || {
478            run_embedded_runtime(runtime_stream, max_concurrency);
479            alive_for_thread.store(false, Ordering::Release);
480        })
481        .inspect_err(|_| bridge::release_embedded_cbor_codec())?;
482
483    Ok((
484        host_stream,
485        EmbeddedRuntimeHandle {
486            alive,
487            codec_released: AtomicBool::new(false),
488            shutdown_stream,
489            join_handle: Mutex::new(Some(join_handle)),
490        },
491    ))
492}
493
494fn default_max_concurrency() -> usize {
495    thread::available_parallelism()
496        .map(|count| count.get())
497        .unwrap_or(4)
498}
499
500fn run_embedded_runtime(stream: UnixStream, max_concurrency: usize) {
501    // Keep bridge-only, agent-SDK, and wasm-runner userland variants warm
502    // without immediately evicting each other.
503    let snapshot_cache = Arc::new(SnapshotCache::new(8));
504    let writer_stream = match stream.try_clone() {
505        Ok(writer_stream) => writer_stream,
506        Err(error) => {
507            eprintln!("embedded V8 runtime failed to clone stream: {error}");
508            return;
509        }
510    };
511    let (event_tx, event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(1024);
512    let call_id_router: CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
513    let connection_id = NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed);
514
515    let writer_handle = match thread::Builder::new()
516        .name(format!("v8-ipc-writer-{connection_id}"))
517        .spawn(move || ipc_writer_thread(event_rx, writer_stream))
518    {
519        Ok(handle) => handle,
520        Err(error) => {
521            eprintln!("embedded V8 runtime failed to spawn writer thread: {error}");
522            return;
523        }
524    };
525
526    let session_mgr = Arc::new(Mutex::new(SessionManager::new(
527        max_concurrency,
528        event_tx,
529        call_id_router,
530        Arc::clone(&snapshot_cache),
531    )));
532
533    handle_connection(stream, connection_id, session_mgr, snapshot_cache);
534    let _ = writer_handle.join();
535}
536
537fn ipc_writer_thread(
538    rx: crossbeam_channel::Receiver<RuntimeEventEnvelope>,
539    mut writer: UnixStream,
540) {
541    while let Ok(envelope) = rx.recv() {
542        let frame: BinaryFrame = envelope.event.into();
543        let bytes = match crate::ipc_binary::frame_to_bytes(&frame) {
544            Ok(bytes) => bytes,
545            Err(error) => {
546                eprintln!("embedded V8 runtime writer encode error: {error}");
547                break;
548            }
549        };
550        if let Err(error) = writer.write_all(&bytes) {
551            eprintln!("embedded V8 runtime writer error: {error}");
552            break;
553        }
554    }
555}
556
557fn handle_connection(
558    mut stream: UnixStream,
559    connection_id: u64,
560    session_mgr: Arc<Mutex<SessionManager>>,
561    snapshot_cache: Arc<SnapshotCache>,
562) {
563    let mut session_ids = HashSet::new();
564
565    loop {
566        let frame = match crate::ipc_binary::read_frame(&mut stream) {
567            Ok(frame) => frame,
568            Err(ref error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
569            Err(error) => {
570                eprintln!("embedded V8 runtime read error on connection {connection_id}: {error}");
571                break;
572            }
573        };
574
575        let command = match RuntimeCommand::try_from(frame) {
576            Ok(command) => command,
577            Err(error) => {
578                eprintln!(
579                    "embedded V8 runtime dispatch error on connection {connection_id}: {error}"
580                );
581                continue;
582            }
583        };
584
585        if let RuntimeCommand::CreateSession { session_id, .. } = &command {
586            session_ids.insert(session_id.clone());
587        } else if let RuntimeCommand::DestroySession { session_id } = &command {
588            session_ids.remove(session_id);
589        }
590
591        if let Err(error) = dispatch_runtime_command(&session_mgr, &snapshot_cache, command) {
592            eprintln!("embedded V8 runtime dispatch error on connection {connection_id}: {error}");
593        }
594    }
595
596    let shutdowns = {
597        let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
598        mgr.begin_destroy_sessions(session_ids)
599    };
600    for shutdown in shutdowns {
601        shutdown.finish();
602    }
603}
604
605fn dispatch_runtime_command(
606    session_mgr: &Arc<Mutex<SessionManager>>,
607    snapshot_cache: &Arc<SnapshotCache>,
608    command: RuntimeCommand,
609) -> io::Result<()> {
610    match command {
611        RuntimeCommand::CreateSession {
612            session_id,
613            heap_limit_mb,
614            cpu_time_limit_ms,
615            wall_clock_limit_ms,
616            warm_hint,
617        } => {
618            let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
619            mgr.create_session_with_output_generation(
620                session_id,
621                heap_limit_mb,
622                cpu_time_limit_ms,
623                wall_clock_limit_ms,
624                None,
625                warm_hint,
626            )
627            .map_err(other_io_error)
628        }
629        RuntimeCommand::DestroySession { session_id } => {
630            let shutdown = {
631                let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
632                mgr.begin_destroy_session(&session_id)
633                    .map_err(other_io_error)?
634            };
635            shutdown.finish();
636            Ok(())
637        }
638        RuntimeCommand::SendToSession {
639            session_id,
640            message,
641        } => {
642            // Resolve the sender and apply terminate side effects under the
643            // lock, then send after releasing it so a full session command
644            // channel cannot block the manager mutex.
645            let is_bridge_response = matches!(&message, SessionMessage::BridgeResponse(_));
646            let sender = {
647                let mgr = session_mgr.lock().expect("session manager lock poisoned");
648                let routed_session_id = match &message {
649                    SessionMessage::BridgeResponse(response) => {
650                        let phase_start = Instant::now();
651                        let routed_session_id = mgr
652                            .call_id_router()
653                            .lock()
654                            .expect("call_id router lock poisoned")
655                            .remove(&response.call_id)
656                            .unwrap_or(session_id);
657                        record_sync_bridge_host_phase(
658                            "sync_rpc_dispatch",
659                            "dispatch_route_lookup",
660                            phase_start.elapsed(),
661                        );
662                        routed_session_id
663                    }
664                    SessionMessage::InjectGlobals { .. }
665                    | SessionMessage::Execute { .. }
666                    | SessionMessage::StreamEvent(_)
667                    | SessionMessage::TerminateExecution => session_id,
668                };
669                let phase_start = Instant::now();
670                let sender = mgr
671                    .session_command_sender(&routed_session_id, &message)
672                    .map_err(other_io_error)?;
673                if is_bridge_response {
674                    record_sync_bridge_host_phase(
675                        "sync_rpc_dispatch",
676                        "dispatch_sender_lookup",
677                        phase_start.elapsed(),
678                    );
679                }
680                sender
681            };
682            if let SessionMessage::BridgeResponse(response) = &message {
683                record_sync_bridge_response_channel_send_start(response.call_id);
684            }
685            let phase_start = Instant::now();
686            let result = sender
687                .send(SessionCommand::Message(message))
688                .map_err(|e| other_io_error(format!("session thread disconnected: {}", e)));
689            if is_bridge_response {
690                record_sync_bridge_host_phase(
691                    "sync_rpc_dispatch",
692                    "dispatch_channel_send",
693                    phase_start.elapsed(),
694                );
695            }
696            result
697        }
698        RuntimeCommand::SetSessionModuleReader { session_id, reader } => {
699            // Resolve the sender under the lock, release, then forward the live
700            // reader as a SetModuleReader command to the session thread.
701            let sender = {
702                let mgr = session_mgr.lock().expect("session manager lock poisoned");
703                mgr.session_sender(&session_id)
704            };
705            let sender = sender.map_err(other_io_error)?;
706            match reader.take() {
707                Some(reader) => sender
708                    .send(SessionCommand::SetModuleReader(reader))
709                    .map_err(|e| other_io_error(format!("session thread disconnected: {}", e))),
710                None => Ok(()),
711            }
712        }
713        RuntimeCommand::WarmSnapshot {
714            bridge_code,
715            userland_code,
716        } => snapshot_cache
717            .get_or_create_with_userland(
718                &bridge_code,
719                (!userland_code.is_empty()).then_some(userland_code.as_str()),
720            )
721            .map(|_| ())
722            .map_err(other_io_error),
723    }
724}
725
726fn route_outbound_event(
727    envelope: RuntimeEventEnvelope,
728    session_outputs: &Arc<Mutex<HashMap<String, SessionOutput>>>,
729    session_mgr: &Arc<Mutex<SessionManager>>,
730) -> bool {
731    let RuntimeEventEnvelope {
732        output_generation,
733        event,
734    } = envelope;
735    let session_id = event.session_id().to_owned();
736
737    let output = session_outputs
738        .lock()
739        .expect("embedded runtime session outputs lock poisoned")
740        .get(&session_id)
741        .cloned();
742
743    let Some(output) = output else {
744        clear_dropped_bridge_call_route(&event, session_mgr);
745        return false;
746    };
747
748    if output_generation != Some(output.generation) {
749        clear_dropped_bridge_call_route(&event, session_mgr);
750        return false;
751    }
752
753    match output.sender.try_send(event) {
754        Ok(()) => {}
755        Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => {
756            if remove_session_output_if_current(session_outputs, &session_id, output.generation) {
757                return session_mgr
758                    .lock()
759                    .expect("session manager lock poisoned")
760                    .detach_session_if_output_generation(&session_id, output.generation)
761                    .unwrap_or(false);
762            }
763        }
764    }
765    false
766}
767
768fn clear_dropped_bridge_call_route(event: &RuntimeEvent, session_mgr: &Arc<Mutex<SessionManager>>) {
769    if let RuntimeEvent::BridgeCall { call_id, .. } = event {
770        session_mgr
771            .lock()
772            .expect("session manager lock poisoned")
773            .clear_call_route(*call_id);
774    }
775}
776
777fn remove_session_output_if_current(
778    session_outputs: &Arc<Mutex<HashMap<String, SessionOutput>>>,
779    session_id: &str,
780    generation: u64,
781) -> bool {
782    let mut outputs = session_outputs
783        .lock()
784        .expect("embedded runtime session outputs lock poisoned");
785    if outputs
786        .get(session_id)
787        .is_some_and(|output| output.generation == generation)
788    {
789        outputs.remove(session_id);
790        return true;
791    }
792    false
793}
794
795fn other_io_error(message: String) -> io::Error {
796    io::Error::other(message)
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::runtime_protocol::{BridgeResponse, RuntimeCommand, RuntimeEvent, SessionMessage};
803    use std::time::Duration;
804
805    static EMBEDDED_RUNTIME_CODEC_TEST_LOCK: Mutex<()> = Mutex::new(());
806
807    #[test]
808    fn embedded_runtime_handle_reports_liveness_and_shutdown() {
809        let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
810            .lock()
811            .expect("embedded runtime codec test lock poisoned");
812        let (_stream, handle) =
813            spawn_embedded_runtime_ipc(Some(1)).expect("spawn embedded runtime");
814        assert!(
815            handle.is_alive(),
816            "embedded runtime should be alive after spawn"
817        );
818        handle.shutdown();
819        assert!(
820            !handle.is_alive(),
821            "embedded runtime should report not alive after shutdown"
822        );
823    }
824
825    #[test]
826    fn embedded_runtime_session_shared_runtime_is_lazy() {
827        let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
828            .lock()
829            .expect("embedded runtime codec test lock poisoned");
830        let first = shared_embedded_runtime().expect("shared embedded runtime");
831        let second = shared_embedded_runtime().expect("shared embedded runtime");
832        assert!(
833            Arc::ptr_eq(&first, &second),
834            "shared_embedded_runtime() should reuse the same runtime instance"
835        );
836    }
837
838    #[test]
839    fn embedded_runtime_drop_releases_codec_after_destroying_sessions() {
840        let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
841            .lock()
842            .expect("embedded runtime codec test lock poisoned");
843        let codec_before = bridge::is_cbor_codec();
844        let alive = {
845            let runtime = EmbeddedV8Runtime::new(Some(1)).expect("embedded runtime");
846            let alive = Arc::clone(&runtime.alive);
847            assert!(
848                bridge::is_cbor_codec(),
849                "embedded runtime should enable the CBOR bridge codec while alive"
850            );
851            let (_receiver, _registration) = runtime
852                .register_session_with_output_registration("drop-lifecycle")
853                .expect("register session output");
854            runtime
855                .dispatch(RuntimeCommand::CreateSession {
856                    session_id: "drop-lifecycle".into(),
857                    heap_limit_mb: None,
858                    cpu_time_limit_ms: None,
859                    wall_clock_limit_ms: None,
860                    warm_hint: None,
861                })
862                .expect("create session");
863            assert_eq!(
864                runtime.session_count(),
865                1,
866                "test should drop a runtime with a live session"
867            );
868            alive
869        };
870
871        assert!(
872            !alive.load(Ordering::Acquire),
873            "dropping embedded runtime should stop the dispatch thread"
874        );
875        assert_eq!(
876            bridge::is_cbor_codec(),
877            codec_before,
878            "dropping embedded runtime should restore the prior codec state"
879        );
880    }
881
882    #[test]
883    fn embedded_runtime_stream_bridge_response_routing_prefers_call_id_router() {
884        let snapshot_cache = Arc::new(SnapshotCache::new(1));
885        let (event_tx, _event_rx) = crossbeam_channel::unbounded::<RuntimeEventEnvelope>();
886        let call_id_router: CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
887        let session_mgr = Arc::new(Mutex::new(SessionManager::new(
888            1,
889            event_tx,
890            Arc::clone(&call_id_router),
891            Arc::clone(&snapshot_cache),
892        )));
893
894        {
895            let mut mgr = session_mgr.lock().expect("session manager");
896            mgr.create_session("stream-target".into(), None, None, None)
897                .expect("create target session");
898        }
899        call_id_router
900            .lock()
901            .expect("call_id router")
902            .insert(41, "stream-target".into());
903
904        dispatch_runtime_command(
905            &session_mgr,
906            &snapshot_cache,
907            RuntimeCommand::SendToSession {
908                session_id: "wrong-session".into(),
909                message: SessionMessage::BridgeResponse(BridgeResponse {
910                    call_id: 41,
911                    status: 0,
912                    payload: vec![0xAB],
913                }),
914            },
915        )
916        .expect("bridge response should route via call_id table");
917
918        assert!(
919            call_id_router
920                .lock()
921                .expect("call_id router")
922                .get(&41)
923                .is_none(),
924            "bridge response routing should consume the call_id entry"
925        );
926
927        session_mgr
928            .lock()
929            .expect("session manager")
930            .destroy_session("stream-target")
931            .expect("destroy target session");
932    }
933
934    #[test]
935    fn embedded_runtime_session_handle_rejects_unknown_bridge_response_status() {
936        let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
937            .lock()
938            .expect("embedded runtime codec test lock poisoned");
939        let runtime = Arc::new(EmbeddedV8Runtime::new(Some(1)).expect("embedded runtime"));
940        let handle = runtime.session_handle("missing-session".into());
941
942        let err = handle
943            .send_bridge_response(1, 3, Vec::new())
944            .expect_err("unknown bridge response status should be rejected");
945
946        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
947        assert!(err.to_string().contains("unknown BridgeResponse status"));
948    }
949
950    #[test]
951    fn embedded_runtime_stream_events_preserve_order_per_session() {
952        let (sender, receiver) = mpsc::sync_channel(SESSION_OUTPUT_CHANNEL_CAPACITY);
953        let session_outputs = Arc::new(Mutex::new(HashMap::from([(
954            String::from("stream-order"),
955            SessionOutput {
956                generation: 1,
957                sender,
958            },
959        )])));
960        let session_mgr = test_session_manager();
961
962        route_outbound_event(
963            runtime_envelope(
964                1,
965                RuntimeEvent::Log {
966                    session_id: "stream-order".into(),
967                    channel: 0,
968                    message: "first".into(),
969                },
970            ),
971            &session_outputs,
972            &session_mgr,
973        );
974        route_outbound_event(
975            runtime_envelope(
976                1,
977                RuntimeEvent::StreamCallback {
978                    session_id: "stream-order".into(),
979                    callback_type: "stdin".into(),
980                    payload: vec![1, 2, 3],
981                },
982            ),
983            &session_outputs,
984            &session_mgr,
985        );
986
987        let first = receiver
988            .recv_timeout(Duration::from_millis(100))
989            .expect("first event");
990        let second = receiver
991            .recv_timeout(Duration::from_millis(100))
992            .expect("second event");
993
994        assert!(matches!(
995            first,
996            RuntimeEvent::Log { ref message, .. } if message == "first"
997        ));
998        assert!(matches!(
999            second,
1000            RuntimeEvent::StreamCallback { ref callback_type, ref payload, .. }
1001                if callback_type == "stdin" && payload == &vec![1, 2, 3]
1002        ));
1003    }
1004
1005    #[test]
1006    fn embedded_runtime_stream_termination_race_drops_late_events_after_receiver_close() {
1007        let (sender, receiver) = mpsc::sync_channel(SESSION_OUTPUT_CHANNEL_CAPACITY);
1008        let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1009            String::from("stream-race"),
1010            SessionOutput {
1011                generation: 1,
1012                sender,
1013            },
1014        )])));
1015        let session_mgr = test_session_manager();
1016        drop(receiver);
1017
1018        route_outbound_event(
1019            runtime_envelope(
1020                1,
1021                RuntimeEvent::ExecutionResult {
1022                    session_id: "stream-race".into(),
1023                    exit_code: 0,
1024                    exports: None,
1025                    error: None,
1026                },
1027            ),
1028            &session_outputs,
1029            &session_mgr,
1030        );
1031
1032        assert!(
1033            session_outputs
1034                .lock()
1035                .expect("session outputs")
1036                .get("stream-race")
1037                .is_none(),
1038            "late events should drop stale receiver registrations during teardown races"
1039        );
1040    }
1041
1042    #[test]
1043    fn embedded_runtime_stream_backpressure_drops_full_session_output() {
1044        let (sender, receiver) = mpsc::sync_channel(1);
1045        let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1046            String::from("stream-full"),
1047            SessionOutput {
1048                generation: 1,
1049                sender,
1050            },
1051        )])));
1052        let session_mgr = test_session_manager_with_session("stream-full");
1053
1054        route_outbound_event(
1055            runtime_envelope(
1056                1,
1057                RuntimeEvent::Log {
1058                    session_id: "stream-full".into(),
1059                    channel: 0,
1060                    message: "first".into(),
1061                },
1062            ),
1063            &session_outputs,
1064            &session_mgr,
1065        );
1066        let cleaned_up = route_outbound_event(
1067            runtime_envelope(
1068                1,
1069                RuntimeEvent::Log {
1070                    session_id: "stream-full".into(),
1071                    channel: 0,
1072                    message: "second".into(),
1073                },
1074            ),
1075            &session_outputs,
1076            &session_mgr,
1077        );
1078        assert!(cleaned_up, "full session output should detach the session");
1079
1080        let first = receiver
1081            .recv_timeout(Duration::from_millis(100))
1082            .expect("first event");
1083        assert!(matches!(
1084            first,
1085            RuntimeEvent::Log { ref message, .. } if message == "first"
1086        ));
1087        assert!(
1088            receiver.recv_timeout(Duration::from_millis(20)).is_err(),
1089            "full session output should drop the overflowing event"
1090        );
1091        assert!(
1092            session_outputs
1093                .lock()
1094                .expect("session outputs")
1095                .get("stream-full")
1096                .is_none(),
1097            "full session output should remove the stale registration"
1098        );
1099        assert_eq!(
1100            session_mgr.lock().expect("session manager").session_count(),
1101            0,
1102            "full session output should destroy the runtime session"
1103        );
1104    }
1105
1106    #[test]
1107    fn embedded_runtime_drops_stale_generation_events_for_reused_session_id() {
1108        let (sender, receiver) = mpsc::sync_channel(SESSION_OUTPUT_CHANNEL_CAPACITY);
1109        let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1110            String::from("stream-reused"),
1111            SessionOutput {
1112                generation: 2,
1113                sender,
1114            },
1115        )])));
1116        let session_mgr = test_session_manager_with_generation("stream-reused", 2);
1117        session_mgr
1118            .lock()
1119            .expect("session manager")
1120            .call_id_router()
1121            .lock()
1122            .expect("call_id router")
1123            .insert(99, "stream-reused".into());
1124
1125        let routed = route_outbound_event(
1126            runtime_envelope(
1127                1,
1128                RuntimeEvent::BridgeCall {
1129                    session_id: "stream-reused".into(),
1130                    call_id: 99,
1131                    method: "_stale".into(),
1132                    payload: Vec::new(),
1133                },
1134            ),
1135            &session_outputs,
1136            &session_mgr,
1137        );
1138
1139        assert!(!routed, "stale generation event should not trigger cleanup");
1140        assert!(
1141            receiver.recv_timeout(Duration::from_millis(20)).is_err(),
1142            "stale generation event should not reach reused session output"
1143        );
1144        assert_eq!(
1145            session_mgr.lock().expect("session manager").session_count(),
1146            1,
1147            "stale generation event must leave reused session alive"
1148        );
1149        assert!(
1150            session_mgr
1151                .lock()
1152                .expect("session manager")
1153                .call_id_router()
1154                .lock()
1155                .expect("call_id router")
1156                .get(&99)
1157                .is_none(),
1158            "stale bridge calls should clear their call route"
1159        );
1160    }
1161
1162    #[test]
1163    fn embedded_runtime_clears_bridge_route_when_output_is_missing() {
1164        let session_outputs = Arc::new(Mutex::new(HashMap::new()));
1165        let session_mgr = test_session_manager();
1166        session_mgr
1167            .lock()
1168            .expect("session manager")
1169            .call_id_router()
1170            .lock()
1171            .expect("call_id router")
1172            .insert(123, "stream-detached".into());
1173
1174        let routed = route_outbound_event(
1175            runtime_envelope(
1176                1,
1177                RuntimeEvent::BridgeCall {
1178                    session_id: "stream-detached".into(),
1179                    call_id: 123,
1180                    method: "_detached".into(),
1181                    payload: Vec::new(),
1182                },
1183            ),
1184            &session_outputs,
1185            &session_mgr,
1186        );
1187
1188        assert!(!routed, "missing output should not route the bridge call");
1189        assert!(
1190            session_mgr
1191                .lock()
1192                .expect("session manager")
1193                .call_id_router()
1194                .lock()
1195                .expect("call_id router")
1196                .get(&123)
1197                .is_none(),
1198            "bridge calls dropped with no output should clear their call route"
1199        );
1200    }
1201
1202    #[test]
1203    fn embedded_runtime_stale_output_registration_cannot_destroy_reused_session_id() {
1204        let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1205            .lock()
1206            .expect("embedded runtime codec test lock poisoned");
1207        let runtime = Arc::new(EmbeddedV8Runtime::new(Some(1)).expect("embedded runtime"));
1208        let session_id = "stream-generation-reuse";
1209        let (_first_receiver, first_registration) = runtime
1210            .register_session_with_capacity(session_id, 1)
1211            .expect("register first session output");
1212        runtime
1213            .dispatch(RuntimeCommand::CreateSession {
1214                session_id: session_id.into(),
1215                heap_limit_mb: None,
1216                cpu_time_limit_ms: None,
1217                wall_clock_limit_ms: None,
1218                warm_hint: None,
1219            })
1220            .expect("create first session");
1221        runtime
1222            .session_handle(session_id.into())
1223            .destroy()
1224            .expect("destroy first session");
1225
1226        let (_second_receiver, _second_registration) = runtime
1227            .register_session_with_capacity(session_id, 1)
1228            .expect("register reused session output");
1229        runtime
1230            .dispatch(RuntimeCommand::CreateSession {
1231                session_id: session_id.into(),
1232                heap_limit_mb: None,
1233                cpu_time_limit_ms: None,
1234                wall_clock_limit_ms: None,
1235                warm_hint: None,
1236            })
1237            .expect("create reused session");
1238
1239        assert!(
1240            !runtime
1241                .destroy_session_if_output_current(&first_registration)
1242                .expect("stale destroy should be ignored"),
1243            "stale registration should not match the reused session output"
1244        );
1245        assert_eq!(
1246            runtime.session_count(),
1247            1,
1248            "stale registration must not destroy the reused session"
1249        );
1250
1251        runtime
1252            .session_handle(session_id.into())
1253            .destroy()
1254            .expect("destroy reused session");
1255    }
1256
1257    #[test]
1258    fn session_cleanup_generation_guard_does_not_destroy_reused_session_id() {
1259        let session_mgr = test_session_manager();
1260        {
1261            let mut mgr = session_mgr.lock().expect("session manager");
1262            mgr.create_session_with_output_generation(
1263                "reused".into(),
1264                None,
1265                None,
1266                None,
1267                Some(1),
1268                None,
1269            )
1270            .expect("create first session");
1271            mgr.destroy_session("reused")
1272                .expect("destroy first session");
1273            mgr.create_session_with_output_generation(
1274                "reused".into(),
1275                None,
1276                None,
1277                None,
1278                Some(2),
1279                None,
1280            )
1281            .expect("create reused session");
1282
1283            assert!(
1284                !mgr.destroy_session_if_output_generation("reused", 1)
1285                    .expect("stale generation destroy should be ignored"),
1286                "stale cleanup generation should not match reused session"
1287            );
1288            assert_eq!(
1289                mgr.session_count(),
1290                1,
1291                "stale cleanup generation must leave reused session alive"
1292            );
1293            mgr.destroy_session("reused")
1294                .expect("destroy reused session");
1295        }
1296    }
1297
1298    fn test_session_manager() -> Arc<Mutex<SessionManager>> {
1299        let (event_tx, _event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(1);
1300        Arc::new(Mutex::new(SessionManager::new(
1301            1,
1302            event_tx,
1303            Arc::new(Mutex::new(HashMap::new())),
1304            Arc::new(SnapshotCache::new(1)),
1305        )))
1306    }
1307
1308    fn runtime_envelope(output_generation: u64, event: RuntimeEvent) -> RuntimeEventEnvelope {
1309        RuntimeEventEnvelope {
1310            output_generation: Some(output_generation),
1311            event,
1312        }
1313    }
1314
1315    fn test_session_manager_with_session(session_id: &str) -> Arc<Mutex<SessionManager>> {
1316        test_session_manager_with_generation(session_id, 1)
1317    }
1318
1319    fn test_session_manager_with_generation(
1320        session_id: &str,
1321        output_generation: u64,
1322    ) -> Arc<Mutex<SessionManager>> {
1323        let session_mgr = test_session_manager();
1324        session_mgr
1325            .lock()
1326            .expect("session manager")
1327            .create_session_with_output_generation(
1328                session_id.into(),
1329                None,
1330                None,
1331                None,
1332                Some(output_generation),
1333                None,
1334            )
1335            .expect("create test session");
1336        session_mgr
1337    }
1338}