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 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 ®istration.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 ®istration.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 #[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 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 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 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 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}