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::{Arc, Mutex, OnceLock, Weak};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use crate::host_call::{record_sync_bridge_host_phase, BridgeCallRegistry, CallIdRouter};
12use crate::ipc_binary::BinaryFrame;
13#[cfg(test)]
14use crate::runtime_protocol::RuntimeEvent;
15use crate::runtime_protocol::{
16 validate_bridge_response_status, BridgeResponse, ModuleReaderHandle, RuntimeCommand,
17 SessionMessage, StreamEvent,
18};
19use crate::session::{
20 runtime_event_output_channel, RuntimeEventEnvelope, RuntimeEventOutputReceiver,
21 RuntimeEventOutputSender, SessionCommand, SessionManager,
22};
23use crate::snapshot::SnapshotCache;
24use crate::{bridge, isolate};
25use agentos_runtime::accounting::ResourceClass;
26
27static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);
28#[cfg(test)]
29const TEST_SESSION_OUTPUT_CHANNEL_CAPACITY: usize = 1024;
30
31pub struct EmbeddedV8Runtime {
32 session_mgr: Arc<Mutex<SessionManager>>,
33 session_outputs: Arc<Mutex<HashMap<String, SessionOutput>>>,
34 snapshot_cache: Arc<SnapshotCache>,
35 alive: Arc<AtomicBool>,
36 next_output_generation: AtomicU64,
37 runtime: agentos_runtime::RuntimeContext,
38 executor_teardown_timeout: Duration,
39}
40
41#[derive(Clone)]
42struct SessionOutput {
43 generation: u64,
44 sender: RuntimeEventOutputSender,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EmbeddedV8SessionOutputRegistration {
49 session_id: String,
50 generation: u64,
51}
52
53impl EmbeddedV8Runtime {
54 pub fn new(
55 max_concurrency: Option<usize>,
56 runtime: agentos_runtime::RuntimeContext,
57 ) -> io::Result<Self> {
58 bridge::init_codec();
59 bridge::acquire_embedded_cbor_codec();
60 isolate::init_v8_platform();
61
62 let snapshot_cache = Arc::new(SnapshotCache::new(8));
65 let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
66 let configured_max_concurrency = runtime.max_active_vm_executors();
67 let executor_teardown_timeout = runtime.vm_executor_teardown_timeout();
68 let session_mgr = Arc::new(Mutex::new(SessionManager::new(
69 max_concurrency.unwrap_or(configured_max_concurrency),
70 crate::session::RuntimeEventSender::closed(),
71 call_id_router,
72 Arc::clone(&snapshot_cache),
73 runtime.clone(),
74 )));
75 let session_outputs = Arc::new(Mutex::new(HashMap::new()));
76 let alive = Arc::new(AtomicBool::new(true));
77
78 Ok(Self {
79 session_mgr,
80 session_outputs,
81 snapshot_cache,
82 alive,
83 next_output_generation: AtomicU64::new(1),
84 runtime,
85 executor_teardown_timeout,
86 })
87 }
88
89 pub fn is_alive(&self) -> bool {
90 self.alive.load(Ordering::Acquire)
91 }
92
93 pub fn snapshot_ready(&self, bridge_code: &str, userland_code: &str) -> bool {
94 if userland_code.is_empty() {
95 return true;
96 }
97 self.snapshot_cache
98 .try_get_with_userland(bridge_code, Some(userland_code))
99 .is_some()
100 }
101
102 pub fn pre_warm_workers(
103 &self,
104 bridge_code: String,
105 userland_code: String,
106 heap_limit_mb: Option<u32>,
107 count: usize,
108 ) {
109 self.session_mgr
110 .lock()
111 .expect("embedded runtime session manager lock poisoned")
112 .pre_warm_workers(bridge_code, userland_code, heap_limit_mb, count);
113 }
114
115 pub fn register_session(&self, session_id: &str) -> io::Result<RuntimeEventOutputReceiver> {
116 self.register_session_with_output_registration(session_id)
117 .map(|(receiver, _registration)| receiver)
118 }
119
120 pub fn register_session_with_output_registration(
121 &self,
122 session_id: &str,
123 ) -> io::Result<(
124 RuntimeEventOutputReceiver,
125 EmbeddedV8SessionOutputRegistration,
126 )> {
127 self.register_session_with_runtime(session_id, &self.runtime)
128 }
129
130 pub fn register_session_with_runtime(
131 &self,
132 session_id: &str,
133 runtime: &agentos_runtime::RuntimeContext,
134 ) -> io::Result<(
135 RuntimeEventOutputReceiver,
136 EmbeddedV8SessionOutputRegistration,
137 )> {
138 let capacity = crate::session::configured_resource_capacity(
139 runtime,
140 ResourceClass::AsyncCompletions,
141 "limits.reactor.maxAsyncCompletions",
142 "runtime.resources.maxAsyncCompletions",
143 )
144 .map_err(other_io_error)?;
145 self.register_session_with_capacity(session_id, capacity, Arc::clone(runtime.resources()))
146 }
147
148 fn register_session_with_capacity(
149 &self,
150 session_id: &str,
151 capacity: usize,
152 resources: Arc<agentos_runtime::accounting::ResourceLedger>,
153 ) -> io::Result<(
154 RuntimeEventOutputReceiver,
155 EmbeddedV8SessionOutputRegistration,
156 )> {
157 let (sender, receiver) = runtime_event_output_channel(capacity, resources);
158 let mut outputs = self
159 .session_outputs
160 .lock()
161 .expect("embedded runtime session outputs lock poisoned");
162 if outputs.contains_key(session_id) {
163 return Err(io::Error::new(
164 io::ErrorKind::AlreadyExists,
165 format!("session output {session_id} already exists"),
166 ));
167 }
168 let generation = self.next_output_generation.fetch_add(1, Ordering::Relaxed);
169 outputs.insert(session_id.to_owned(), SessionOutput { generation, sender });
170 Ok((
171 receiver,
172 EmbeddedV8SessionOutputRegistration {
173 session_id: session_id.to_owned(),
174 generation,
175 },
176 ))
177 }
178
179 pub fn unregister_session(&self, session_id: &str) {
180 self.session_outputs
181 .lock()
182 .expect("embedded runtime session outputs lock poisoned")
183 .remove(session_id);
184 }
185
186 pub fn destroy_session_if_output_current(
187 &self,
188 registration: &EmbeddedV8SessionOutputRegistration,
189 ) -> io::Result<bool> {
190 let output_is_current = self
191 .session_outputs
192 .lock()
193 .expect("embedded runtime session outputs lock poisoned")
194 .get(®istration.session_id)
195 .is_some_and(|output| output.generation == registration.generation);
196 if !output_is_current {
197 return Ok(false);
198 }
199
200 let detached = {
201 let mut mgr = self
202 .session_mgr
203 .lock()
204 .expect("session manager lock poisoned");
205 mgr.detach_session_if_output_generation(
206 ®istration.session_id,
207 registration.generation,
208 )
209 .map_err(other_io_error)?
210 };
211 if detached {
212 remove_session_output_if_current(
213 &self.session_outputs,
214 ®istration.session_id,
215 registration.generation,
216 );
217 }
218 Ok(detached)
219 }
220
221 pub fn session_handle(self: &Arc<Self>, session_id: String) -> EmbeddedV8SessionHandle {
222 let output_generation = self
223 .session_outputs
224 .lock()
225 .expect("embedded runtime session outputs lock poisoned")
226 .get(&session_id)
227 .map(|output| output.generation);
228 EmbeddedV8SessionHandle {
229 session_id,
230 output_generation,
231 runtime: Arc::clone(self),
232 }
233 }
234
235 pub fn dispatch(&self, command: RuntimeCommand) -> io::Result<()> {
236 match command {
237 RuntimeCommand::CreateSession {
238 session_id,
239 heap_limit_mb,
240 cpu_time_limit_ms,
241 wall_clock_limit_ms,
242 warm_hint,
243 } => {
244 let output = self
245 .session_outputs
246 .lock()
247 .expect("embedded runtime session outputs lock poisoned")
248 .get(&session_id)
249 .cloned();
250 let output_generation = output.as_ref().map(|output| output.generation);
251 let event_tx = output.map(|output| {
252 crate::session::RuntimeEventSender::direct(output.generation, output.sender)
253 });
254 let mut mgr = self
255 .session_mgr
256 .lock()
257 .expect("session manager lock poisoned");
258 mgr.create_session_with_output_generation_and_sender(
259 session_id,
260 heap_limit_mb,
261 cpu_time_limit_ms,
262 wall_clock_limit_ms,
263 output_generation,
264 warm_hint,
265 event_tx,
266 )
267 .map_err(other_io_error)
268 }
269 command => dispatch_runtime_command(&self.session_mgr, &self.snapshot_cache, command),
270 }
271 }
272
273 fn settle_bridge_response(
274 &self,
275 session_id: &str,
276 output_generation: Option<u64>,
277 response: BridgeResponse,
278 ) -> io::Result<()> {
279 let registry = {
280 let mgr = self
281 .session_mgr
282 .lock()
283 .expect("session manager lock poisoned");
284 Arc::clone(mgr.call_id_router())
285 };
286 let phase_start = Instant::now();
287 let result = registry
288 .settle(session_id, output_generation, response)
289 .map_err(other_io_error);
290 record_sync_bridge_host_phase(
291 "sync_rpc_dispatch",
292 "direct_response_settlement",
293 phase_start.elapsed(),
294 );
295 result
296 }
297
298 pub fn dispatch_create_session_with_runtime(
302 &self,
303 command: RuntimeCommand,
304 session_runtime: agentos_runtime::RuntimeContext,
305 ready_batch_handle_limit: usize,
306 bridge_call_timeout: std::time::Duration,
307 ) -> io::Result<()> {
308 let RuntimeCommand::CreateSession {
309 session_id,
310 heap_limit_mb,
311 cpu_time_limit_ms,
312 wall_clock_limit_ms,
313 warm_hint,
314 } = command
315 else {
316 return Err(io::Error::new(
317 io::ErrorKind::InvalidInput,
318 "dispatch_create_session_with_runtime requires CreateSession",
319 ));
320 };
321
322 let output = self
323 .session_outputs
324 .lock()
325 .expect("embedded runtime session outputs lock poisoned")
326 .get(&session_id)
327 .cloned();
328 let output_generation = output.as_ref().map(|output| output.generation);
329 let event_tx = output.map(|output| {
330 crate::session::RuntimeEventSender::direct(output.generation, output.sender)
331 });
332 self.session_mgr
333 .lock()
334 .expect("session manager lock poisoned")
335 .create_session_with_output_generation_sender_and_runtime(
336 session_id,
337 heap_limit_mb,
338 cpu_time_limit_ms,
339 wall_clock_limit_ms,
340 output_generation,
341 warm_hint,
342 event_tx,
343 session_runtime,
344 ready_batch_handle_limit,
345 bridge_call_timeout,
346 )
347 .map_err(other_io_error)
348 }
349
350 pub fn session_count(&self) -> usize {
351 self.session_mgr
352 .lock()
353 .expect("embedded runtime session manager lock poisoned")
354 .session_count()
355 }
356
357 pub fn active_slot_count(&self) -> usize {
358 self.session_mgr
359 .lock()
360 .expect("embedded runtime session manager lock poisoned")
361 .active_slot_count()
362 }
363}
364
365impl Drop for EmbeddedV8Runtime {
366 fn drop(&mut self) {
367 self.alive.store(false, Ordering::Release);
368 let session_handles = self
369 .session_mgr
370 .lock()
371 .map(|mut mgr| mgr.take_session_shutdown_handles())
372 .unwrap_or_default();
373 let deadline = Instant::now() + self.executor_teardown_timeout;
374 let mut session_handles = session_handles;
375 while !session_handles.is_empty() {
376 let mut pending = Vec::with_capacity(session_handles.len());
377 for handle in session_handles {
378 if !handle.is_finished() {
379 pending.push(handle);
380 continue;
381 }
382 if handle.join().is_err() {
383 eprintln!(
384 "ERR_AGENTOS_VM_EXECUTOR_PANIC: executor panicked during runtime shutdown"
385 );
386 }
387 }
388 session_handles = pending;
389 if session_handles.is_empty() {
390 break;
391 }
392 if Instant::now() >= deadline {
393 eprintln!(
394 "FATAL_AGENTOS_VM_EXECUTOR_SHUTDOWN_TIMEOUT: {} executor(s) survived the {}ms process deadline; raise runtime.executor.teardownTimeoutMs",
395 session_handles.len(),
396 self.executor_teardown_timeout.as_millis()
397 );
398 std::process::abort();
402 }
403 thread::sleep(Duration::from_millis(5));
404 }
405 if let Ok(mut outputs) = self.session_outputs.lock() {
406 outputs.clear();
407 }
408 bridge::release_embedded_cbor_codec();
409 }
410}
411
412pub struct EmbeddedV8SessionHandle {
413 session_id: String,
414 output_generation: Option<u64>,
415 runtime: Arc<EmbeddedV8Runtime>,
416}
417
418impl EmbeddedV8SessionHandle {
419 #[allow(clippy::too_many_arguments)]
421 pub fn execute(
422 &self,
423 mode: u8,
424 file_path: String,
425 bridge_code: String,
426 post_restore_script: String,
427 userland_code: String,
428 high_resolution_time: bool,
429 user_code: String,
430 wasm_module_bytes: Option<Arc<Vec<u8>>>,
431 ) -> io::Result<()> {
432 validate_execute_mode(mode)?;
433 self.runtime.dispatch(RuntimeCommand::SendToSession {
434 session_id: self.session_id.clone(),
435 message: SessionMessage::Execute {
436 mode,
437 file_path,
438 bridge_code,
439 post_restore_script,
440 userland_code,
441 high_resolution_time,
442 user_code,
443 wasm_module_bytes,
444 },
445 })
446 }
447
448 pub fn send_bridge_response(
449 &self,
450 call_id: u64,
451 status: u8,
452 payload: Vec<u8>,
453 ) -> io::Result<()> {
454 validate_bridge_response_status(status)?;
455 self.runtime.settle_bridge_response(
456 &self.session_id,
457 self.output_generation,
458 BridgeResponse {
459 call_id,
460 status,
461 payload,
462 reservation: None,
463 },
464 )
465 }
466
467 pub fn send_stream_event(&self, event_type: &str, payload: Vec<u8>) -> io::Result<()> {
468 self.runtime.dispatch(RuntimeCommand::SendToSession {
469 session_id: self.session_id.clone(),
470 message: SessionMessage::StreamEvent(StreamEvent {
471 event_type: event_type.to_owned(),
472 payload,
473 }),
474 })
475 }
476
477 pub fn publish_readiness(
478 &self,
479 capability_id: u64,
480 capability_generation: u64,
481 flags: agentos_runtime::readiness::ReadyFlags,
482 ) -> io::Result<()> {
483 self.runtime.dispatch(RuntimeCommand::PublishReadiness {
484 session_id: self.session_id.clone(),
485 capability_id,
486 capability_generation,
487 flags,
488 })
489 }
490
491 pub fn remove_readiness(
492 &self,
493 capability_id: u64,
494 capability_generation: u64,
495 ) -> io::Result<()> {
496 self.runtime.dispatch(RuntimeCommand::RemoveReadiness {
497 session_id: self.session_id.clone(),
498 capability_id,
499 capability_generation,
500 })
501 }
502
503 pub fn set_application_read_interest(
504 &self,
505 capability_id: u64,
506 capability_generation: u64,
507 enabled: bool,
508 ) -> io::Result<()> {
509 self.runtime
510 .session_mgr
511 .lock()
512 .expect("session manager lock poisoned")
513 .set_application_read_interest(
514 &self.session_id,
515 capability_id,
516 capability_generation,
517 enabled,
518 )
519 .map_err(other_io_error)
520 }
521
522 pub fn publish_signal(&self, signal: i32) -> io::Result<()> {
523 self.runtime
524 .session_mgr
525 .lock()
526 .expect("session manager lock poisoned")
527 .publish_signal(&self.session_id, signal)
528 .map_err(other_io_error)
529 }
530
531 pub fn publish_timer(&self, timer_id: u64) -> io::Result<()> {
532 self.runtime.dispatch(RuntimeCommand::PublishTimer {
533 session_id: self.session_id.clone(),
534 timer_id,
535 })
536 }
537
538 pub fn set_module_reader(
542 &self,
543 reader: Box<dyn crate::execution::GuestModuleReader>,
544 ) -> io::Result<()> {
545 self.runtime
546 .dispatch(RuntimeCommand::SetSessionModuleReader {
547 session_id: self.session_id.clone(),
548 reader: ModuleReaderHandle::new(reader),
549 })
550 }
551
552 pub fn terminate(&self) -> io::Result<()> {
553 self.runtime.dispatch(RuntimeCommand::SendToSession {
554 session_id: self.session_id.clone(),
555 message: SessionMessage::TerminateExecution,
556 })
557 }
558
559 pub fn pause(&self) -> io::Result<()> {
560 self.runtime.dispatch(RuntimeCommand::PauseSession {
561 session_id: self.session_id.clone(),
562 })
563 }
564
565 pub fn resume(&self) -> io::Result<()> {
566 self.runtime.dispatch(RuntimeCommand::ResumeSession {
567 session_id: self.session_id.clone(),
568 })
569 }
570
571 pub fn destroy(&self) -> io::Result<()> {
572 let result = self.runtime.dispatch(RuntimeCommand::DestroySession {
577 session_id: self.session_id.clone(),
578 });
579 if let Some(generation) = self.output_generation {
580 remove_session_output_if_current(
581 &self.runtime.session_outputs,
582 &self.session_id,
583 generation,
584 );
585 } else {
586 self.runtime.unregister_session(&self.session_id);
587 }
588 result
589 }
590
591 pub fn session_id(&self) -> &str {
592 &self.session_id
593 }
594}
595
596fn validate_execute_mode(mode: u8) -> io::Result<()> {
597 if mode > 1 {
598 return Err(io::Error::new(
599 io::ErrorKind::InvalidInput,
600 format!("unknown Execute mode: {mode}"),
601 ));
602 }
603 Ok(())
604}
605
606impl Clone for EmbeddedV8SessionHandle {
607 fn clone(&self) -> Self {
608 Self {
609 session_id: self.session_id.clone(),
610 output_generation: self.output_generation,
611 runtime: Arc::clone(&self.runtime),
612 }
613 }
614}
615
616pub fn shared_embedded_runtime(
617 runtime: agentos_runtime::RuntimeContext,
618) -> io::Result<Arc<EmbeddedV8Runtime>> {
619 static SHARED_RUNTIME: OnceLock<Mutex<Weak<EmbeddedV8Runtime>>> = OnceLock::new();
620
621 let shared_slot = SHARED_RUNTIME.get_or_init(|| Mutex::new(Weak::new()));
622 let mut shared_guard = shared_slot
623 .lock()
624 .expect("shared embedded runtime init lock poisoned");
625 if let Some(shared) = shared_guard.upgrade() {
626 return Ok(shared);
627 }
628
629 let shared = Arc::new(EmbeddedV8Runtime::new(None, runtime)?);
630 *shared_guard = Arc::downgrade(&shared);
631 Ok(shared)
632}
633
634pub struct EmbeddedRuntimeHandle {
635 alive: Arc<AtomicBool>,
636 codec_released: AtomicBool,
637 shutdown_stream: UnixStream,
638 join_handle: Mutex<Option<thread::JoinHandle<()>>>,
639}
640
641impl EmbeddedRuntimeHandle {
642 pub fn is_alive(&self) -> bool {
643 self.alive.load(Ordering::Acquire)
644 }
645
646 pub fn shutdown(&self) {
647 let _ = self.shutdown_stream.shutdown(Shutdown::Both);
648 if let Ok(mut guard) = self.join_handle.lock() {
649 if let Some(handle) = guard.take() {
650 let _ = handle.join();
651 }
652 }
653 self.release_codec();
654 }
655
656 fn release_codec(&self) {
657 if !self.codec_released.swap(true, Ordering::AcqRel) {
658 bridge::release_embedded_cbor_codec();
659 }
660 }
661}
662
663impl Drop for EmbeddedRuntimeHandle {
664 fn drop(&mut self) {
665 let _ = self.shutdown_stream.shutdown(Shutdown::Both);
666 if let Some(handle) = self.join_handle.get_mut().ok().and_then(Option::take) {
667 let _ = handle.join();
668 }
669 self.release_codec();
670 }
671}
672
673pub fn spawn_embedded_runtime_ipc(
674 max_concurrency: Option<usize>,
675 runtime: agentos_runtime::RuntimeContext,
676) -> io::Result<(UnixStream, EmbeddedRuntimeHandle)> {
677 bridge::init_codec();
678 bridge::acquire_embedded_cbor_codec();
679 isolate::init_v8_platform();
680
681 let (host_stream, runtime_stream) = UnixStream::pair()?;
682 let shutdown_stream = host_stream.try_clone()?;
683 let alive = Arc::new(AtomicBool::new(true));
684 let alive_for_thread = Arc::clone(&alive);
685 let max_concurrency = max_concurrency.unwrap_or_else(|| runtime.max_active_vm_executors());
686
687 let join_handle = thread::Builder::new()
689 .name(String::from("agentos-v8-runtime"))
690 .spawn(move || {
691 run_embedded_runtime(runtime_stream, max_concurrency, runtime);
692 alive_for_thread.store(false, Ordering::Release);
693 })
694 .inspect_err(|_| bridge::release_embedded_cbor_codec())?;
695
696 Ok((
697 host_stream,
698 EmbeddedRuntimeHandle {
699 alive,
700 codec_released: AtomicBool::new(false),
701 shutdown_stream,
702 join_handle: Mutex::new(Some(join_handle)),
703 },
704 ))
705}
706
707fn run_embedded_runtime(
708 stream: UnixStream,
709 max_concurrency: usize,
710 runtime: agentos_runtime::RuntimeContext,
711) {
712 let snapshot_cache = Arc::new(SnapshotCache::new(8));
715 let writer_stream = match stream.try_clone() {
716 Ok(writer_stream) => writer_stream,
717 Err(error) => {
718 eprintln!("embedded V8 runtime failed to clone stream: {error}");
719 return;
720 }
721 };
722 let output_capacity = match crate::session::configured_resource_capacity(
723 &runtime,
724 ResourceClass::AsyncCompletions,
725 "limits.reactor.maxAsyncCompletions",
726 "runtime.resources.maxAsyncCompletions",
727 ) {
728 Ok(capacity) => capacity,
729 Err(error) => {
730 eprintln!("{error}");
731 return;
732 }
733 };
734 let (event_tx, event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(output_capacity);
735 let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
736 let connection_id = NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed);
737
738 let writer_handle = match thread::Builder::new()
740 .name(format!("v8-ipc-writer-{connection_id}"))
741 .spawn(move || ipc_writer_thread(event_rx, writer_stream))
742 {
743 Ok(handle) => handle,
744 Err(error) => {
745 eprintln!("embedded V8 runtime failed to spawn writer thread: {error}");
746 return;
747 }
748 };
749
750 let session_mgr = Arc::new(Mutex::new(SessionManager::new(
751 max_concurrency,
752 event_tx,
753 call_id_router,
754 Arc::clone(&snapshot_cache),
755 runtime,
756 )));
757
758 handle_connection(stream, connection_id, session_mgr, snapshot_cache);
759 let _ = writer_handle.join();
760}
761
762fn ipc_writer_thread(
763 rx: crossbeam_channel::Receiver<RuntimeEventEnvelope>,
764 mut writer: UnixStream,
765) {
766 while let Ok(envelope) = rx.recv() {
767 let frame: BinaryFrame = envelope.event.into();
768 let bytes = match crate::ipc_binary::frame_to_bytes(&frame) {
769 Ok(bytes) => bytes,
770 Err(error) => {
771 eprintln!("embedded V8 runtime writer encode error: {error}");
772 break;
773 }
774 };
775 if let Err(error) = writer.write_all(&bytes) {
776 eprintln!("embedded V8 runtime writer error: {error}");
777 break;
778 }
779 }
780}
781
782fn handle_connection(
783 mut stream: UnixStream,
784 connection_id: u64,
785 session_mgr: Arc<Mutex<SessionManager>>,
786 snapshot_cache: Arc<SnapshotCache>,
787) {
788 let mut session_ids = HashSet::new();
789
790 loop {
791 let frame = match crate::ipc_binary::read_frame(&mut stream) {
792 Ok(frame) => frame,
793 Err(ref error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
794 Err(error) => {
795 eprintln!("embedded V8 runtime read error on connection {connection_id}: {error}");
796 break;
797 }
798 };
799
800 let command = match RuntimeCommand::try_from(frame) {
801 Ok(command) => command,
802 Err(error) => {
803 eprintln!(
804 "embedded V8 runtime dispatch error on connection {connection_id}: {error}"
805 );
806 continue;
807 }
808 };
809
810 if let RuntimeCommand::CreateSession { session_id, .. } = &command {
811 session_ids.insert(session_id.clone());
812 } else if let RuntimeCommand::DestroySession { session_id } = &command {
813 session_ids.remove(session_id);
814 }
815
816 if let Err(error) = dispatch_runtime_command(&session_mgr, &snapshot_cache, command) {
817 eprintln!("embedded V8 runtime dispatch error on connection {connection_id}: {error}");
818 }
819 }
820
821 {
822 let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
823 for session_id in session_ids {
824 if let Err(error) = mgr.detach_session(&session_id) {
825 eprintln!(
826 "ERR_AGENTOS_VM_EXECUTOR_QUARANTINE: failed to detach session {session_id}: {error}"
827 );
828 }
829 }
830 }
831}
832
833fn dispatch_runtime_command(
834 session_mgr: &Arc<Mutex<SessionManager>>,
835 snapshot_cache: &Arc<SnapshotCache>,
836 command: RuntimeCommand,
837) -> io::Result<()> {
838 match command {
839 RuntimeCommand::CreateSession {
840 session_id,
841 heap_limit_mb,
842 cpu_time_limit_ms,
843 wall_clock_limit_ms,
844 warm_hint,
845 } => {
846 let mut mgr = session_mgr.lock().expect("session manager lock poisoned");
847 mgr.create_session_with_output_generation(
848 session_id,
849 heap_limit_mb,
850 cpu_time_limit_ms,
851 wall_clock_limit_ms,
852 None,
853 warm_hint,
854 )
855 .map_err(other_io_error)
856 }
857 RuntimeCommand::DestroySession { session_id } => {
858 let shutdown = session_mgr
863 .lock()
864 .expect("session manager lock poisoned")
865 .begin_destroy_session(&session_id)
866 .map_err(other_io_error)?;
867 shutdown.finish();
868 Ok(())
869 }
870 RuntimeCommand::PauseSession { session_id } => {
871 let mgr = session_mgr.lock().expect("session manager lock poisoned");
872 mgr.pause_session(&session_id).map_err(other_io_error)
873 }
874 RuntimeCommand::ResumeSession { session_id } => {
875 let mgr = session_mgr.lock().expect("session manager lock poisoned");
876 mgr.resume_session(&session_id).map_err(other_io_error)
877 }
878 RuntimeCommand::SendToSession {
879 session_id,
880 message,
881 } => {
882 let message = match message {
883 SessionMessage::BridgeResponse(response) => {
884 let (registry, output_generation) = {
885 let mgr = session_mgr.lock().expect("session manager lock poisoned");
886 (
887 Arc::clone(mgr.call_id_router()),
888 mgr.session_output_generation(&session_id),
889 )
890 };
891 let phase_start = Instant::now();
892 let result = registry
893 .settle(&session_id, output_generation, response)
894 .map(|_| ())
895 .map_err(other_io_error);
896 record_sync_bridge_host_phase(
897 "sync_rpc_dispatch",
898 "direct_response_settlement",
899 phase_start.elapsed(),
900 );
901 return result;
902 }
903 message => message,
904 };
905
906 {
907 let mgr = session_mgr.lock().expect("session manager lock poisoned");
908 let phase_start = Instant::now();
909 let result = mgr
910 .try_send_to_session(&session_id, message)
911 .map_err(other_io_error);
912 record_sync_bridge_host_phase(
913 "session_dispatch",
914 "nonblocking_command_admission",
915 phase_start.elapsed(),
916 );
917 result
918 }
919 }
920 RuntimeCommand::PublishReadiness {
921 session_id,
922 capability_id,
923 capability_generation,
924 flags,
925 } => session_mgr
926 .lock()
927 .expect("session manager lock poisoned")
928 .publish_readiness(&session_id, capability_id, capability_generation, flags)
929 .map_err(other_io_error),
930 RuntimeCommand::RemoveReadiness {
931 session_id,
932 capability_id,
933 capability_generation,
934 } => session_mgr
935 .lock()
936 .expect("session manager lock poisoned")
937 .remove_readiness(&session_id, capability_id, capability_generation)
938 .map_err(other_io_error),
939 RuntimeCommand::PublishSignal { session_id, signal } => session_mgr
940 .lock()
941 .expect("session manager lock poisoned")
942 .publish_signal(&session_id, signal)
943 .map_err(other_io_error),
944 RuntimeCommand::PublishTimer {
945 session_id,
946 timer_id,
947 } => session_mgr
948 .lock()
949 .expect("session manager lock poisoned")
950 .publish_timer(&session_id, timer_id)
951 .map_err(other_io_error),
952 RuntimeCommand::SetSessionModuleReader { session_id, reader } => {
953 let (sender, command_capacity) = {
956 let mgr = session_mgr.lock().expect("session manager lock poisoned");
957 mgr.session_sender(&session_id)
958 }
959 .map_err(other_io_error)?;
960 match reader.take() {
961 Some(reader) => sender
962 .try_send(SessionCommand::SetModuleReader(reader))
963 .map_err(|error| match error {
964 crossbeam_channel::TrySendError::Full(_) => other_io_error(format!(
965 "ERR_AGENTOS_SESSION_COMMAND_LIMIT: session {session_id} command queue exceeded limit of {command_capacity} while admitting module_reader (queued={}); raise limits.reactor.maxHandleCommands",
966 sender.len()
967 )),
968 crossbeam_channel::TrySendError::Disconnected(_) => other_io_error(
969 format!("session thread disconnected for session {session_id}"),
970 ),
971 }),
972 None => Ok(()),
973 }
974 }
975 RuntimeCommand::WarmSnapshot {
976 bridge_code,
977 userland_code,
978 } => snapshot_cache
979 .get_or_create_with_userland(
980 &bridge_code,
981 (!userland_code.is_empty()).then_some(userland_code.as_str()),
982 )
983 .map(|_| ())
984 .map_err(other_io_error),
985 }
986}
987
988#[cfg(test)]
989fn route_outbound_event(
990 envelope: RuntimeEventEnvelope,
991 session_outputs: &Arc<Mutex<HashMap<String, SessionOutput>>>,
992 session_mgr: &Arc<Mutex<SessionManager>>,
993) -> bool {
994 let RuntimeEventEnvelope {
995 output_generation,
996 event,
997 } = envelope;
998 let session_id = event.session_id().to_owned();
999
1000 let output = session_outputs
1001 .lock()
1002 .expect("embedded runtime session outputs lock poisoned")
1003 .get(&session_id)
1004 .cloned();
1005
1006 let Some(output) = output else {
1007 clear_dropped_bridge_call_route(&event, session_mgr);
1008 return false;
1009 };
1010
1011 if output_generation != Some(output.generation) {
1012 clear_dropped_bridge_call_route(&event, session_mgr);
1013 return false;
1014 }
1015
1016 match output.sender.try_send(event) {
1017 Ok(()) => {}
1018 Err(_) => {
1019 if remove_session_output_if_current(session_outputs, &session_id, output.generation) {
1020 return session_mgr
1021 .lock()
1022 .expect("session manager lock poisoned")
1023 .detach_session_if_output_generation(&session_id, output.generation)
1024 .unwrap_or(false);
1025 }
1026 }
1027 }
1028 false
1029}
1030
1031#[cfg(test)]
1032fn clear_dropped_bridge_call_route(event: &RuntimeEvent, session_mgr: &Arc<Mutex<SessionManager>>) {
1033 if let RuntimeEvent::BridgeCall { call_id, .. } = event {
1034 session_mgr
1035 .lock()
1036 .expect("session manager lock poisoned")
1037 .clear_call_route(*call_id);
1038 }
1039}
1040
1041fn remove_session_output_if_current(
1042 session_outputs: &Arc<Mutex<HashMap<String, SessionOutput>>>,
1043 session_id: &str,
1044 generation: u64,
1045) -> bool {
1046 let mut outputs = session_outputs
1047 .lock()
1048 .expect("embedded runtime session outputs lock poisoned");
1049 if outputs
1050 .get(session_id)
1051 .is_some_and(|output| output.generation == generation)
1052 {
1053 outputs.remove(session_id);
1054 return true;
1055 }
1056 false
1057}
1058
1059fn other_io_error(message: String) -> io::Error {
1060 io::Error::other(message)
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::runtime_protocol::{BridgeResponse, RuntimeCommand, RuntimeEvent, SessionMessage};
1067 use std::process::Command;
1068 use std::time::Duration;
1069
1070 static EMBEDDED_RUNTIME_CODEC_TEST_LOCK: Mutex<()> = Mutex::new(());
1071
1072 fn run_isolated_unit_test(env_name: &str, test_name: &str) -> bool {
1073 if std::env::var_os(env_name).is_some() {
1074 return true;
1075 }
1076 let output = Command::new(std::env::current_exe().expect("current test binary"))
1077 .arg(test_name)
1078 .arg("--exact")
1079 .arg("--nocapture")
1080 .env(env_name, "1")
1081 .output()
1082 .unwrap_or_else(|error| panic!("spawn isolated test {test_name}: {error}"));
1083 assert!(
1084 output.status.success(),
1085 "isolated test {test_name} failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
1086 output.status.code(),
1087 String::from_utf8_lossy(&output.stdout),
1088 String::from_utf8_lossy(&output.stderr),
1089 );
1090 false
1091 }
1092
1093 fn test_runtime_context() -> agentos_runtime::RuntimeContext {
1094 agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default())
1095 .expect("test process runtime")
1096 .context()
1097 }
1098
1099 fn test_output_channel(
1100 capacity: usize,
1101 ) -> (RuntimeEventOutputSender, RuntimeEventOutputReceiver) {
1102 let runtime = test_runtime_context();
1103 runtime_event_output_channel(capacity, Arc::clone(runtime.resources()))
1104 }
1105
1106 fn output_event(message: &str) -> RuntimeEvent {
1107 RuntimeEvent::Log {
1108 session_id: String::from("aggregate-test"),
1109 channel: 0,
1110 message: message.to_owned(),
1111 }
1112 }
1113
1114 #[test]
1115 fn session_outputs_share_one_vm_completion_limit() {
1116 use agentos_runtime::accounting::{ResourceLedger, ResourceLimit};
1117
1118 let resources = Arc::new(ResourceLedger::root(
1119 "v8-output-test-vm",
1120 [(
1121 ResourceClass::AsyncCompletions,
1122 ResourceLimit::new(2, "limits.reactor.maxAsyncCompletions"),
1123 )],
1124 ));
1125 let (first_tx, first_rx) = runtime_event_output_channel(2, Arc::clone(&resources));
1126 let (second_tx, second_rx) = runtime_event_output_channel(2, Arc::clone(&resources));
1127
1128 first_tx
1129 .try_send(output_event("first"))
1130 .expect("first session output admission");
1131 second_tx
1132 .try_send(output_event("second"))
1133 .expect("second session output admission");
1134 assert_eq!(resources.usage(ResourceClass::AsyncCompletions).used, 2);
1135
1136 let error = first_tx
1137 .try_send(output_event("overflow"))
1138 .expect_err("aggregate VM limit must span both session lanes");
1139 assert!(error.contains("limits.reactor.maxAsyncCompletions"));
1140
1141 first_rx.try_recv().expect("release one output reservation");
1142 second_tx
1143 .try_send(output_event("replacement"))
1144 .expect("released slot can be used by another session lane");
1145 drop(first_rx);
1146 drop(second_rx);
1147 assert_eq!(
1148 resources.usage(ResourceClass::AsyncCompletions).used,
1149 0,
1150 "session output teardown must release queued completion reservations"
1151 );
1152
1153 let (disconnected_tx, disconnected_rx) =
1154 runtime_event_output_channel(1, Arc::clone(&resources));
1155 drop(disconnected_rx);
1156 disconnected_tx
1157 .try_send(output_event("disconnected"))
1158 .expect_err("disconnected output must reject insertion");
1159 assert_eq!(
1160 resources.usage(ResourceClass::AsyncCompletions).used,
1161 0,
1162 "failed insertion must release its reservation"
1163 );
1164 }
1165
1166 #[test]
1167 fn embedded_runtime_uses_configured_executor_and_output_bounds() {
1168 if !run_isolated_unit_test(
1169 "AGENTOS_V8_CONFIGURED_EMBEDDED_RUNTIME_SUBPROCESS",
1170 "embedded_runtime::tests::embedded_runtime_uses_configured_executor_and_output_bounds",
1171 ) {
1172 return;
1173 }
1174 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1175 .lock()
1176 .expect("embedded runtime codec test lock poisoned");
1177 let mut config = agentos_runtime::RuntimeConfig {
1178 max_active_vm_executors: 2,
1179 vm_executor_teardown_timeout_ms: 31,
1180 ..agentos_runtime::RuntimeConfig::default()
1181 };
1182 config.resources.max_async_completions = 3;
1183 let runtime_context = agentos_runtime::SidecarRuntime::process(&config)
1184 .expect("configured process runtime")
1185 .context();
1186 let runtime = EmbeddedV8Runtime::new(None, runtime_context.clone())
1187 .expect("configured embedded runtime");
1188
1189 assert_eq!(
1190 runtime
1191 .session_mgr
1192 .lock()
1193 .expect("session manager")
1194 .max_concurrency(),
1195 2
1196 );
1197 assert_eq!(runtime.executor_teardown_timeout, Duration::from_millis(31));
1198 let (_receiver, registration) = runtime
1199 .register_session_with_runtime("configured-output", &runtime_context)
1200 .expect("register configured output lane");
1201 let outputs = runtime.session_outputs.lock().expect("session outputs");
1202 assert_eq!(outputs[®istration.session_id].sender.capacity(), Some(3));
1203 }
1204
1205 #[test]
1206 fn embedded_runtime_handle_reports_liveness_and_shutdown() {
1207 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1208 .lock()
1209 .expect("embedded runtime codec test lock poisoned");
1210 let (_stream, handle) = spawn_embedded_runtime_ipc(Some(1), test_runtime_context())
1211 .expect("spawn embedded runtime");
1212 assert!(
1213 handle.is_alive(),
1214 "embedded runtime should be alive after spawn"
1215 );
1216 handle.shutdown();
1217 assert!(
1218 !handle.is_alive(),
1219 "embedded runtime should report not alive after shutdown"
1220 );
1221 }
1222
1223 #[test]
1224 fn embedded_runtime_session_shared_runtime_is_lazy() {
1225 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1226 .lock()
1227 .expect("embedded runtime codec test lock poisoned");
1228 let first =
1229 shared_embedded_runtime(test_runtime_context()).expect("shared embedded runtime");
1230 let second =
1231 shared_embedded_runtime(test_runtime_context()).expect("shared embedded runtime");
1232 assert!(
1233 Arc::ptr_eq(&first, &second),
1234 "shared_embedded_runtime() should reuse the same runtime instance"
1235 );
1236 }
1237
1238 #[test]
1239 fn in_process_session_creation_preserves_vm_resource_scope() {
1240 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1241 .lock()
1242 .expect("embedded runtime codec test lock poisoned");
1243 let process = test_runtime_context();
1244 let vm_resources = Arc::new(agentos_runtime::accounting::ResourceLedger::child(
1245 "embedded-runtime-vm",
1246 [
1247 (
1248 agentos_runtime::accounting::ResourceClass::BridgeCalls,
1249 agentos_runtime::accounting::ResourceLimit::new(
1250 1,
1251 "limits.reactor.maxBridgeCalls",
1252 ),
1253 ),
1254 (
1255 agentos_runtime::accounting::ResourceClass::HandleCommands,
1256 agentos_runtime::accounting::ResourceLimit::new(
1257 2,
1258 "limits.reactor.maxHandleCommands",
1259 ),
1260 ),
1261 (
1262 agentos_runtime::accounting::ResourceClass::ReadyHandles,
1263 agentos_runtime::accounting::ResourceLimit::new(
1264 2,
1265 "limits.reactor.maxReadyHandles",
1266 ),
1267 ),
1268 (
1269 agentos_runtime::accounting::ResourceClass::Timers,
1270 agentos_runtime::accounting::ResourceLimit::new(
1271 2,
1272 "limits.jsRuntime.maxTimers",
1273 ),
1274 ),
1275 ],
1276 Arc::clone(process.resources()),
1277 ));
1278 let vm_runtime = process.scoped_for_vm(Arc::clone(&vm_resources), 42);
1279 let runtime = EmbeddedV8Runtime::new(Some(1), process).expect("embedded runtime");
1280
1281 runtime
1282 .dispatch_create_session_with_runtime(
1283 RuntimeCommand::CreateSession {
1284 session_id: "vm-scoped-session".into(),
1285 heap_limit_mb: None,
1286 cpu_time_limit_ms: None,
1287 wall_clock_limit_ms: None,
1288 warm_hint: None,
1289 },
1290 vm_runtime,
1291 64,
1292 std::time::Duration::from_secs(30),
1293 )
1294 .expect("create VM-scoped session");
1295
1296 let actual = runtime
1297 .session_mgr
1298 .lock()
1299 .expect("session manager lock poisoned")
1300 .session_resources("vm-scoped-session")
1301 .expect("session resources");
1302 assert!(
1303 Arc::ptr_eq(&actual, &vm_resources),
1304 "session work must retain the caller's VM ledger"
1305 );
1306 }
1307
1308 #[test]
1309 fn embedded_runtime_drop_releases_codec_after_destroying_sessions() {
1310 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1311 .lock()
1312 .expect("embedded runtime codec test lock poisoned");
1313 let codec_before = bridge::is_cbor_codec();
1314 let alive = {
1315 let runtime =
1316 EmbeddedV8Runtime::new(Some(1), test_runtime_context()).expect("embedded runtime");
1317 let alive = Arc::clone(&runtime.alive);
1318 assert!(
1319 bridge::is_cbor_codec(),
1320 "embedded runtime should enable the CBOR bridge codec while alive"
1321 );
1322 let (_receiver, _registration) = runtime
1323 .register_session_with_output_registration("drop-lifecycle")
1324 .expect("register session output");
1325 runtime
1326 .dispatch(RuntimeCommand::CreateSession {
1327 session_id: "drop-lifecycle".into(),
1328 heap_limit_mb: None,
1329 cpu_time_limit_ms: None,
1330 wall_clock_limit_ms: None,
1331 warm_hint: None,
1332 })
1333 .expect("create session");
1334 assert_eq!(
1335 runtime.session_count(),
1336 1,
1337 "test should drop a runtime with a live session"
1338 );
1339 alive
1340 };
1341
1342 assert!(
1343 !alive.load(Ordering::Acquire),
1344 "dropping embedded runtime should stop the dispatch thread"
1345 );
1346 assert_eq!(
1347 bridge::is_cbor_codec(),
1348 codec_before,
1349 "dropping embedded runtime should restore the prior codec state"
1350 );
1351 }
1352
1353 #[test]
1354 fn embedded_runtime_bridge_response_requires_matching_session_generation() {
1355 let snapshot_cache = Arc::new(SnapshotCache::new(1));
1356 let (event_tx, _event_rx) = crossbeam_channel::unbounded::<RuntimeEventEnvelope>();
1357 let call_id_router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
1358 let runtime = test_runtime_context();
1359 let session_mgr = Arc::new(Mutex::new(SessionManager::new(
1360 1,
1361 event_tx,
1362 Arc::clone(&call_id_router),
1363 Arc::clone(&snapshot_cache),
1364 runtime.clone(),
1365 )));
1366
1367 {
1368 let mut mgr = session_mgr.lock().expect("session manager");
1369 mgr.create_session("stream-target".into(), None, None, None)
1370 .expect("create target session");
1371 }
1372 let waiter = call_id_router
1373 .register_sync(&runtime, 0, 1, 41, "stream-target", None)
1374 .expect("register bridge call target");
1375
1376 let error = dispatch_runtime_command(
1377 &session_mgr,
1378 &snapshot_cache,
1379 RuntimeCommand::SendToSession {
1380 session_id: "wrong-session".into(),
1381 message: SessionMessage::BridgeResponse(BridgeResponse {
1382 call_id: 41,
1383 status: 0,
1384 payload: vec![0xAB],
1385 reservation: None,
1386 }),
1387 },
1388 )
1389 .expect_err("wrong-session bridge response must be rejected");
1390 assert!(
1391 error
1392 .to_string()
1393 .contains("ERR_AGENTOS_BRIDGE_STALE_GENERATION"),
1394 "wrong-session rejection should be typed: {error}"
1395 );
1396 assert_eq!(call_id_router.pending_len(), 1);
1397
1398 dispatch_runtime_command(
1399 &session_mgr,
1400 &snapshot_cache,
1401 RuntimeCommand::SendToSession {
1402 session_id: "stream-target".into(),
1403 message: SessionMessage::BridgeResponse(BridgeResponse {
1404 call_id: 41,
1405 status: 0,
1406 payload: vec![0xAB],
1407 reservation: None,
1408 }),
1409 },
1410 )
1411 .expect("matching bridge response should settle directly");
1412 assert_eq!(
1413 waiter.recv().expect("settled bridge response").payload,
1414 vec![0xAB]
1415 );
1416 assert_eq!(call_id_router.pending_len(), 0);
1417
1418 session_mgr
1419 .lock()
1420 .expect("session manager")
1421 .destroy_session("stream-target")
1422 .expect("destroy target session");
1423 }
1424
1425 #[test]
1426 fn embedded_runtime_session_handle_rejects_unknown_bridge_response_status() {
1427 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1428 .lock()
1429 .expect("embedded runtime codec test lock poisoned");
1430 let runtime = Arc::new(
1431 EmbeddedV8Runtime::new(Some(1), test_runtime_context()).expect("embedded runtime"),
1432 );
1433 let handle = runtime.session_handle("missing-session".into());
1434
1435 let err = handle
1436 .send_bridge_response(1, 3, Vec::new())
1437 .expect_err("unknown bridge response status should be rejected");
1438
1439 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1440 assert!(err.to_string().contains("unknown BridgeResponse status"));
1441 }
1442
1443 #[test]
1444 fn embedded_runtime_stream_events_preserve_order_per_session() {
1445 let (sender, receiver) = test_output_channel(TEST_SESSION_OUTPUT_CHANNEL_CAPACITY);
1446 let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1447 String::from("stream-order"),
1448 SessionOutput {
1449 generation: 1,
1450 sender,
1451 },
1452 )])));
1453 let session_mgr = test_session_manager();
1454
1455 route_outbound_event(
1456 runtime_envelope(
1457 1,
1458 RuntimeEvent::Log {
1459 session_id: "stream-order".into(),
1460 channel: 0,
1461 message: "first".into(),
1462 },
1463 ),
1464 &session_outputs,
1465 &session_mgr,
1466 );
1467 route_outbound_event(
1468 runtime_envelope(
1469 1,
1470 RuntimeEvent::StreamCallback {
1471 session_id: "stream-order".into(),
1472 callback_type: "stdin".into(),
1473 payload: vec![1, 2, 3],
1474 },
1475 ),
1476 &session_outputs,
1477 &session_mgr,
1478 );
1479
1480 let first = receiver
1481 .recv_timeout(Duration::from_millis(100))
1482 .expect("first event");
1483 let second = receiver
1484 .recv_timeout(Duration::from_millis(100))
1485 .expect("second event");
1486
1487 assert!(matches!(
1488 first,
1489 RuntimeEvent::Log { ref message, .. } if message == "first"
1490 ));
1491 assert!(matches!(
1492 second,
1493 RuntimeEvent::StreamCallback { ref callback_type, ref payload, .. }
1494 if callback_type == "stdin" && payload == &vec![1, 2, 3]
1495 ));
1496 }
1497
1498 #[test]
1499 fn embedded_runtime_stream_termination_race_drops_late_events_after_receiver_close() {
1500 let (sender, receiver) = test_output_channel(TEST_SESSION_OUTPUT_CHANNEL_CAPACITY);
1501 let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1502 String::from("stream-race"),
1503 SessionOutput {
1504 generation: 1,
1505 sender,
1506 },
1507 )])));
1508 let session_mgr = test_session_manager();
1509 drop(receiver);
1510
1511 route_outbound_event(
1512 runtime_envelope(
1513 1,
1514 RuntimeEvent::ExecutionResult {
1515 session_id: "stream-race".into(),
1516 exit_code: 0,
1517 exports: None,
1518 error: None,
1519 },
1520 ),
1521 &session_outputs,
1522 &session_mgr,
1523 );
1524
1525 assert!(
1526 session_outputs
1527 .lock()
1528 .expect("session outputs")
1529 .get("stream-race")
1530 .is_none(),
1531 "late events should drop stale receiver registrations during teardown races"
1532 );
1533 }
1534
1535 #[test]
1536 fn embedded_runtime_stream_backpressure_drops_full_session_output() {
1537 let (sender, receiver) = test_output_channel(1);
1538 let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1539 String::from("stream-full"),
1540 SessionOutput {
1541 generation: 1,
1542 sender,
1543 },
1544 )])));
1545 let session_mgr = test_session_manager_with_session("stream-full");
1546
1547 route_outbound_event(
1548 runtime_envelope(
1549 1,
1550 RuntimeEvent::Log {
1551 session_id: "stream-full".into(),
1552 channel: 0,
1553 message: "first".into(),
1554 },
1555 ),
1556 &session_outputs,
1557 &session_mgr,
1558 );
1559 let cleaned_up = route_outbound_event(
1560 runtime_envelope(
1561 1,
1562 RuntimeEvent::Log {
1563 session_id: "stream-full".into(),
1564 channel: 0,
1565 message: "second".into(),
1566 },
1567 ),
1568 &session_outputs,
1569 &session_mgr,
1570 );
1571 assert!(cleaned_up, "full session output should detach the session");
1572
1573 let first = receiver
1574 .recv_timeout(Duration::from_millis(100))
1575 .expect("first event");
1576 assert!(matches!(
1577 first,
1578 RuntimeEvent::Log { ref message, .. } if message == "first"
1579 ));
1580 assert!(
1581 receiver.recv_timeout(Duration::from_millis(20)).is_err(),
1582 "full session output should drop the overflowing event"
1583 );
1584 assert!(
1585 session_outputs
1586 .lock()
1587 .expect("session outputs")
1588 .get("stream-full")
1589 .is_none(),
1590 "full session output should remove the stale registration"
1591 );
1592 assert_eq!(
1593 session_mgr.lock().expect("session manager").session_count(),
1594 0,
1595 "full session output should destroy the runtime session"
1596 );
1597 }
1598
1599 #[test]
1600 fn embedded_runtime_drops_stale_generation_events_for_reused_session_id() {
1601 let (sender, receiver) = test_output_channel(TEST_SESSION_OUTPUT_CHANNEL_CAPACITY);
1602 let session_outputs = Arc::new(Mutex::new(HashMap::from([(
1603 String::from("stream-reused"),
1604 SessionOutput {
1605 generation: 2,
1606 sender,
1607 },
1608 )])));
1609 let session_mgr = test_session_manager_with_generation("stream-reused", 2);
1610 let runtime = test_runtime_context();
1611 let _waiter = session_mgr
1612 .lock()
1613 .expect("session manager")
1614 .call_id_router()
1615 .register_sync(&runtime, 0, 1, 99, "stream-reused", Some(1))
1616 .expect("register stale bridge call target");
1617
1618 let routed = route_outbound_event(
1619 runtime_envelope(
1620 1,
1621 RuntimeEvent::BridgeCall {
1622 session_id: "stream-reused".into(),
1623 call_id: 99,
1624 method: "_stale".into(),
1625 payload: Vec::new(),
1626 },
1627 ),
1628 &session_outputs,
1629 &session_mgr,
1630 );
1631
1632 assert!(!routed, "stale generation event should not trigger cleanup");
1633 assert!(
1634 receiver.recv_timeout(Duration::from_millis(20)).is_err(),
1635 "stale generation event should not reach reused session output"
1636 );
1637 assert_eq!(
1638 session_mgr.lock().expect("session manager").session_count(),
1639 1,
1640 "stale generation event must leave reused session alive"
1641 );
1642 assert!(
1643 session_mgr
1644 .lock()
1645 .expect("session manager")
1646 .call_id_router()
1647 .pending_len()
1648 == 0,
1649 "stale bridge calls should clear their call route"
1650 );
1651 }
1652
1653 #[test]
1654 fn embedded_runtime_clears_bridge_route_when_output_is_missing() {
1655 let session_outputs = Arc::new(Mutex::new(HashMap::new()));
1656 let session_mgr = test_session_manager();
1657 let runtime = test_runtime_context();
1658 let _waiter = session_mgr
1659 .lock()
1660 .expect("session manager")
1661 .call_id_router()
1662 .register_sync(&runtime, 0, 1, 123, "stream-detached", None)
1663 .expect("register detached bridge call target");
1664
1665 let routed = route_outbound_event(
1666 runtime_envelope(
1667 1,
1668 RuntimeEvent::BridgeCall {
1669 session_id: "stream-detached".into(),
1670 call_id: 123,
1671 method: "_detached".into(),
1672 payload: Vec::new(),
1673 },
1674 ),
1675 &session_outputs,
1676 &session_mgr,
1677 );
1678
1679 assert!(!routed, "missing output should not route the bridge call");
1680 assert!(
1681 session_mgr
1682 .lock()
1683 .expect("session manager")
1684 .call_id_router()
1685 .pending_len()
1686 == 0,
1687 "bridge calls dropped with no output should clear their call route"
1688 );
1689 }
1690
1691 #[test]
1692 fn embedded_runtime_stale_output_registration_cannot_destroy_reused_session_id() {
1693 let _codec_guard = EMBEDDED_RUNTIME_CODEC_TEST_LOCK
1694 .lock()
1695 .expect("embedded runtime codec test lock poisoned");
1696 let runtime = Arc::new(
1697 EmbeddedV8Runtime::new(Some(1), test_runtime_context()).expect("embedded runtime"),
1698 );
1699 let session_id = "stream-generation-reuse";
1700 let (_first_receiver, first_registration) = runtime
1701 .register_session_with_capacity(session_id, 1, Arc::clone(runtime.runtime.resources()))
1702 .expect("register first session output");
1703 runtime
1704 .dispatch(RuntimeCommand::CreateSession {
1705 session_id: session_id.into(),
1706 heap_limit_mb: None,
1707 cpu_time_limit_ms: None,
1708 wall_clock_limit_ms: None,
1709 warm_hint: None,
1710 })
1711 .expect("create first session");
1712 runtime
1713 .session_handle(session_id.into())
1714 .destroy()
1715 .expect("destroy first session");
1716
1717 let deadline = Instant::now() + Duration::from_secs(5);
1718 loop {
1719 let reconciled = {
1720 let mut manager = runtime
1721 .session_mgr
1722 .lock()
1723 .expect("session manager lock poisoned");
1724 manager.quarantined_session_count() == 0 && manager.active_slot_count() == 0
1725 };
1726 if reconciled {
1727 break;
1728 }
1729 assert!(
1730 Instant::now() < deadline,
1731 "destroyed generation did not release its quarantined executor permit"
1732 );
1733 thread::yield_now();
1734 }
1735
1736 let (_second_receiver, _second_registration) = runtime
1737 .register_session_with_capacity(session_id, 1, Arc::clone(runtime.runtime.resources()))
1738 .expect("register reused session output");
1739 runtime
1740 .dispatch(RuntimeCommand::CreateSession {
1741 session_id: session_id.into(),
1742 heap_limit_mb: None,
1743 cpu_time_limit_ms: None,
1744 wall_clock_limit_ms: None,
1745 warm_hint: None,
1746 })
1747 .expect("create reused session");
1748
1749 assert!(
1750 !runtime
1751 .destroy_session_if_output_current(&first_registration)
1752 .expect("stale destroy should be ignored"),
1753 "stale registration should not match the reused session output"
1754 );
1755 assert_eq!(
1756 runtime.session_count(),
1757 1,
1758 "stale registration must not destroy the reused session"
1759 );
1760
1761 runtime
1762 .session_handle(session_id.into())
1763 .destroy()
1764 .expect("destroy reused session");
1765 }
1766
1767 #[test]
1768 fn session_cleanup_generation_guard_does_not_destroy_reused_session_id() {
1769 let session_mgr = test_session_manager();
1770 {
1771 let mut mgr = session_mgr.lock().expect("session manager");
1772 mgr.create_session_with_output_generation(
1773 "reused".into(),
1774 None,
1775 None,
1776 None,
1777 Some(1),
1778 None,
1779 )
1780 .expect("create first session");
1781 mgr.destroy_session("reused")
1782 .expect("destroy first session");
1783 mgr.create_session_with_output_generation(
1784 "reused".into(),
1785 None,
1786 None,
1787 None,
1788 Some(2),
1789 None,
1790 )
1791 .expect("create reused session");
1792
1793 assert!(
1794 !mgr.destroy_session_if_output_generation("reused", 1)
1795 .expect("stale generation destroy should be ignored"),
1796 "stale cleanup generation should not match reused session"
1797 );
1798 assert_eq!(
1799 mgr.session_count(),
1800 1,
1801 "stale cleanup generation must leave reused session alive"
1802 );
1803 mgr.destroy_session("reused")
1804 .expect("destroy reused session");
1805 }
1806 }
1807
1808 fn test_session_manager() -> Arc<Mutex<SessionManager>> {
1809 let (event_tx, _event_rx) = crossbeam_channel::bounded::<RuntimeEventEnvelope>(1);
1810 Arc::new(Mutex::new(SessionManager::new(
1811 1,
1812 event_tx,
1813 Arc::new(BridgeCallRegistry::with_default_limit()),
1814 Arc::new(SnapshotCache::new(1)),
1815 test_runtime_context(),
1816 )))
1817 }
1818
1819 fn runtime_envelope(output_generation: u64, event: RuntimeEvent) -> RuntimeEventEnvelope {
1820 RuntimeEventEnvelope {
1821 output_generation: Some(output_generation),
1822 event,
1823 }
1824 }
1825
1826 fn test_session_manager_with_session(session_id: &str) -> Arc<Mutex<SessionManager>> {
1827 test_session_manager_with_generation(session_id, 1)
1828 }
1829
1830 fn test_session_manager_with_generation(
1831 session_id: &str,
1832 output_generation: u64,
1833 ) -> Arc<Mutex<SessionManager>> {
1834 let session_mgr = test_session_manager();
1835 session_mgr
1836 .lock()
1837 .expect("session manager")
1838 .create_session_with_output_generation(
1839 session_id.into(),
1840 None,
1841 None,
1842 None,
1843 Some(output_generation),
1844 None,
1845 )
1846 .expect("create test session");
1847 session_mgr
1848 }
1849}