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::{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        // Keep bridge-only, agent-SDK, and wasm-runner userland variants warm
63        // without immediately evicting each other.
64        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(&registration.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                &registration.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                &registration.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    /// Dispatch an in-process create-session command with the VM-scoped runtime
299    /// that owns guest work. Serialized IPC commands cannot carry this trusted
300    /// host capability and continue to use the manager's process context.
301    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                // A live untrusted executor may not be detached while the
399                // process continues. Process shutdown is the final containment
400                // boundary when cooperative V8 termination fails.
401                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    // In-process runtime protocol call; the arg list mirrors the Execute frame.
420    #[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    /// Install a direct module-source reader on this session's thread so module
539    /// loads read source directly instead of round-tripping the bridge. Routed
540    /// through the dispatch thread (which owns the session manager).
541    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        // Keep the output lane registered while the session executor joins so
573        // terminal results and lifecycle diagnostics can drain. Removing it
574        // first closes the receiver underneath the executor. Generation-check
575        // the final removal so a concurrently reused session ID keeps its lane.
576        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    // AGENTOS_THREAD_SITE: embedded-v8-dispatch
688    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    // Keep bridge-only, agent-SDK, and wasm-runner userland variants warm
713    // without immediately evicting each other.
714    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    // AGENTOS_THREAD_SITE: embedded-v8-writer
739    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            // Explicit destruction is a quiescence boundary. Remove the entry
859            // while holding the manager lock, then join after releasing it so
860            // the executor cannot leak into a successor session and the event
861            // dispatcher remains free to drain terminal output.
862            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            // Resolve the sender under the lock, release, then forward the live
954            // reader as a SetModuleReader command to the session thread.
955            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[&registration.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}