Skip to main content

agentos_v8_runtime/
session.rs

1// Session management: create/destroy sessions with V8 isolates on dedicated threads
2
3#[cfg(not(test))]
4use std::collections::BTreeMap;
5use std::collections::{HashMap, HashSet};
6use std::sync::atomic::AtomicU64;
7use std::sync::{Arc, Condvar, Mutex, OnceLock};
8use std::thread;
9use std::time::{Duration, Instant};
10
11#[cfg(not(test))]
12use agentos_bridge::queue_tracker::warn_limit_exhausted;
13use agentos_bridge::queue_tracker::{register_queue, QueueGauge, TrackedLimit};
14use agentos_bridge::{bridge_contract, BridgeCallConvention};
15use agentos_runtime::accounting::{Reservation, ResourceClass, ResourceLedger};
16use agentos_runtime::metrics::{ExecutorMetricClass, RuntimeMetrics};
17use agentos_runtime::readiness::{
18    ReadyAcknowledgement, ReadyBatch as RuntimeReadyBatch, ReadyFlags, ReadyObservation, ReadyWake,
19    SessionReadyBroker as RuntimeSessionReadyBroker,
20};
21use agentos_runtime::RuntimeContext;
22use crossbeam_channel::{Receiver, Select, Sender};
23
24use crate::execution;
25#[cfg(test)]
26use crate::host_call::BridgeCallRegistry;
27#[cfg(not(test))]
28use crate::host_call::{BridgeCallContext, ChannelRuntimeEventSender};
29use crate::host_call::{CallIdRouter, SharedCallIdCounter};
30use crate::ipc::ExecutionError;
31#[cfg(not(test))]
32use crate::ipc_binary::ExecutionErrorBin;
33use crate::runtime_protocol::{
34    BridgeResponse, RuntimeEvent, SessionMessage, StreamEvent, WarmSessionHint,
35};
36use crate::snapshot::{snapshot_cache_key, SnapshotCache, SnapshotCacheKey};
37#[cfg(not(test))]
38use crate::{bridge, isolate, snapshot};
39
40/// Commands sent to a session thread
41pub enum SessionCommand {
42    /// Shut down the session and destroy the isolate
43    Shutdown,
44    /// Forward a typed session message to the session thread for processing
45    Message(SessionMessage),
46    /// Install a direct module-source reader on the session thread. Carried as a
47    /// live object over the in-process command channel (NOT a serialized frame),
48    /// so subsequent module loads on this thread read source directly instead of
49    /// round-tripping the bridge. Sent just before an Execute message.
50    SetModuleReader(Box<dyn crate::execution::GuestModuleReader>),
51    /// A bounded capability-identity batch drained from the session's
52    /// dedicated readiness lane. Durable data remains in the owning subsystem.
53    ReadyBatch(RuntimeReadyBatch),
54}
55
56#[derive(Debug)]
57struct SessionReadyWakeState {
58    runtime_wake_rx: tokio::sync::mpsc::Receiver<ReadyWake>,
59}
60
61/// VM-scoped adapter from the Tokio broker's capacity-one wake lane to the
62/// thread-affine V8 executor's capacity-one crossbeam lane.
63#[derive(Debug)]
64struct SessionReadiness {
65    generation: u64,
66    max_batch_handles: usize,
67    broker: RuntimeSessionReadyBroker,
68    wakes: Mutex<SessionReadyWakeState>,
69    executor_wake_tx: Sender<ReadyWake>,
70}
71
72impl SessionReadiness {
73    fn new(
74        generation: u64,
75        runtime: &RuntimeContext,
76        max_batch_handles: usize,
77    ) -> Result<(Arc<Self>, Receiver<ReadyWake>), String> {
78        if max_batch_handles == 0 {
79            return Err(String::from(
80                "ERR_AGENTOS_READY_BATCH_LIMIT: limits.reactor.workQuantum must be greater than zero",
81            ));
82        }
83        let (broker, runtime_wake_rx) = RuntimeSessionReadyBroker::new_with_resources(
84            generation,
85            Arc::clone(runtime.resources()),
86            runtime.metrics().clone(),
87        )
88        .map_err(|error| error.to_string())?;
89        let (executor_wake_tx, executor_wake_rx) = crossbeam_channel::bounded(1);
90        Ok((
91            Arc::new(Self {
92                generation,
93                max_batch_handles,
94                broker,
95                wakes: Mutex::new(SessionReadyWakeState { runtime_wake_rx }),
96                executor_wake_tx,
97            }),
98            executor_wake_rx,
99        ))
100    }
101
102    /// Readiness-disabled adapter for the standalone event-loop test seam. It
103    /// has no publication handle; production sessions always use `new` with
104    /// their VM-scoped runtime and configured capability bound.
105    fn disabled(generation: u64) -> Result<(Arc<Self>, Receiver<ReadyWake>), String> {
106        let (broker, runtime_wake_rx) =
107            RuntimeSessionReadyBroker::new(generation, 1).map_err(|error| error.to_string())?;
108        let (executor_wake_tx, executor_wake_rx) = crossbeam_channel::bounded(1);
109        Ok((
110            Arc::new(Self {
111                generation,
112                max_batch_handles: 1,
113                broker,
114                wakes: Mutex::new(SessionReadyWakeState { runtime_wake_rx }),
115                executor_wake_tx,
116            }),
117            executor_wake_rx,
118        ))
119    }
120
121    fn publish(
122        &self,
123        capability_id: u64,
124        capability_generation: u64,
125        flags: ReadyFlags,
126    ) -> Result<(), String> {
127        self.broker
128            .mark_ready(self.generation, capability_id, capability_generation, flags)
129            .map_err(|error| error.to_string())?;
130        let mut state = self.wakes.lock().map_err(|_| {
131            String::from("ERR_AGENTOS_READY_STATE_POISONED: session readiness lock poisoned")
132        })?;
133        self.forward_runtime_wake_locked(&mut state)
134    }
135
136    fn publish_signal(&self, signal: i32) -> Result<(), String> {
137        self.broker
138            .mark_signal_ready(self.generation, signal)
139            .map_err(|error| error.to_string())?;
140        let mut state = self.wakes.lock().map_err(|_| {
141            String::from("ERR_AGENTOS_READY_STATE_POISONED: session readiness lock poisoned")
142        })?;
143        self.forward_runtime_wake_locked(&mut state)
144    }
145
146    fn remove(&self, capability_id: u64, capability_generation: u64) -> Result<(), String> {
147        self.broker
148            .remove_capability(self.generation, capability_id, capability_generation)
149            .map_err(|error| error.to_string())
150    }
151
152    fn set_application_read_interest(
153        &self,
154        capability_id: u64,
155        capability_generation: u64,
156        enabled: bool,
157    ) -> Result<(), String> {
158        self.broker
159            .set_application_read_interest(
160                self.generation,
161                capability_id,
162                capability_generation,
163                enabled,
164            )
165            .map_err(|error| error.to_string())
166    }
167
168    fn publish_timer(&self, timer_id: u64) -> Result<(), String> {
169        self.broker
170            .mark_timer_ready(self.generation, timer_id)
171            .map_err(|error| error.to_string())?;
172        let mut state = self.wakes.lock().map_err(|_| {
173            String::from("ERR_AGENTOS_READY_STATE_POISONED: session readiness lock poisoned")
174        })?;
175        self.forward_runtime_wake_locked(&mut state)
176    }
177
178    fn forward_runtime_wake_locked(&self, state: &mut SessionReadyWakeState) -> Result<(), String> {
179        let wake = match state.runtime_wake_rx.try_recv() {
180            Ok(wake) => wake,
181            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => return Ok(()),
182            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
183                return Err(String::from(
184                    "ERR_AGENTOS_READY_WAKE_DISCONNECTED: shared readiness wake source disconnected",
185                ));
186            }
187        };
188        match self.executor_wake_tx.try_send(wake) {
189            Ok(()) => Ok(()),
190            Err(crossbeam_channel::TrySendError::Full(_)) => Err(String::from(
191                "ERR_AGENTOS_READY_WAKE_INVARIANT: executor readiness lane was full for a shared wake",
192            )),
193            Err(crossbeam_channel::TrySendError::Disconnected(_)) => Err(String::from(
194                "ERR_AGENTOS_READY_WAKE_DISCONNECTED: executor readiness consumer disconnected",
195            )),
196        }
197    }
198
199    fn take_batch(&self, wake: ReadyWake) -> Result<RuntimeReadyBatch, String> {
200        self.broker
201            .ready_batch(wake.generation, wake.epoch, self.max_batch_handles)
202            .map_err(|error| error.to_string())
203    }
204
205    fn drain_signals(&self, batch: &RuntimeReadyBatch) -> Result<Vec<i32>, String> {
206        self.broker
207            .drain_signals(batch.generation, batch.epoch, self.max_batch_handles)
208            .map_err(|error| error.to_string())
209    }
210
211    fn drain_timers(&self, batch: &RuntimeReadyBatch) -> Result<Vec<u64>, String> {
212        self.broker
213            .drain_timers(batch.generation, batch.epoch, self.max_batch_handles)
214            .map_err(|error| error.to_string())
215    }
216
217    fn complete_batch(
218        &self,
219        batch: &RuntimeReadyBatch,
220        delivered: &[ReadyObservation],
221    ) -> Result<(), String> {
222        let acknowledgements = delivered
223            .iter()
224            .map(|entry| ReadyAcknowledgement {
225                capability_id: entry.capability_id,
226                capability_generation: entry.capability_generation,
227                observed_revision: entry.revision,
228                clear: entry.flags,
229            })
230            .collect::<Vec<_>>();
231        self.broker
232            .complete_wake(batch.generation, batch.epoch, &acknowledgements)
233            .map_err(|error| error.to_string())?;
234        let mut state = self.wakes.lock().map_err(|_| {
235            String::from("ERR_AGENTOS_READY_STATE_POISONED: session readiness lock poisoned")
236        })?;
237        self.forward_runtime_wake_locked(&mut state)
238    }
239}
240
241#[derive(Default)]
242struct SessionPauseState {
243    paused: bool,
244    shutdown: bool,
245}
246
247#[derive(Default)]
248#[doc(hidden)]
249pub struct SessionPauseControl {
250    state: Mutex<SessionPauseState>,
251    resumed: Condvar,
252}
253
254impl SessionPauseControl {
255    pub(crate) fn pause(&self) {
256        self.state
257            .lock()
258            .expect("session pause lock poisoned")
259            .paused = true;
260    }
261
262    pub(crate) fn resume(&self) {
263        let mut state = self.state.lock().expect("session pause lock poisoned");
264        state.paused = false;
265        self.resumed.notify_all();
266    }
267
268    fn shutdown(&self) {
269        let mut state = self.state.lock().expect("session pause lock poisoned");
270        state.shutdown = true;
271        state.paused = false;
272        self.resumed.notify_all();
273    }
274
275    pub(crate) fn wait_while_paused(&self) {
276        let mut state = self.state.lock().expect("session pause lock poisoned");
277        while state.paused && !state.shutdown {
278            state = self
279                .resumed
280                .wait(state)
281                .expect("session pause lock poisoned while waiting");
282        }
283    }
284}
285
286#[cfg(not(test))]
287extern "C" fn pause_isolate_interrupt(_isolate: &mut v8::Isolate, data: *mut std::ffi::c_void) {
288    // SAFETY: pause_session passes one Arc strong reference with into_raw for
289    // this callback. V8 invokes each accepted interrupt exactly once.
290    let control = unsafe { Arc::from_raw(data.cast::<SessionPauseControl>()) };
291    control.wait_while_paused();
292}
293
294#[cfg(not(test))]
295type SharedIsolateHandle = Arc<Mutex<Option<v8::IsolateHandle>>>;
296#[cfg(test)]
297type SharedIsolateHandle = Arc<Mutex<Option<()>>>;
298
299/// Sender for typed runtime events produced by session threads. IPC sessions
300/// share a bounded connection writer lane; in-process sessions write directly
301/// to their own bounded output lane so one backpressured VM cannot stall or
302/// destroy unrelated VMs in a global dispatch thread.
303#[derive(Clone)]
304pub enum RuntimeEventSender {
305    Channel(crossbeam_channel::Sender<RuntimeEventEnvelope>),
306    Closed,
307    Direct {
308        generation: u64,
309        sender: RuntimeEventOutputSender,
310    },
311}
312
313impl RuntimeEventSender {
314    pub fn closed() -> Self {
315        Self::Closed
316    }
317
318    pub fn direct(generation: u64, sender: RuntimeEventOutputSender) -> Self {
319        Self::Direct { generation, sender }
320    }
321
322    pub fn send(&self, envelope: RuntimeEventEnvelope) -> Result<(), String> {
323        match self {
324            Self::Channel(sender) => sender.try_send(envelope).map_err(|error| match error {
325                crossbeam_channel::TrySendError::Full(_) => String::from(
326                    "ERR_AGENTOS_V8_OUTPUT_LIMIT: runtime output lane is full; raise runtime.resources.maxAsyncCompletions",
327                ),
328                crossbeam_channel::TrySendError::Disconnected(_) => String::from(
329                    "ERR_AGENTOS_V8_OUTPUT_DISCONNECTED: runtime output consumer disconnected",
330                ),
331            }),
332            Self::Closed => Err(String::from(
333                "ERR_AGENTOS_V8_OUTPUT_UNREGISTERED: session has no registered output lane",
334            )),
335            Self::Direct { generation, sender } => {
336                if envelope.output_generation != Some(*generation) {
337                    return Err(format!(
338                        "ERR_AGENTOS_STALE_V8_OUTPUT: event generation {:?} does not match registered generation {generation}",
339                        envelope.output_generation
340                    ));
341                }
342                sender.try_send(envelope.event)
343            }
344        }
345    }
346}
347
348impl From<crossbeam_channel::Sender<RuntimeEventEnvelope>> for RuntimeEventSender {
349    fn from(sender: crossbeam_channel::Sender<RuntimeEventEnvelope>) -> Self {
350        Self::Channel(sender)
351    }
352}
353
354#[derive(Debug, Clone, PartialEq)]
355pub struct RuntimeEventEnvelope {
356    pub output_generation: Option<u64>,
357    pub event: RuntimeEvent,
358}
359
360#[derive(Clone)]
361pub struct RuntimeEventOutputSender {
362    inner: flume::Sender<QueuedRuntimeEvent>,
363    resources: Arc<ResourceLedger>,
364    gauge: Arc<QueueGauge>,
365}
366
367pub struct RuntimeEventOutputReceiver {
368    inner: flume::Receiver<QueuedRuntimeEvent>,
369    gauge: Arc<QueueGauge>,
370}
371
372struct QueuedRuntimeEvent {
373    event: RuntimeEvent,
374    _reservation: Reservation,
375}
376
377pub fn runtime_event_output_channel(
378    capacity: usize,
379    resources: Arc<ResourceLedger>,
380) -> (RuntimeEventOutputSender, RuntimeEventOutputReceiver) {
381    let (sender, receiver) = flume::bounded(capacity);
382    let gauge = register_queue(TrackedLimit::V8SessionFrames, capacity);
383    (
384        RuntimeEventOutputSender {
385            inner: sender,
386            resources,
387            gauge: Arc::clone(&gauge),
388        },
389        RuntimeEventOutputReceiver {
390            inner: receiver,
391            gauge,
392        },
393    )
394}
395
396impl RuntimeEventOutputSender {
397    #[cfg(test)]
398    pub(crate) fn capacity(&self) -> Option<usize> {
399        self.inner.capacity()
400    }
401
402    pub fn send(&self, event: RuntimeEvent) -> Result<(), String> {
403        let reservation = self
404            .resources
405            .reserve(ResourceClass::AsyncCompletions, 1)
406            .map_err(|error| error.to_string())?;
407        let result = self
408            .inner
409            .send(QueuedRuntimeEvent {
410                event,
411                _reservation: reservation,
412            })
413            .map_err(|_| {
414                String::from(
415                    "ERR_AGENTOS_V8_OUTPUT_DISCONNECTED: session output consumer disconnected",
416                )
417            });
418        self.gauge.observe_depth(self.inner.len());
419        result
420    }
421
422    pub fn try_send(&self, event: RuntimeEvent) -> Result<(), String> {
423        let reservation = self
424            .resources
425            .reserve(ResourceClass::AsyncCompletions, 1)
426            .map_err(|error| error.to_string())?;
427        let result = self
428            .inner
429            .try_send(QueuedRuntimeEvent {
430                event,
431                _reservation: reservation,
432            })
433            .map_err(|error| match error {
434                flume::TrySendError::Full(_) => String::from(
435                    "ERR_AGENTOS_V8_OUTPUT_LIMIT: session output lane is full; raise limits.reactor.maxAsyncCompletions",
436                ),
437                flume::TrySendError::Disconnected(_) => String::from(
438                    "ERR_AGENTOS_V8_OUTPUT_DISCONNECTED: session output consumer disconnected",
439                ),
440            });
441        self.gauge.observe_depth(self.inner.len());
442        result
443    }
444}
445
446impl RuntimeEventOutputReceiver {
447    pub fn recv(&self) -> Result<RuntimeEvent, flume::RecvError> {
448        let result = self.inner.recv().map(|queued| queued.event);
449        self.gauge.observe_depth(self.inner.len());
450        result
451    }
452
453    pub fn recv_timeout(&self, timeout: Duration) -> Result<RuntimeEvent, flume::RecvTimeoutError> {
454        let result = self.inner.recv_timeout(timeout).map(|queued| queued.event);
455        self.gauge.observe_depth(self.inner.len());
456        result
457    }
458
459    pub fn try_recv(&self) -> Result<RuntimeEvent, flume::TryRecvError> {
460        let result = self.inner.try_recv().map(|queued| queued.event);
461        self.gauge.observe_depth(self.inner.len());
462        result
463    }
464
465    pub async fn recv_async(&self) -> Result<RuntimeEvent, flume::RecvError> {
466        let result = self.inner.recv_async().await.map(|queued| queued.event);
467        self.gauge.observe_depth(self.inner.len());
468        result
469    }
470}
471
472impl Drop for RuntimeEventOutputReceiver {
473    fn drop(&mut self) {
474        while self.inner.try_recv().is_ok() {}
475        self.gauge.observe_depth(self.inner.len());
476    }
477}
478
479const LATE_TERMINATE_EXECUTION_ERROR_CODE: &str = "ERR_LATE_TERMINATE_EXECUTION";
480const LATE_STREAM_EVENT_ERROR_CODE: &str = "ERR_LATE_STREAM_EVENT";
481const LATE_BRIDGE_RESPONSE_ERROR_CODE: &str = "ERR_LATE_BRIDGE_RESPONSE";
482#[derive(Clone, Debug, Eq, Hash, PartialEq)]
483struct WarmPoolKey {
484    snapshot_key_digest: SnapshotCacheKey,
485    heap_limit_mb: u32,
486}
487
488struct ParkedWorker {
489    assignment_tx: Sender<SessionAssignment>,
490    join_handle: thread::JoinHandle<()>,
491}
492
493#[derive(Default)]
494struct WarmWorkerPoolState {
495    workers: HashMap<WarmPoolKey, Vec<ParkedWorker>>,
496    refilling: HashSet<WarmPoolKey>,
497    reserved_workers: usize,
498}
499
500#[derive(Default)]
501struct WarmWorkerPool {
502    state: Mutex<WarmWorkerPoolState>,
503}
504
505struct SessionAssignment {
506    heap_limit_mb: Option<u32>,
507    cpu_time_limit_ms: Option<u32>,
508    wall_clock_limit_ms: Option<u32>,
509    rx: Receiver<SessionCommand>,
510    shutdown_rx: Receiver<()>,
511    ready_rx: Receiver<ReadyWake>,
512    ready_broker: Arc<SessionReadiness>,
513    slot_permit: SessionSlotPermit,
514    event_tx: RuntimeEventSender,
515    call_id_router: CallIdRouter,
516    shared_call_id: SharedCallIdCounter,
517    snapshot_cache: Arc<SnapshotCache>,
518    isolate_handle: SharedIsolateHandle,
519    execution_abort: SharedExecutionAbort,
520    pause_control: Arc<SessionPauseControl>,
521    session_id: String,
522    output_generation: Option<u64>,
523    runtime: RuntimeContext,
524    bridge_call_timeout: Duration,
525}
526
527#[cfg(not(test))]
528struct PrecreatedIsolate {
529    // Keep both V8 owners optional so `Drop` can enforce the required order:
530    // every Global must be released before its isolate, and isolate destruction
531    // must share the process-wide lifecycle lock with isolate creation.
532    isolate: Option<v8::OwnedIsolate>,
533    context: Option<v8::Global<v8::Context>>,
534    bridge_code: String,
535    userland_code: String,
536}
537
538#[cfg(not(test))]
539impl Drop for PrecreatedIsolate {
540    fn drop(&mut self) {
541        drop(self.context.take());
542        isolate::drop_isolate(self.isolate.take());
543    }
544}
545
546#[cfg(test)]
547struct PrecreatedIsolate;
548
549#[cfg(not(test))]
550#[derive(Default)]
551struct V8SessionPhaseStats {
552    calls: u64,
553    total_ns: u128,
554    max_ns: u128,
555}
556
557#[cfg(not(test))]
558static V8_SESSION_PHASES: OnceLock<Mutex<BTreeMap<String, V8SessionPhaseStats>>> = OnceLock::new();
559
560#[cfg(not(test))]
561fn v8_session_phases_enabled() -> bool {
562    std::env::var("AGENTOS_V8_SESSION_PHASES").as_deref() == Ok("1")
563}
564
565#[cfg(not(test))]
566fn record_v8_session_phase(stage: &str, elapsed: Duration) {
567    if !v8_session_phases_enabled() {
568        return;
569    }
570    let phases = V8_SESSION_PHASES.get_or_init(|| Mutex::new(BTreeMap::new()));
571    let Ok(mut phases) = phases.lock() else {
572        return;
573    };
574    let stats = phases.entry(stage.to_string()).or_default();
575    stats.calls += 1;
576    let elapsed_ns = elapsed.as_nanos();
577    stats.total_ns += elapsed_ns;
578    stats.max_ns = stats.max_ns.max(elapsed_ns);
579
580    let Some(path) = std::env::var_os("AGENTOS_V8_SESSION_PHASES_FILE") else {
581        return;
582    };
583    let mut output = String::new();
584    for (stage, stats) in phases.iter() {
585        let total_us = stats.total_ns / 1_000;
586        let avg_us = if stats.calls == 0 {
587            0
588        } else {
589            total_us / u128::from(stats.calls)
590        };
591        let max_us = stats.max_ns / 1_000;
592        output.push_str(&format!(
593            "stage={stage} calls={} total_us={total_us} avg_us={avg_us} max_us={max_us}\n",
594            stats.calls
595        ));
596    }
597    let _ = std::fs::write(path, output);
598}
599
600#[cfg(not(test))]
601fn record_warm_worker_hit() {
602    record_v8_session_phase("warm_worker_hit", Duration::ZERO);
603}
604
605#[cfg(test)]
606fn record_warm_worker_hit() {}
607
608#[cfg(not(test))]
609fn record_warm_worker_miss() {
610    record_v8_session_phase("warm_worker_miss", Duration::ZERO);
611}
612
613#[cfg(test)]
614fn record_warm_worker_miss() {}
615
616fn warm_worker_capacity_per_key() -> usize {
617    std::env::var("AGENTOS_V8_WARM_ISOLATES")
618        .ok()
619        .and_then(|value| value.parse::<usize>().ok())
620        .unwrap_or(MAX_PROCESS_WARM_WORKERS)
621        .min(MAX_PROCESS_WARM_WORKERS)
622}
623
624const MAX_PROCESS_WARM_WORKERS: usize = 4;
625
626fn effective_heap_limit_mb(heap_limit_mb: Option<u32>) -> u32 {
627    heap_limit_mb.unwrap_or(crate::isolate::DEFAULT_HEAP_LIMIT_MB)
628}
629
630fn warm_pool_key(
631    bridge_code: &str,
632    userland_code: &str,
633    heap_limit_mb: Option<u32>,
634) -> WarmPoolKey {
635    WarmPoolKey {
636        snapshot_key_digest: snapshot_cache_key(
637            bridge_code,
638            (!userland_code.is_empty()).then_some(userland_code),
639        ),
640        heap_limit_mb: effective_heap_limit_mb(heap_limit_mb),
641    }
642}
643
644fn warm_key_prefix(key: &WarmPoolKey) -> String {
645    key.snapshot_key_digest[..4]
646        .iter()
647        .map(|byte| format!("{byte:02x}"))
648        .collect()
649}
650
651impl WarmWorkerPool {
652    fn claim(&self, key: &WarmPoolKey) -> Option<ParkedWorker> {
653        let mut state = self.state.lock().expect("warm worker pool lock poisoned");
654        state.workers.get_mut(key).and_then(Vec::pop)
655    }
656
657    fn shutdown_handles(&self) -> Vec<thread::JoinHandle<()>> {
658        let mut state = self.state.lock().expect("warm worker pool lock poisoned");
659        state.refilling.clear();
660        state.reserved_workers = 0;
661        state
662            .workers
663            .drain()
664            .flat_map(|(_, workers)| workers)
665            .map(|worker| {
666                drop(worker.assignment_tx);
667                worker.join_handle
668            })
669            .collect()
670    }
671
672    #[allow(clippy::too_many_arguments)]
673    fn ensure_count(
674        self: &Arc<Self>,
675        runtime: RuntimeContext,
676        snapshot_cache: Arc<SnapshotCache>,
677        slot_control: SlotControl,
678        bridge_code: String,
679        userland_code: String,
680        heap_limit_mb: Option<u32>,
681        requested_count: usize,
682    ) {
683        let capacity = warm_worker_capacity_per_key();
684        if capacity == 0 || requested_count == 0 {
685            return;
686        }
687
688        let target_count = requested_count.min(capacity);
689        let key = warm_pool_key(&bridge_code, &userland_code, heap_limit_mb);
690        {
691            let mut state = self.state.lock().expect("warm worker pool lock poisoned");
692            let current = state.workers.get(&key).map_or(0, Vec::len);
693            if current >= target_count || state.refilling.contains(&key) {
694                return;
695            }
696            state.refilling.insert(key.clone());
697        }
698
699        let pool = Arc::clone(self);
700        let spawn_key = key.clone();
701        let requested_bytes = bridge_code.len().saturating_add(userland_code.len());
702        if let Err(error) = runtime.blocking().submit(requested_bytes, move || {
703            pool.refill_until(
704                snapshot_cache,
705                slot_control,
706                spawn_key,
707                bridge_code,
708                userland_code,
709                heap_limit_mb,
710                target_count,
711            );
712        }) {
713            eprintln!("ERR_AGENTOS_V8_WARM_REFILL: bounded executor rejected refill: {error}");
714            self.state
715                .lock()
716                .expect("warm worker pool lock poisoned")
717                .refilling
718                .remove(&key);
719        }
720    }
721
722    // Internal pool-refill plumbing; args mirror the parked-worker construction.
723    #[allow(clippy::too_many_arguments)]
724    fn refill_until(
725        &self,
726        snapshot_cache: Arc<SnapshotCache>,
727        slot_control: SlotControl,
728        key: WarmPoolKey,
729        bridge_code: String,
730        userland_code: String,
731        heap_limit_mb: Option<u32>,
732        target_count: usize,
733    ) {
734        loop {
735            let capacity = warm_worker_capacity_per_key();
736            if capacity == 0 {
737                break;
738            }
739            let desired = target_count.min(capacity);
740            let refill_slot = {
741                let mut state = self.state.lock().expect("warm worker pool lock poisoned");
742                let current = state.workers.get(&key).map_or(0, Vec::len);
743                let total = state.workers.values().map(Vec::len).sum::<usize>();
744                if current >= desired {
745                    break;
746                }
747                if total.saturating_add(state.reserved_workers) < MAX_PROCESS_WARM_WORKERS {
748                    state.reserved_workers += 1;
749                    Some(None)
750                } else {
751                    let evict_key = state
752                        .workers
753                        .iter()
754                        .find(|(candidate, workers)| *candidate != &key && !workers.is_empty())
755                        .map(|(candidate, _)| candidate.clone());
756                    evict_key.and_then(|evict_key| {
757                        let workers = state
758                            .workers
759                            .get_mut(&evict_key)
760                            .expect("selected warm worker key exists");
761                        let worker = workers.pop();
762                        if workers.is_empty() {
763                            state.workers.remove(&evict_key);
764                        }
765                        worker.map(|worker| Some((evict_key, worker)))
766                    })
767                }
768            };
769            let Some(evicted) = refill_slot else {
770                break;
771            };
772            if let Some((evicted_key, worker)) = evicted {
773                drop(worker.assignment_tx);
774                let _ = worker.join_handle.join();
775                eprintln!(
776                    "agentos-v8-runtime: warm worker evicted key={} heap={}",
777                    warm_key_prefix(&evicted_key),
778                    evicted_key.heap_limit_mb
779                );
780                continue;
781            }
782
783            let worker = spawn_warm_worker(
784                Arc::clone(&snapshot_cache),
785                Arc::clone(&slot_control),
786                key.clone(),
787                bridge_code.clone(),
788                userland_code.clone(),
789                heap_limit_mb,
790            );
791            let mut state = self.state.lock().expect("warm worker pool lock poisoned");
792            state.reserved_workers = state.reserved_workers.saturating_sub(1);
793            let Some(worker) = worker else {
794                break;
795            };
796
797            let workers = state.workers.entry(key.clone()).or_default();
798            if workers.len() >= desired {
799                drop(worker.assignment_tx);
800                let _ = worker.join_handle.join();
801                break;
802            }
803            workers.push(worker);
804            eprintln!(
805                "agentos-v8-runtime: warm worker refilled key={} heap={} pool_size={}",
806                warm_key_prefix(&key),
807                key.heap_limit_mb,
808                workers.len()
809            );
810        }
811
812        self.state
813            .lock()
814            .expect("warm worker pool lock poisoned")
815            .refilling
816            .remove(&key);
817    }
818}
819
820#[cfg(not(test))]
821fn spawn_warm_worker(
822    snapshot_cache: Arc<SnapshotCache>,
823    slot_control: SlotControl,
824    key: WarmPoolKey,
825    bridge_code: String,
826    userland_code: String,
827    heap_limit_mb: Option<u32>,
828) -> Option<ParkedWorker> {
829    let (assignment_tx, assignment_rx) = crossbeam_channel::bounded::<SessionAssignment>(1);
830    let (ready_tx, ready_rx) = crossbeam_channel::bounded::<Result<(), String>>(1);
831    let worker_bridge_code = bridge_code.clone();
832    let worker_userland_code = userland_code.clone();
833    // AGENTOS_THREAD_SITE: bounded-v8-warm-worker
834    let join_handle = match thread::Builder::new()
835        .name(String::from("secure-exec-v8-warm-worker"))
836        .spawn(move || {
837            let precreated = precreate_warm_isolate(
838                snapshot_cache,
839                slot_control,
840                worker_bridge_code,
841                worker_userland_code,
842                heap_limit_mb,
843            );
844            match precreated {
845                Ok(precreated) => {
846                    if ready_tx.send(Ok(())).is_err() {
847                        eprintln!("INFO_AGENTOS_STALE_WARM_WORKER: warm worker requester disconnected before startup completed");
848                    }
849                    if let Ok(assignment) = assignment_rx.recv() {
850                        session_thread(assignment, Some(precreated));
851                    }
852                }
853                Err(error) => {
854                    if ready_tx.send(Err(error)).is_err() {
855                        eprintln!("INFO_AGENTOS_STALE_WARM_WORKER: warm worker requester disconnected before startup failure was delivered");
856                    }
857                }
858            }
859        }) {
860        Ok(handle) => handle,
861        Err(error) => {
862            eprintln!("agentos-v8-runtime: warm worker spawn failed: {error}");
863            return None;
864        }
865    };
866
867    match ready_rx.recv() {
868        Ok(Ok(())) => Some(ParkedWorker {
869            assignment_tx,
870            join_handle,
871        }),
872        Ok(Err(error)) => {
873            eprintln!(
874                "agentos-v8-runtime: warm worker refill failed key={} heap={}: {error}",
875                warm_key_prefix(&key),
876                key.heap_limit_mb
877            );
878            let _ = join_handle.join();
879            None
880        }
881        Err(error) => {
882            eprintln!(
883                "agentos-v8-runtime: warm worker refill failed key={} heap={}: {error}",
884                warm_key_prefix(&key),
885                key.heap_limit_mb
886            );
887            let _ = join_handle.join();
888            None
889        }
890    }
891}
892
893#[cfg(test)]
894fn spawn_warm_worker(
895    _snapshot_cache: Arc<SnapshotCache>,
896    _slot_control: SlotControl,
897    _key: WarmPoolKey,
898    _bridge_code: String,
899    _userland_code: String,
900    _heap_limit_mb: Option<u32>,
901) -> Option<ParkedWorker> {
902    None
903}
904
905#[cfg(not(test))]
906fn precreate_warm_isolate(
907    snapshot_cache: Arc<SnapshotCache>,
908    _slot_control: SlotControl,
909    bridge_code: String,
910    userland_code: String,
911    heap_limit_mb: Option<u32>,
912) -> Result<PrecreatedIsolate, String> {
913    isolate::init_v8_platform();
914    let snapshot_blob = snapshot_cache.get_or_create_with_userland(
915        &bridge_code,
916        (!userland_code.is_empty()).then_some(userland_code.as_str()),
917    )?;
918    let snapshot_blob = (*snapshot_blob).clone();
919    // Parked workers are bounded by MAX_PROCESS_WARM_WORKERS and do not execute
920    // guest code until they receive a slot-owning SessionAssignment. Building
921    // one must not wait for every active session to exit: long-lived parents
922    // need the pool to replenish while short-lived child commands come and go.
923    let mut isolate = snapshot::create_isolate_from_snapshot(snapshot_blob, heap_limit_mb);
924    isolate.set_host_import_module_dynamically_callback(execution::dynamic_import_callback);
925    isolate.set_host_initialize_import_meta_object_callback(execution::import_meta_object_callback);
926    let context = isolate::create_context(&mut isolate);
927    Ok(PrecreatedIsolate {
928        isolate: Some(isolate),
929        context: Some(context),
930        bridge_code,
931        userland_code,
932    })
933}
934
935/// Normalize an opt-in CPU-time budget: `Some(0)` means "disabled" and folds to
936/// `None` so the CPU-budget watchdog is NOT armed. The runtime layer does not
937/// invent a default here: secure-exec sidecar VM executions pass the typed
938/// `limits.jsRuntime.cpuTimeLimitMs` default, while lower-level callers can pass
939/// `None`/`0` deliberately.
940fn normalize_cpu_time_limit_ms(cpu_time_limit_ms: Option<u32>) -> Option<u32> {
941    cpu_time_limit_ms.filter(|budget_ms| *budget_ms > 0)
942}
943
944/// Normalize an opt-in WALL-CLOCK backstop: `Some(0)` means "disabled" and folds
945/// to `None` so the wall-clock `TimeoutGuard` is NOT armed. There is no default —
946/// when the caller passes `None`/`0`, the guest runs with no wall-clock limit
947/// (opt-in by design, so long-lived ACP adapters are never killed by a default).
948/// This is INDEPENDENT of the CPU-time budget: setting one does not arm the other.
949fn normalize_wall_clock_limit_ms(wall_clock_limit_ms: Option<u32>) -> Option<u32> {
950    wall_clock_limit_ms.filter(|limit_ms| *limit_ms > 0)
951}
952
953fn signal_session_shutdown(sender: &Sender<()>, session_id: &str) {
954    match sender.try_send(()) {
955        Ok(()) => {}
956        Err(crossbeam_channel::TrySendError::Full(())) => eprintln!(
957            "INFO_AGENTOS_VM_SHUTDOWN_COALESCED: session={session_id} already has a pending shutdown signal"
958        ),
959        Err(crossbeam_channel::TrySendError::Disconnected(())) => eprintln!(
960            "INFO_AGENTOS_STALE_VM_SHUTDOWN: session={session_id} executor already disconnected"
961        ),
962    }
963}
964
965pub(crate) fn configured_resource_capacity(
966    runtime: &RuntimeContext,
967    resource: ResourceClass,
968    vm_config_path: &'static str,
969    process_config_path: &'static str,
970) -> Result<usize, String> {
971    let config_path = if runtime.vm_generation().is_some() {
972        vm_config_path
973    } else {
974        process_config_path
975    };
976    match runtime.resources().usage(resource).limit {
977        Some(capacity) if capacity > 0 => Ok(capacity),
978        Some(_) => Err(format!(
979            "ERR_AGENTOS_RUNTIME_CONFIG: {config_path} must be greater than zero"
980        )),
981        None => Err(format!(
982            "ERR_AGENTOS_RUNTIME_CONFIG: {config_path} must configure a bounded {} limit",
983            resource.name()
984        )),
985    }
986}
987
988/// Internal entry for a running session
989struct SessionEntry {
990    /// Output receiver generation current when this session was created.
991    output_generation: Option<u64>,
992    /// Channel to send commands to the session thread
993    tx: Sender<SessionCommand>,
994    /// Configured bound for this generation's ordinary command lane.
995    command_capacity: usize,
996    /// Dedicated capacity-one control lane. Shutdown must never contend with
997    /// ordinary session commands, because that lane may be full precisely when
998    /// an overloaded session needs to be terminated.
999    shutdown_tx: Sender<()>,
1000    /// Thread join handle
1001    join_handle: Option<thread::JoinHandle<()>>,
1002    /// Thread-safe V8 isolate handle for out-of-band termination.
1003    #[cfg_attr(test, allow(dead_code))]
1004    isolate_handle: SharedIsolateHandle,
1005    /// Current execution abort handle used to wake sync bridge waits.
1006    execution_abort: SharedExecutionAbort,
1007    pause_control: Arc<SessionPauseControl>,
1008    /// Durable socket readiness and its dedicated capacity-one wake lane.
1009    ready_broker: Arc<SessionReadiness>,
1010    #[cfg(test)]
1011    session_resources: Arc<agentos_runtime::accounting::ResourceLedger>,
1012}
1013
1014/// Deferred shutdown work for a session that has already been removed from
1015/// the manager. `finish()` joins the session thread and clears any call
1016/// routes the thread registered while shutting down. Callers must release
1017/// the SessionManager lock before calling `finish()`. Joining under the lock
1018/// deadlocks: the dispatch thread needs the lock to drain the event channel,
1019/// and the joined thread can be parked on a full event channel send.
1020pub struct SessionShutdown {
1021    session_id: String,
1022    output_generation: Option<u64>,
1023    join_handle: Option<thread::JoinHandle<()>>,
1024    call_id_router: CallIdRouter,
1025}
1026
1027impl SessionShutdown {
1028    pub fn finish(mut self) {
1029        if let Some(handle) = self.join_handle.take() {
1030            if handle.join().is_err() {
1031                eprintln!(
1032                    "ERR_AGENTOS_VM_EXECUTOR_PANIC: session={} generation={:?}",
1033                    self.session_id, self.output_generation
1034                );
1035            }
1036        }
1037        self.call_id_router
1038            .cancel_session(&self.session_id, self.output_generation);
1039    }
1040}
1041
1042/// Concurrency slot tracker shared across session threads
1043type SlotControl = Arc<(Mutex<usize>, Condvar)>;
1044
1045/// An admitted V8 executor slot. It is acquired before spawning or assigning
1046/// an OS thread and remains owned by that generation until the thread exits.
1047/// Detached/stuck generations therefore stay quarantined instead of lending
1048/// their capacity to a successor VM.
1049struct SessionSlotPermit {
1050    control: SlotControl,
1051    metrics: RuntimeMetrics,
1052}
1053
1054impl SessionSlotPermit {
1055    fn try_acquire(
1056        control: &SlotControl,
1057        maximum: usize,
1058        metrics: RuntimeMetrics,
1059    ) -> Result<Self, String> {
1060        let (lock, _) = &**control;
1061        let mut active = lock
1062            .lock()
1063            .map_err(|_| String::from("ERR_AGENTOS_VM_EXECUTOR_POISONED: slot lock poisoned"))?;
1064        if *active >= maximum {
1065            return Err(format!(
1066                "ERR_AGENTOS_VM_EXECUTOR_LIMIT: active V8 executors reached limit of {maximum}; raise runtime.executor.maxActiveVms"
1067            ));
1068        }
1069        *active += 1;
1070        metrics.observe_executor(ExecutorMetricClass::Vm, *active, 0);
1071        Ok(Self {
1072            control: Arc::clone(control),
1073            metrics,
1074        })
1075    }
1076}
1077
1078impl Drop for SessionSlotPermit {
1079    fn drop(&mut self) {
1080        let (lock, cvar) = &*self.control;
1081        match lock.lock() {
1082            Ok(mut active) if *active > 0 => {
1083                *active -= 1;
1084                self.metrics
1085                    .observe_executor(ExecutorMetricClass::Vm, *active, 0);
1086                cvar.notify_all();
1087            }
1088            Ok(_) => eprintln!(
1089                "ERR_AGENTOS_VM_EXECUTOR_ACCOUNTING_UNDERFLOW: executor permit released at zero"
1090            ),
1091            Err(_) => {
1092                eprintln!("ERR_AGENTOS_VM_EXECUTOR_POISONED: executor permit could not be released")
1093            }
1094        }
1095    }
1096}
1097
1098#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1099pub(crate) enum ExecutionAbortReason {
1100    /// Caller explicitly terminated the execution (e.g. session destroy).
1101    Terminated,
1102    /// The opt-in WALL-CLOCK backstop (`TimeoutGuard`) elapsed. Counts elapsed
1103    /// real time INCLUDING idle/await, so it can cap a guest that blocks/awaits
1104    /// indefinitely. Armed only when `limits.jsRuntime.wallClockLimitMs` is set;
1105    /// independent of the CPU-time budget.
1106    #[cfg_attr(test, allow(dead_code))]
1107    WallClockTimedOut,
1108    /// The TRUE CPU-TIME budget (`CpuBudgetGuard`) was exhausted by active JS CPU.
1109    #[cfg_attr(test, allow(dead_code))]
1110    CpuBudgetExceeded,
1111}
1112
1113struct ExecutionAbortState {
1114    sender: Option<crossbeam_channel::Sender<()>>,
1115    reason: Option<ExecutionAbortReason>,
1116}
1117
1118pub(crate) struct SharedExecutionAbort(Arc<Mutex<Option<ExecutionAbortState>>>);
1119
1120impl Clone for SharedExecutionAbort {
1121    fn clone(&self) -> Self {
1122        Self(Arc::clone(&self.0))
1123    }
1124}
1125
1126pub(crate) fn new_execution_abort() -> SharedExecutionAbort {
1127    SharedExecutionAbort(Arc::new(Mutex::new(None)))
1128}
1129
1130#[cfg_attr(test, allow(dead_code))]
1131pub(crate) struct ActiveExecutionAbort {
1132    shared: SharedExecutionAbort,
1133}
1134
1135#[cfg_attr(test, allow(dead_code))]
1136impl ActiveExecutionAbort {
1137    pub(crate) fn arm(shared: &SharedExecutionAbort) -> (Self, crossbeam_channel::Receiver<()>) {
1138        let (tx, rx) = crossbeam_channel::bounded::<()>(0);
1139        let mut guard = shared.0.lock().unwrap();
1140        if let Some(reason) = guard.as_ref().and_then(|state| state.reason) {
1141            // Cancellation is durable across the short gap between dequeuing
1142            // Execute and arming its waiter. Leave the new receiver
1143            // disconnected so the execution observes the already-recorded
1144            // terminal reason immediately.
1145            drop(tx);
1146            *guard = Some(ExecutionAbortState {
1147                sender: None,
1148                reason: Some(reason),
1149            });
1150        } else {
1151            *guard = Some(ExecutionAbortState {
1152                sender: Some(tx),
1153                reason: None,
1154            });
1155        }
1156        (
1157            Self {
1158                shared: shared.clone(),
1159            },
1160            rx,
1161        )
1162    }
1163}
1164
1165impl Drop for ActiveExecutionAbort {
1166    fn drop(&mut self) {
1167        *self.shared.0.lock().unwrap() = None;
1168    }
1169}
1170
1171pub(crate) fn signal_execution_abort(shared: &SharedExecutionAbort, reason: ExecutionAbortReason) {
1172    let mut guard = shared.0.lock().unwrap();
1173    if let Some(state) = guard.as_mut() {
1174        state.reason.get_or_insert(reason);
1175        state.sender.take();
1176    }
1177}
1178
1179fn signal_execution_abort_durable(shared: &SharedExecutionAbort, reason: ExecutionAbortReason) {
1180    let mut guard = shared.0.lock().unwrap();
1181    if let Some(state) = guard.as_mut() {
1182        state.reason.get_or_insert(reason);
1183        state.sender.take();
1184    } else {
1185        // Session teardown is durable even if the execution has not armed its
1186        // receiver yet. Ordinary TerminateExecution remains edge-scoped so a
1187        // request against an idle reusable session does not poison its next run.
1188        *guard = Some(ExecutionAbortState {
1189            sender: None,
1190            reason: Some(reason),
1191        });
1192    }
1193}
1194
1195#[cfg(not(test))]
1196fn execution_abort_reason(shared: &SharedExecutionAbort) -> Option<ExecutionAbortReason> {
1197    shared
1198        .0
1199        .lock()
1200        .unwrap()
1201        .as_ref()
1202        .and_then(|state| state.reason)
1203}
1204
1205/// Manages V8 sessions with concurrency limiting.
1206/// Each session runs on a dedicated OS thread with its own V8 isolate.
1207pub struct SessionManager {
1208    sessions: HashMap<String, SessionEntry>,
1209    /// Detached generations remain owned until their executor exits. The
1210    /// thread itself retains the concurrency permit, so a successor cannot
1211    /// consume capacity that is still running untrusted code.
1212    quarantined: Vec<QuarantinedSession>,
1213    max_concurrency: usize,
1214    slot_control: SlotControl,
1215    /// Typed runtime event sender shared across session threads.
1216    event_tx: RuntimeEventSender,
1217    /// Call_id → session_id routing table for BridgeResponse dispatch
1218    call_id_router: CallIdRouter,
1219    /// Shared call_id counter — all sessions use this to generate globally unique
1220    /// call_ids, preventing collisions in the call_id_router
1221    shared_call_id: SharedCallIdCounter,
1222    /// Shared snapshot cache for fast isolate creation from pre-compiled bridge code
1223    snapshot_cache: Arc<SnapshotCache>,
1224    /// Ready-to-claim isolate workers keyed by snapshot digest and heap cap.
1225    warm_pool: Arc<WarmWorkerPool>,
1226    /// Process-owned scheduler and bounded blocking executor, injected when the
1227    /// session manager is constructed rather than discovered during refill.
1228    runtime: RuntimeContext,
1229    executor_teardown_timeout: Duration,
1230}
1231
1232struct QuarantinedSession {
1233    session_id: String,
1234    output_generation: Option<u64>,
1235    join_handle: thread::JoinHandle<()>,
1236    quarantined_at: Instant,
1237    deadline_reported: bool,
1238}
1239
1240impl SessionManager {
1241    pub fn new(
1242        max_concurrency: usize,
1243        event_tx: impl Into<RuntimeEventSender>,
1244        call_id_router: CallIdRouter,
1245        snapshot_cache: Arc<SnapshotCache>,
1246        runtime: RuntimeContext,
1247    ) -> Self {
1248        SessionManager {
1249            sessions: HashMap::new(),
1250            quarantined: Vec::new(),
1251            max_concurrency,
1252            slot_control: Arc::new((Mutex::new(0), Condvar::new())),
1253            event_tx: event_tx.into(),
1254            call_id_router,
1255            shared_call_id: Arc::new(AtomicU64::new(1)),
1256            snapshot_cache,
1257            warm_pool: Arc::new(WarmWorkerPool::default()),
1258            executor_teardown_timeout: runtime.vm_executor_teardown_timeout(),
1259            runtime,
1260        }
1261    }
1262
1263    #[cfg(test)]
1264    pub(crate) fn max_concurrency(&self) -> usize {
1265        self.max_concurrency
1266    }
1267
1268    /// Get the snapshot cache for pre-warming from WarmSnapshot messages.
1269    #[allow(dead_code)]
1270    pub fn snapshot_cache(&self) -> &Arc<SnapshotCache> {
1271        &self.snapshot_cache
1272    }
1273
1274    pub fn pre_warm_workers(
1275        &self,
1276        bridge_code: String,
1277        userland_code: String,
1278        heap_limit_mb: Option<u32>,
1279        count: usize,
1280    ) {
1281        self.warm_pool.ensure_count(
1282            self.runtime.clone(),
1283            Arc::clone(&self.snapshot_cache),
1284            Arc::clone(&self.slot_control),
1285            bridge_code,
1286            userland_code,
1287            heap_limit_mb,
1288            count,
1289        );
1290    }
1291
1292    /// Create a new session.
1293    /// Spawns a dedicated admitted thread with a V8 isolate. Admission happens
1294    /// before thread creation; overload returns a typed limit error.
1295    pub fn create_session(
1296        &mut self,
1297        session_id: String,
1298        heap_limit_mb: Option<u32>,
1299        cpu_time_limit_ms: Option<u32>,
1300        wall_clock_limit_ms: Option<u32>,
1301    ) -> Result<(), String> {
1302        self.create_session_with_output_generation(
1303            session_id,
1304            heap_limit_mb,
1305            cpu_time_limit_ms,
1306            wall_clock_limit_ms,
1307            None,
1308            None,
1309        )
1310    }
1311
1312    pub fn create_session_with_output_generation(
1313        &mut self,
1314        session_id: String,
1315        heap_limit_mb: Option<u32>,
1316        cpu_time_limit_ms: Option<u32>,
1317        wall_clock_limit_ms: Option<u32>,
1318        output_generation: Option<u64>,
1319        warm_hint: Option<WarmSessionHint>,
1320    ) -> Result<(), String> {
1321        self.create_session_with_output_generation_and_sender(
1322            session_id,
1323            heap_limit_mb,
1324            cpu_time_limit_ms,
1325            wall_clock_limit_ms,
1326            output_generation,
1327            warm_hint,
1328            None,
1329        )
1330    }
1331
1332    #[allow(clippy::too_many_arguments)]
1333    pub fn create_session_with_output_generation_and_sender(
1334        &mut self,
1335        session_id: String,
1336        heap_limit_mb: Option<u32>,
1337        cpu_time_limit_ms: Option<u32>,
1338        wall_clock_limit_ms: Option<u32>,
1339        output_generation: Option<u64>,
1340        warm_hint: Option<WarmSessionHint>,
1341        event_tx: Option<RuntimeEventSender>,
1342    ) -> Result<(), String> {
1343        // Serialized/standalone sessions do not carry VM reactor policy. Keep
1344        // their historical ceiling; sidecar VM sessions must call the explicit
1345        // `_and_runtime` path with `limits.reactor.workQuantum`.
1346        let ready_batch_handle_limit = configured_resource_capacity(
1347            &self.runtime,
1348            ResourceClass::ReadyHandles,
1349            "limits.reactor.maxReadyHandles",
1350            "runtime.resources.maxReadyHandles",
1351        )?;
1352        self.create_session_with_output_generation_sender_and_runtime(
1353            session_id,
1354            heap_limit_mb,
1355            cpu_time_limit_ms,
1356            wall_clock_limit_ms,
1357            output_generation,
1358            warm_hint,
1359            event_tx,
1360            self.runtime.clone(),
1361            ready_batch_handle_limit,
1362            crate::host_call::DEFAULT_BRIDGE_CALL_TIMEOUT,
1363        )
1364    }
1365
1366    /// Create a session whose guest-owned work and resource reservations are
1367    /// charged to `session_runtime`. The manager's own runtime remains the
1368    /// process-scoped context used for shared snapshot and warm-pool work.
1369    #[allow(clippy::too_many_arguments)]
1370    pub fn create_session_with_output_generation_sender_and_runtime(
1371        &mut self,
1372        session_id: String,
1373        heap_limit_mb: Option<u32>,
1374        cpu_time_limit_ms: Option<u32>,
1375        wall_clock_limit_ms: Option<u32>,
1376        output_generation: Option<u64>,
1377        warm_hint: Option<WarmSessionHint>,
1378        event_tx: Option<RuntimeEventSender>,
1379        session_runtime: RuntimeContext,
1380        ready_batch_handle_limit: usize,
1381        bridge_call_timeout: Duration,
1382    ) -> Result<(), String> {
1383        self.reap_finished_quarantines();
1384        if self.sessions.contains_key(&session_id) {
1385            return Err(format!("session {} already exists", session_id));
1386        }
1387
1388        let slot_permit = SessionSlotPermit::try_acquire(
1389            &self.slot_control,
1390            self.max_concurrency,
1391            self.runtime.metrics().clone(),
1392        )?;
1393
1394        let cpu_time_limit_ms = normalize_cpu_time_limit_ms(cpu_time_limit_ms);
1395        let wall_clock_limit_ms = normalize_wall_clock_limit_ms(wall_clock_limit_ms);
1396        let command_capacity = configured_resource_capacity(
1397            &session_runtime,
1398            ResourceClass::HandleCommands,
1399            "limits.reactor.maxHandleCommands",
1400            "runtime.resources.maxHandleCommands",
1401        )?;
1402        let (tx, rx) = crossbeam_channel::bounded(command_capacity);
1403        let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded(1);
1404        let ready_generation = output_generation.unwrap_or(1);
1405        let (ready_broker, ready_rx) =
1406            SessionReadiness::new(ready_generation, &session_runtime, ready_batch_handle_limit)?;
1407        let isolate_handle = Arc::new(Mutex::new(None));
1408        let execution_abort = new_execution_abort();
1409        let pause_control = Arc::new(SessionPauseControl::default());
1410        #[cfg(test)]
1411        let session_resources = Arc::clone(session_runtime.resources());
1412        let assignment = SessionAssignment {
1413            heap_limit_mb,
1414            cpu_time_limit_ms,
1415            wall_clock_limit_ms,
1416            rx,
1417            shutdown_rx,
1418            ready_rx,
1419            ready_broker: Arc::clone(&ready_broker),
1420            slot_permit,
1421            event_tx: event_tx.unwrap_or_else(|| self.event_tx.clone()),
1422            call_id_router: Arc::clone(&self.call_id_router),
1423            shared_call_id: Arc::clone(&self.shared_call_id),
1424            snapshot_cache: Arc::clone(&self.snapshot_cache),
1425            isolate_handle: Arc::clone(&isolate_handle),
1426            execution_abort: execution_abort.clone(),
1427            pause_control: Arc::clone(&pause_control),
1428            session_id: session_id.clone(),
1429            output_generation,
1430            runtime: session_runtime,
1431            bridge_call_timeout,
1432        };
1433
1434        let join_handle = match self.claim_warm_worker(warm_hint.as_ref(), assignment) {
1435            Ok((join_handle, true)) => {
1436                if let Some(hint) = warm_hint {
1437                    self.warm_pool.ensure_count(
1438                        self.runtime.clone(),
1439                        Arc::clone(&self.snapshot_cache),
1440                        Arc::clone(&self.slot_control),
1441                        hint.bridge_code,
1442                        hint.userland_code,
1443                        hint.heap_limit_mb,
1444                        warm_worker_capacity_per_key(),
1445                    );
1446                }
1447                join_handle
1448            }
1449            Ok((join_handle, false)) => join_handle,
1450            Err(assignment) => {
1451                if let Some(hint) = warm_hint {
1452                    self.warm_pool.ensure_count(
1453                        self.runtime.clone(),
1454                        Arc::clone(&self.snapshot_cache),
1455                        Arc::clone(&self.slot_control),
1456                        hint.bridge_code,
1457                        hint.userland_code,
1458                        hint.heap_limit_mb,
1459                        warm_worker_capacity_per_key(),
1460                    );
1461                }
1462                spawn_session_thread(assignment)
1463                    .map_err(|e| format!("failed to spawn session thread: {}", e))?
1464            }
1465        };
1466
1467        self.sessions.insert(
1468            session_id,
1469            SessionEntry {
1470                output_generation,
1471                tx,
1472                command_capacity,
1473                shutdown_tx,
1474                join_handle: Some(join_handle),
1475                isolate_handle,
1476                execution_abort,
1477                pause_control,
1478                ready_broker,
1479                #[cfg(test)]
1480                session_resources,
1481            },
1482        );
1483
1484        Ok(())
1485    }
1486
1487    // The Err variant intentionally carries the whole SessionAssignment back to
1488    // the caller for the fallback spawn path — it is moved, not copied.
1489    #[allow(clippy::result_large_err)]
1490    fn claim_warm_worker(
1491        &self,
1492        warm_hint: Option<&WarmSessionHint>,
1493        assignment: SessionAssignment,
1494    ) -> Result<(thread::JoinHandle<()>, bool), SessionAssignment> {
1495        let Some(hint) = warm_hint else {
1496            return Err(assignment);
1497        };
1498        if warm_worker_capacity_per_key() == 0 {
1499            record_warm_worker_miss();
1500            return Err(assignment);
1501        }
1502
1503        let key = warm_pool_key(&hint.bridge_code, &hint.userland_code, hint.heap_limit_mb);
1504        let Some(worker) = self.warm_pool.claim(&key) else {
1505            record_warm_worker_miss();
1506            eprintln!(
1507                "agentos-v8-runtime: warm worker pool-empty key={} heap={}",
1508                warm_key_prefix(&key),
1509                key.heap_limit_mb
1510            );
1511            return Err(assignment);
1512        };
1513
1514        match worker.assignment_tx.send(assignment) {
1515            Ok(()) => {
1516                record_warm_worker_hit();
1517                eprintln!(
1518                    "agentos-v8-runtime: warm worker claimed key={} heap={}",
1519                    warm_key_prefix(&key),
1520                    key.heap_limit_mb
1521                );
1522                Ok((worker.join_handle, true))
1523            }
1524            Err(error) => {
1525                record_warm_worker_miss();
1526                let _ = worker.join_handle.join();
1527                Err(error.0)
1528            }
1529        }
1530    }
1531
1532    pub fn destroy_session_if_output_generation(
1533        &mut self,
1534        session_id: &str,
1535        output_generation: u64,
1536    ) -> Result<bool, String> {
1537        match self.begin_destroy_session_if_output_generation(session_id, output_generation)? {
1538            Some(shutdown) => {
1539                shutdown.finish();
1540                Ok(true)
1541            }
1542            None => Ok(false),
1543        }
1544    }
1545
1546    pub fn begin_destroy_session_if_output_generation(
1547        &mut self,
1548        session_id: &str,
1549        output_generation: u64,
1550    ) -> Result<Option<SessionShutdown>, String> {
1551        if self
1552            .sessions
1553            .get(session_id)
1554            .is_none_or(|entry| entry.output_generation != Some(output_generation))
1555        {
1556            return Ok(None);
1557        }
1558
1559        self.begin_destroy_session(session_id).map(Some)
1560    }
1561
1562    pub fn detach_session_if_output_generation(
1563        &mut self,
1564        session_id: &str,
1565        output_generation: u64,
1566    ) -> Result<bool, String> {
1567        if self
1568            .sessions
1569            .get(session_id)
1570            .is_none_or(|entry| entry.output_generation != Some(output_generation))
1571        {
1572            return Ok(false);
1573        }
1574
1575        self.detach_session(session_id)?;
1576        Ok(true)
1577    }
1578
1579    pub(crate) fn detach_session(&mut self, session_id: &str) -> Result<(), String> {
1580        let entry = self
1581            .sessions
1582            .get(session_id)
1583            .ok_or_else(|| format!("session {} does not exist", session_id))?;
1584        entry.pause_control.shutdown();
1585
1586        #[cfg(not(test))]
1587        if let Some(handle) = entry
1588            .isolate_handle
1589            .lock()
1590            .ok()
1591            .and_then(|guard| guard.as_ref().cloned())
1592        {
1593            handle.terminate_execution();
1594        }
1595        signal_execution_abort_durable(&entry.execution_abort, ExecutionAbortReason::Terminated);
1596        self.clear_call_routes_for_session(session_id, entry.output_generation);
1597        let mut entry = self.sessions.remove(session_id).unwrap();
1598        signal_session_shutdown(&entry.shutdown_tx, session_id);
1599        drop(entry.tx);
1600        if let Some(join_handle) = entry.join_handle.take() {
1601            eprintln!(
1602                "WARN_AGENTOS_VM_EXECUTOR_QUARANTINED: session={} generation={:?}",
1603                session_id, entry.output_generation
1604            );
1605            self.quarantined.push(QuarantinedSession {
1606                session_id: session_id.to_owned(),
1607                output_generation: entry.output_generation,
1608                join_handle,
1609                quarantined_at: Instant::now(),
1610                deadline_reported: false,
1611            });
1612        }
1613        Ok(())
1614    }
1615
1616    fn reap_finished_quarantines(&mut self) {
1617        let mut retained = Vec::with_capacity(self.quarantined.len());
1618        for mut quarantined in self.quarantined.drain(..) {
1619            if !quarantined.join_handle.is_finished() {
1620                if !quarantined.deadline_reported
1621                    && quarantined.quarantined_at.elapsed() >= self.executor_teardown_timeout
1622                {
1623                    quarantined.deadline_reported = true;
1624                    eprintln!(
1625                        "ERR_AGENTOS_VM_EXECUTOR_TEARDOWN_TIMEOUT: session={} generation={:?} deadline_ms={}; executor remains quarantined and retains its permit; raise runtime.executor.teardownTimeoutMs",
1626                        quarantined.session_id,
1627                        quarantined.output_generation,
1628                        self.executor_teardown_timeout.as_millis()
1629                    );
1630                }
1631                retained.push(quarantined);
1632                continue;
1633            }
1634            if quarantined.join_handle.join().is_err() {
1635                eprintln!(
1636                    "ERR_AGENTOS_VM_EXECUTOR_PANIC: quarantined session={} generation={:?}",
1637                    quarantined.session_id, quarantined.output_generation
1638                );
1639            } else {
1640                eprintln!(
1641                    "INFO_AGENTOS_VM_EXECUTOR_QUARANTINE_RELEASED: session={} generation={:?}",
1642                    quarantined.session_id, quarantined.output_generation
1643                );
1644            }
1645            self.call_id_router
1646                .cancel_session(&quarantined.session_id, quarantined.output_generation);
1647        }
1648        self.quarantined = retained;
1649    }
1650
1651    /// Destroy a session inline. Joins the session thread before returning, so
1652    /// this must not be called while a shared lock on the manager is held. Lock
1653    /// holders use `begin_destroy_session` and call `finish()` after unlocking.
1654    pub fn destroy_session(&mut self, session_id: &str) -> Result<(), String> {
1655        self.begin_destroy_session(session_id)?.finish();
1656        Ok(())
1657    }
1658
1659    /// First phase of destroying a session: terminate execution, signal abort,
1660    /// send shutdown, clear call routes, and remove the entry. The returned
1661    /// shutdown joins the session thread and must be finished after the
1662    /// SessionManager lock is released.
1663    pub fn begin_destroy_session(&mut self, session_id: &str) -> Result<SessionShutdown, String> {
1664        if !self.sessions.contains_key(session_id) {
1665            return Err(format!("session {} does not exist", session_id));
1666        }
1667
1668        let output_generation = self
1669            .sessions
1670            .get(session_id)
1671            .and_then(|entry| entry.output_generation);
1672        self.clear_call_routes_for_session(session_id, output_generation);
1673        let mut entry = self
1674            .sessions
1675            .remove(session_id)
1676            .expect("checked session exists");
1677        entry.pause_control.shutdown();
1678
1679        #[cfg(not(test))]
1680        if let Some(handle) = entry
1681            .isolate_handle
1682            .lock()
1683            .ok()
1684            .and_then(|guard| guard.as_ref().cloned())
1685        {
1686            handle.terminate_execution();
1687        }
1688        signal_execution_abort_durable(&entry.execution_abort, ExecutionAbortReason::Terminated);
1689        // Shutdown has a dedicated lane so a full ordinary command queue can
1690        // never turn the following join into a deadlock.
1691        signal_session_shutdown(&entry.shutdown_tx, session_id);
1692        let join_handle = entry.join_handle.take();
1693        drop(entry);
1694        Ok(SessionShutdown {
1695            session_id: session_id.to_owned(),
1696            output_generation,
1697            join_handle,
1698            call_id_router: Arc::clone(&self.call_id_router),
1699        })
1700    }
1701
1702    pub(crate) fn take_session_shutdown_handles(&mut self) -> Vec<thread::JoinHandle<()>> {
1703        self.call_id_router.clear();
1704
1705        let mut handles: Vec<_> = self
1706            .sessions
1707            .drain()
1708            .filter_map(|(session_id, mut entry)| {
1709                #[cfg(not(test))]
1710                if let Some(handle) = entry
1711                    .isolate_handle
1712                    .lock()
1713                    .ok()
1714                    .and_then(|guard| guard.as_ref().cloned())
1715                {
1716                    handle.terminate_execution();
1717                }
1718                signal_execution_abort_durable(
1719                    &entry.execution_abort,
1720                    ExecutionAbortReason::Terminated,
1721                );
1722                signal_session_shutdown(&entry.shutdown_tx, &session_id);
1723                drop(entry.tx);
1724                entry.join_handle.take()
1725            })
1726            .collect();
1727        handles.extend(self.warm_pool.shutdown_handles());
1728        handles.extend(
1729            self.quarantined
1730                .drain(..)
1731                .map(|quarantined| quarantined.join_handle),
1732        );
1733        handles
1734    }
1735
1736    #[cfg(test)]
1737    pub(crate) fn clear_call_route(&self, call_id: u64) {
1738        self.call_id_router.cancel(call_id);
1739    }
1740
1741    fn clear_call_routes_for_session(&self, session_id: &str, output_generation: Option<u64>) {
1742        self.call_id_router
1743            .cancel_session(session_id, output_generation);
1744    }
1745
1746    /// Resolve a session's command sender and apply message side effects that
1747    /// must happen under the manager lock (isolate termination, abort signal).
1748    /// The caller sends on the returned channel after releasing the lock so a
1749    /// full command channel cannot block the manager mutex.
1750    pub fn session_command_sender(
1751        &self,
1752        session_id: &str,
1753        msg: &SessionMessage,
1754    ) -> Result<(Sender<SessionCommand>, usize), String> {
1755        let entry = self
1756            .sessions
1757            .get(session_id)
1758            .ok_or_else(|| format!("session {} does not exist", session_id))?;
1759
1760        #[cfg(not(test))]
1761        if matches!(msg, SessionMessage::TerminateExecution) {
1762            if let Some(handle) = entry
1763                .isolate_handle
1764                .lock()
1765                .ok()
1766                .and_then(|guard| guard.as_ref().cloned())
1767            {
1768                handle.terminate_execution();
1769            }
1770        }
1771        if matches!(msg, SessionMessage::TerminateExecution) {
1772            signal_execution_abort(&entry.execution_abort, ExecutionAbortReason::Terminated);
1773        }
1774
1775        Ok((entry.tx.clone(), entry.command_capacity))
1776    }
1777
1778    /// Admit an ordinary message without ever blocking the thread that also
1779    /// routes call-specific bridge responses. Readiness must use
1780    /// `publish_readiness`; ordinary events are never reclassified by name.
1781    pub fn try_send_to_session(&self, session_id: &str, msg: SessionMessage) -> Result<(), String> {
1782        let terminate_requested = matches!(&msg, SessionMessage::TerminateExecution);
1783        let incoming_kind = match &msg {
1784            SessionMessage::InjectGlobals { .. } => String::from("inject_globals"),
1785            SessionMessage::Execute { .. } => String::from("execute"),
1786            SessionMessage::BridgeResponse(_) => String::from("bridge_response"),
1787            SessionMessage::StreamEvent(event) => format!("stream_event:{}", event.event_type),
1788            SessionMessage::TerminateExecution => String::from("terminate_execution"),
1789        };
1790        let (sender, command_capacity) = self.session_command_sender(session_id, &msg)?;
1791        let command = SessionCommand::Message(msg);
1792
1793        match sender.try_send(command) {
1794            Ok(()) => Ok(()),
1795            Err(crossbeam_channel::TrySendError::Full(_)) if terminate_requested => {
1796                // session_command_sender already delivered termination through
1797                // the isolate handle and execution-abort channel.
1798                Ok(())
1799            }
1800            Err(crossbeam_channel::TrySendError::Full(_)) => {
1801                Err(format!(
1802                    "ERR_AGENTOS_SESSION_COMMAND_LIMIT: session {session_id} command queue exceeded limit of {command_capacity} while admitting {incoming_kind} (queued={}); raise limits.reactor.maxHandleCommands",
1803                    sender.len()
1804                ))
1805            }
1806            Err(crossbeam_channel::TrySendError::Disconnected(_)) => {
1807                Err(format!(
1808                    "session thread disconnected for session {session_id}"
1809                ))
1810            }
1811        }
1812    }
1813
1814    pub fn publish_readiness(
1815        &self,
1816        session_id: &str,
1817        capability_id: u64,
1818        capability_generation: u64,
1819        flags: ReadyFlags,
1820    ) -> Result<(), String> {
1821        let entry = self
1822            .sessions
1823            .get(session_id)
1824            .ok_or_else(|| format!("session {session_id} does not exist"))?;
1825        entry
1826            .ready_broker
1827            .publish(capability_id, capability_generation, flags)
1828    }
1829
1830    pub fn publish_signal(&self, session_id: &str, signal: i32) -> Result<(), String> {
1831        let entry = self
1832            .sessions
1833            .get(session_id)
1834            .ok_or_else(|| format!("session {session_id} does not exist"))?;
1835        entry.ready_broker.publish_signal(signal)
1836    }
1837
1838    pub fn remove_readiness(
1839        &self,
1840        session_id: &str,
1841        capability_id: u64,
1842        capability_generation: u64,
1843    ) -> Result<(), String> {
1844        let entry = self
1845            .sessions
1846            .get(session_id)
1847            .ok_or_else(|| format!("session {session_id} does not exist"))?;
1848        entry
1849            .ready_broker
1850            .remove(capability_id, capability_generation)
1851    }
1852
1853    pub fn set_application_read_interest(
1854        &self,
1855        session_id: &str,
1856        capability_id: u64,
1857        capability_generation: u64,
1858        enabled: bool,
1859    ) -> Result<(), String> {
1860        let entry = self
1861            .sessions
1862            .get(session_id)
1863            .ok_or_else(|| format!("session {session_id} does not exist"))?;
1864        entry.ready_broker.set_application_read_interest(
1865            capability_id,
1866            capability_generation,
1867            enabled,
1868        )
1869    }
1870
1871    pub fn publish_timer(&self, session_id: &str, timer_id: u64) -> Result<(), String> {
1872        let entry = self
1873            .sessions
1874            .get(session_id)
1875            .ok_or_else(|| format!("session {session_id} does not exist"))?;
1876        entry.ready_broker.publish_timer(timer_id)
1877    }
1878
1879    /// Get a session's command sender without a message (used for control commands
1880    /// like SetModuleReader that aren't a SessionMessage). Dispatch-thread only.
1881    pub fn session_sender(
1882        &self,
1883        session_id: &str,
1884    ) -> Result<(Sender<SessionCommand>, usize), String> {
1885        self.sessions
1886            .get(session_id)
1887            .map(|entry| (entry.tx.clone(), entry.command_capacity))
1888            .ok_or_else(|| format!("session {} does not exist", session_id))
1889    }
1890
1891    /// Pause a session at the V8 execution boundary. A running synchronous
1892    /// script is interrupted on its isolate thread and remains parked with its
1893    /// JavaScript stack intact until `resume_session` is called.
1894    pub fn pause_session(&self, session_id: &str) -> Result<(), String> {
1895        let entry = self
1896            .sessions
1897            .get(session_id)
1898            .ok_or_else(|| format!("session {} does not exist", session_id))?;
1899        entry.pause_control.pause();
1900        #[cfg(not(test))]
1901        if let Some(handle) = entry
1902            .isolate_handle
1903            .lock()
1904            .ok()
1905            .and_then(|guard| guard.as_ref().cloned())
1906        {
1907            let raw = Arc::into_raw(Arc::clone(&entry.pause_control)) as *mut std::ffi::c_void;
1908            if !handle.request_interrupt(pause_isolate_interrupt, raw) {
1909                // SAFETY: V8 rejected the interrupt, so it did not consume the
1910                // strong reference transferred above.
1911                unsafe { drop(Arc::from_raw(raw.cast::<SessionPauseControl>())) };
1912            }
1913        }
1914        Ok(())
1915    }
1916
1917    pub fn resume_session(&self, session_id: &str) -> Result<(), String> {
1918        let entry = self
1919            .sessions
1920            .get(session_id)
1921            .ok_or_else(|| format!("session {} does not exist", session_id))?;
1922        entry.pause_control.resume();
1923        Ok(())
1924    }
1925
1926    /// Send a message to a session without blocking response/control progress.
1927    pub fn send_to_session(&self, session_id: &str, msg: SessionMessage) -> Result<(), String> {
1928        self.try_send_to_session(session_id, msg)
1929    }
1930
1931    /// Destroy a set of sessions inline, ignoring sessions that were already
1932    /// removed. Joins session threads, so this must not be called while a
1933    /// shared lock on the manager is held.
1934    pub fn destroy_sessions<I>(&mut self, session_ids: I)
1935    where
1936        I: IntoIterator<Item = String>,
1937    {
1938        for shutdown in self.begin_destroy_sessions(session_ids) {
1939            shutdown.finish();
1940        }
1941    }
1942
1943    /// Begin destroying a set of sessions, ignoring sessions that were already
1944    /// removed. Finish each returned shutdown after releasing the manager lock.
1945    pub fn begin_destroy_sessions<I>(&mut self, session_ids: I) -> Vec<SessionShutdown>
1946    where
1947        I: IntoIterator<Item = String>,
1948    {
1949        session_ids
1950            .into_iter()
1951            .filter_map(|sid| self.begin_destroy_session(&sid).ok())
1952            .collect()
1953    }
1954
1955    /// Number of registered sessions (including those waiting for a slot).
1956    #[allow(dead_code)]
1957    pub fn session_count(&self) -> usize {
1958        self.sessions.len()
1959    }
1960
1961    #[allow(dead_code)]
1962    pub fn quarantined_session_count(&mut self) -> usize {
1963        self.reap_finished_quarantines();
1964        self.quarantined.len()
1965    }
1966
1967    /// Return all session IDs.
1968    #[allow(dead_code)]
1969    pub fn all_sessions(&self) -> Vec<String> {
1970        self.sessions.keys().cloned().collect()
1971    }
1972
1973    /// Number of sessions that have acquired a concurrency slot.
1974    #[allow(dead_code)]
1975    pub fn active_slot_count(&self) -> usize {
1976        let (lock, _) = &*self.slot_control;
1977        *lock.lock().unwrap()
1978    }
1979
1980    pub fn session_output_generation(&self, session_id: &str) -> Option<u64> {
1981        self.sessions
1982            .get(session_id)
1983            .and_then(|entry| entry.output_generation)
1984    }
1985
1986    #[cfg(test)]
1987    pub fn session_resources(
1988        &self,
1989        session_id: &str,
1990    ) -> Option<Arc<agentos_runtime::accounting::ResourceLedger>> {
1991        self.sessions
1992            .get(session_id)
1993            .map(|entry| Arc::clone(&entry.session_resources))
1994    }
1995
1996    /// Get the direct bridge-call response registry.
1997    pub fn call_id_router(&self) -> &CallIdRouter {
1998        &self.call_id_router
1999    }
2000}
2001
2002/// Send a typed runtime event without re-serializing it on the session thread.
2003#[cfg(not(test))]
2004fn send_event_with_generation(
2005    event_tx: &RuntimeEventSender,
2006    output_generation: Option<u64>,
2007    event: RuntimeEvent,
2008) {
2009    if let Err(error) = event_tx.send(RuntimeEventEnvelope {
2010        output_generation,
2011        event,
2012    }) {
2013        eprintln!("failed to send runtime event: {error}");
2014    }
2015}
2016
2017fn send_late_message_warning(
2018    event_tx: &RuntimeEventSender,
2019    session_id: &str,
2020    output_generation: Option<u64>,
2021    error_code: &str,
2022    detail: String,
2023) {
2024    let warning = RuntimeEvent::Log {
2025        session_id: session_id.to_string(),
2026        channel: 1,
2027        message: format!("[{error_code}] {detail}"),
2028    };
2029    if let Err(error) = event_tx.send(RuntimeEventEnvelope {
2030        output_generation,
2031        event: warning,
2032    }) {
2033        eprintln!("failed to send late-session warning: {error}");
2034    }
2035}
2036
2037fn handle_late_session_message(
2038    event_tx: &RuntimeEventSender,
2039    session_id: &str,
2040    output_generation: Option<u64>,
2041    message: SessionMessage,
2042) {
2043    match message {
2044        SessionMessage::BridgeResponse(BridgeResponse {
2045            call_id,
2046            status,
2047            payload,
2048            reservation: _,
2049        }) => send_late_message_warning(
2050            event_tx,
2051            session_id,
2052            output_generation,
2053            LATE_BRIDGE_RESPONSE_ERROR_CODE,
2054            format!(
2055                "dropping BridgeResponse after execution completed (call_id={call_id}, status={status}, payload_len={})",
2056                payload.len()
2057            ),
2058        ),
2059        SessionMessage::StreamEvent(StreamEvent {
2060            event_type,
2061            payload,
2062        }) => {
2063            // Timer and socket-readiness events are wake hints, not data.
2064            // `stdin_end` is likewise an idempotent teardown notification. All
2065            // three can race execution completion by design and carry no data
2066            // to recover, so classify them as expected stale control events
2067            // instead of writing a false error into the guest's stderr.
2068            if event_type == "timer" || event_type == "net_socket" || event_type == "stdin_end" {
2069                return;
2070            }
2071            send_late_message_warning(
2072                event_tx,
2073                session_id,
2074                output_generation,
2075                LATE_STREAM_EVENT_ERROR_CODE,
2076                format!(
2077                    "dropping StreamEvent after execution completed (event_type={event_type}, payload_len={})",
2078                    payload.len()
2079                ),
2080            )
2081        }
2082        SessionMessage::TerminateExecution => send_late_message_warning(
2083            event_tx,
2084            session_id,
2085            output_generation,
2086            LATE_TERMINATE_EXECUTION_ERROR_CODE,
2087            String::from("dropping TerminateExecution after execution completed"),
2088        ),
2089        SessionMessage::InjectGlobals { .. } | SessionMessage::Execute { .. } => {}
2090    }
2091}
2092
2093#[cfg(not(test))]
2094fn install_wasm_module_bytes_global<'s>(scope: &mut v8::HandleScope<'s>, bytes: &[u8]) -> bool {
2095    let global = scope.get_current_context().global(scope);
2096    let Some(name) = v8::String::new(scope, "__agentOSWasmModuleBytes") else {
2097        return false;
2098    };
2099    let len = bytes.len();
2100    let backing_store = v8::ArrayBuffer::new_backing_store_from_bytes(bytes.to_vec());
2101    let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_store.make_shared());
2102    let Some(bytes_value) = v8::Uint8Array::new(scope, array_buffer, 0, len) else {
2103        return false;
2104    };
2105    global.set(scope, name.into(), bytes_value.into()).is_some()
2106}
2107
2108/// Session thread: acquires a concurrency slot, defers V8 isolate creation
2109/// to first Execute (when bridge code is known for snapshot lookup), and
2110/// processes commands until shutdown.
2111fn spawn_session_thread(assignment: SessionAssignment) -> std::io::Result<thread::JoinHandle<()>> {
2112    let name_prefix = if assignment.session_id.len() > 8 {
2113        assignment.session_id[..8].to_string()
2114    } else {
2115        assignment.session_id.clone()
2116    };
2117    // AGENTOS_THREAD_SITE: admitted-v8-session-executor
2118    thread::Builder::new()
2119        .name(format!("session-{}", name_prefix))
2120        .spawn(move || session_thread(assignment, None))
2121}
2122
2123fn recv_session_command(
2124    rx: &Receiver<SessionCommand>,
2125    shutdown_rx: &Receiver<()>,
2126    ready_rx: &Receiver<ReadyWake>,
2127    ready_broker: &SessionReadiness,
2128) -> Option<SessionCommand> {
2129    loop {
2130        // Commands already admitted before a readiness publication establish
2131        // the V8 context that consumes that readiness. In particular, a kill
2132        // can publish a signal immediately after Execute is queued. Selecting
2133        // the later signal first would enter the pre-execution discard branch
2134        // below and lose the default termination. Preserve admission order
2135        // before falling back to the fair blocking selector.
2136        match shutdown_rx.try_recv() {
2137            Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected) => {
2138                return Some(SessionCommand::Shutdown);
2139            }
2140            Err(crossbeam_channel::TryRecvError::Empty) => {}
2141        }
2142        match rx.try_recv() {
2143            Ok(command) => return Some(command),
2144            Err(crossbeam_channel::TryRecvError::Disconnected) => return None,
2145            Err(crossbeam_channel::TryRecvError::Empty) => {}
2146        }
2147        match ready_rx.try_recv() {
2148            Ok(wake) => match ready_batch_command(ready_broker, wake) {
2149                Ok(command) => return Some(command),
2150                Err(error) => {
2151                    eprintln!("{error}");
2152                    continue;
2153                }
2154            },
2155            Err(crossbeam_channel::TryRecvError::Disconnected) => return None,
2156            Err(crossbeam_channel::TryRecvError::Empty) => {}
2157        }
2158
2159        crossbeam_channel::select! {
2160            recv(shutdown_rx) -> _ => return Some(SessionCommand::Shutdown),
2161            recv(ready_rx) -> wake => {
2162                let wake = wake.ok()?;
2163                match ready_batch_command(ready_broker, wake) {
2164                    Ok(command) => return Some(command),
2165                    Err(error) => {
2166                        eprintln!("{error}");
2167                        continue;
2168                    }
2169                }
2170            },
2171            recv(rx) -> command => return command.ok(),
2172        }
2173    }
2174}
2175
2176/// Every consumed wake must become a command, including control-only and
2177/// currently-empty batches. Dispatching the batch is what completes the
2178/// broker epoch; dropping it would strand the capacity-one wake lane forever.
2179fn ready_batch_command(
2180    ready_broker: &SessionReadiness,
2181    wake: ReadyWake,
2182) -> Result<SessionCommand, String> {
2183    ready_broker
2184        .take_batch(wake)
2185        .map(SessionCommand::ReadyBatch)
2186}
2187
2188#[allow(clippy::too_many_arguments)]
2189fn session_thread(
2190    assignment: SessionAssignment,
2191    #[cfg_attr(test, allow(unused_variables))] precreated_isolate: Option<PrecreatedIsolate>,
2192) {
2193    let SessionAssignment {
2194        heap_limit_mb,
2195        cpu_time_limit_ms,
2196        wall_clock_limit_ms,
2197        rx,
2198        shutdown_rx,
2199        ready_rx,
2200        ready_broker,
2201        slot_permit: _slot_permit,
2202        event_tx,
2203        call_id_router,
2204        shared_call_id,
2205        snapshot_cache,
2206        isolate_handle,
2207        execution_abort,
2208        pause_control,
2209        session_id,
2210        output_generation,
2211        runtime,
2212        bridge_call_timeout,
2213    } = assignment;
2214    #[cfg(not(test))]
2215    let execution_task_owner =
2216        output_generation.map(|generation| agentos_runtime::TaskOwner::Vm { generation });
2217    #[cfg(test)]
2218    let _ = (
2219        heap_limit_mb,
2220        cpu_time_limit_ms,
2221        wall_clock_limit_ms,
2222        call_id_router,
2223        shared_call_id,
2224        snapshot_cache,
2225        isolate_handle,
2226        execution_abort,
2227        &pause_control,
2228        &ready_broker,
2229        &runtime,
2230        bridge_call_timeout,
2231    );
2232
2233    // Capture THIS session thread's per-thread CPU clock once. The clock id is
2234    // stable for the thread's lifetime and can be polled from the watchdog
2235    // thread; this is what lets the CPU-budget guard measure active JS CPU time
2236    // (excluding idle/await) without running on the execution thread itself.
2237    // Guest JS always runs on this thread, so this clock is the execution clock.
2238    #[cfg(all(not(test), unix))]
2239    let exec_thread_cpu_clock = crate::timeout::current_thread_cpu_clock();
2240    #[cfg(all(not(test), not(unix)))]
2241    let exec_thread_cpu_clock: Option<crate::timeout::ThreadCpuClock> = None;
2242
2243    // Isolate creation is normally deferred to first Execute (when bridge code is
2244    // known for snapshot cache lookup). A claimed warm worker enters here with
2245    // the snapshot isolate already created on this same thread.
2246    #[cfg(not(test))]
2247    let (
2248        mut v8_isolate,
2249        mut _v8_context,
2250        mut from_snapshot,
2251        mut isolate_bridge_code,
2252        mut isolate_userland_code,
2253    ) = match precreated_isolate {
2254        Some(mut precreated) => (
2255            precreated.isolate.take(),
2256            precreated.context.take(),
2257            true,
2258            Some(std::mem::take(&mut precreated.bridge_code)),
2259            Some(std::mem::take(&mut precreated.userland_code)),
2260        ),
2261        None => (None, None, false, None, None),
2262    };
2263
2264    #[cfg(not(test))]
2265    let mut pending = bridge::PendingPromises::new();
2266
2267    // Store latest InjectGlobals V8 payload for re-injection into fresh contexts
2268    #[cfg(not(test))]
2269    let mut last_globals_payload: Option<Vec<u8>> = None;
2270
2271    // Bridge code cache for V8 code caching across executions
2272    #[cfg(not(test))]
2273    let mut bridge_cache: Option<execution::BridgeCodeCache> = None;
2274
2275    // Cached bridge code string to skip resending over IPC
2276    #[cfg(not(test))]
2277    let mut last_bridge_code: Option<String> = None;
2278    // Cached agent-SDK userland bundle (same 0-length = use cached convention).
2279    #[cfg(not(test))]
2280    let mut last_userland_code: Option<String> = None;
2281
2282    // A session can reuse its isolate across Executes only while the effective
2283    // bridge code stays the same. Fresh contexts cloned from a snapshot inherit
2284    // the snapshot's bridge IIFE, so a bridge-code change must rebuild the
2285    // isolate before the next execution or the session will keep restoring the
2286    // old snapshot forever. The userland bundle is part of the same guard.
2287    #[cfg(not(test))]
2288    let mut high_resolution_time_origin = Instant::now();
2289
2290    #[cfg(not(test))]
2291    if let Some(iso) = v8_isolate.as_mut() {
2292        *isolate_handle
2293            .lock()
2294            .expect("session isolate handle lock poisoned") = Some(iso.thread_safe_handle());
2295    }
2296
2297    // Process commands until shutdown or channel close
2298    loop {
2299        let next_command = recv_session_command(&rx, &shutdown_rx, &ready_rx, &ready_broker);
2300
2301        pause_control.wait_while_paused();
2302        match next_command {
2303            Some(SessionCommand::Shutdown) | None => break,
2304            Some(SessionCommand::ReadyBatch(batch)) => {
2305                if batch.signals_ready {
2306                    if let Err(error) = ready_broker.drain_signals(&batch) {
2307                        eprintln!("ERR_AGENTOS_READY_DISCARD: could not discard signals: {error}");
2308                    }
2309                }
2310                if batch.timers_ready {
2311                    if let Err(error) = ready_broker.drain_timers(&batch) {
2312                        eprintln!("ERR_AGENTOS_READY_DISCARD: could not discard timers: {error}");
2313                    }
2314                }
2315                if let Err(error) = ready_broker.complete_batch(&batch, &batch.entries) {
2316                    eprintln!("{error}");
2317                }
2318            }
2319            Some(SessionCommand::SetModuleReader(reader)) => {
2320                execution::install_session_guest_reader(Some(reader));
2321            }
2322            Some(SessionCommand::Message(msg)) => match msg {
2323                SessionMessage::InjectGlobals { payload } => {
2324                    #[cfg(not(test))]
2325                    {
2326                        // Store V8-serialized config for injection into fresh context at Execute time
2327                        last_globals_payload = Some(payload);
2328                    }
2329                    #[cfg(test)]
2330                    {
2331                        let _ = payload;
2332                    }
2333                }
2334                SessionMessage::Execute {
2335                    mode,
2336                    file_path,
2337                    bridge_code,
2338                    post_restore_script,
2339                    userland_code,
2340                    high_resolution_time,
2341                    user_code,
2342                    wasm_module_bytes,
2343                } => {
2344                    // `userland_code` is consumed only by the non-test snapshot
2345                    // path below; keep it bound (without a warning) under `test`.
2346                    #[cfg(test)]
2347                    let _ = &userland_code;
2348                    #[cfg(test)]
2349                    let _ = high_resolution_time;
2350                    #[cfg(test)]
2351                    let _ = &wasm_module_bytes;
2352                    #[cfg(not(test))]
2353                    {
2354                        let session_id = session_id.clone();
2355                        // Use cached bridge code when host sends empty (0-length = use cached)
2356                        let should_update_cached_bridge_code = !bridge_code.is_empty();
2357                        let effective_bridge_code = if bridge_code.is_empty() {
2358                            last_bridge_code.as_deref().unwrap_or("").to_string()
2359                        } else {
2360                            bridge_code
2361                        };
2362                        // Same 0-length = use-cached convention for the userland bundle.
2363                        let should_update_cached_userland_code = !userland_code.is_empty();
2364                        let effective_userland_code = if userland_code.is_empty() {
2365                            last_userland_code.as_deref().unwrap_or("").to_string()
2366                        } else {
2367                            userland_code
2368                        };
2369
2370                        if let Err(message) =
2371                            snapshot::validate_bridge_code_size(&effective_bridge_code)
2372                        {
2373                            let result_frame = RuntimeEvent::ExecutionResult {
2374                                session_id,
2375                                exit_code: 1,
2376                                exports: None,
2377                                error: Some(ExecutionErrorBin {
2378                                    error_type: "Error".into(),
2379                                    message,
2380                                    stack: String::new(),
2381                                    code: snapshot::V8_BRIDGE_CODE_LIMIT_ERROR_CODE.into(),
2382                                }),
2383                            };
2384                            send_event_with_generation(&event_tx, output_generation, result_frame);
2385                            continue;
2386                        }
2387
2388                        if should_update_cached_bridge_code {
2389                            last_bridge_code = Some(effective_bridge_code.clone());
2390                        }
2391                        if should_update_cached_userland_code {
2392                            last_userland_code = Some(effective_userland_code.clone());
2393                        }
2394
2395                        if v8_isolate.is_some()
2396                            && (isolate_bridge_code.as_deref()
2397                                != Some(effective_bridge_code.as_str())
2398                                || isolate_userland_code.as_deref()
2399                                    != Some(effective_userland_code.as_str()))
2400                        {
2401                            *isolate_handle
2402                                .lock()
2403                                .expect("session isolate handle lock poisoned") = None;
2404                            // Reset pending promise-resolver Globals BEFORE this
2405                            // isolate is dropped. The registry is reused across
2406                            // isolate rebuilds, and a prior execution that was
2407                            // terminated early (Shutdown / timeout-abort) can
2408                            // leave resolvers registered, so they would otherwise
2409                            // outlive the isolate that created them.
2410                            reset_pending_promises(&mut pending);
2411                            drop(_v8_context.take());
2412                            isolate::drop_isolate(v8_isolate.take());
2413                            from_snapshot = false;
2414                            isolate_bridge_code = None;
2415                            isolate_userland_code = None;
2416                        }
2417
2418                        // Deferred isolate creation: create on first Execute using snapshot cache
2419                        if v8_isolate.is_none() {
2420                            isolate::init_v8_platform();
2421                            // The snapshot captures the bridge AND (when present) the
2422                            // agent-SDK userland bundle, keyed process-wide by both, so
2423                            // the SDK is evaluated once per sidecar and reused here.
2424                            let phase_start = Instant::now();
2425                            let snapshot_blob = match snapshot_cache.get_or_create_with_userland(
2426                                &effective_bridge_code,
2427                                (!effective_userland_code.is_empty())
2428                                    .then_some(effective_userland_code.as_str()),
2429                            ) {
2430                                Ok(blob) => Some(blob),
2431                                Err(message) => {
2432                                    // Snapshot creation runs in a helper subprocess; if
2433                                    // that fails (unsupported platform, spawn failure),
2434                                    // degrade to a fresh isolate that evaluates the
2435                                    // bridge in-context rather than failing the session.
2436                                    eprintln!(
2437                                        "agentos-v8-runtime: snapshot creation failed, \
2438                                         falling back to fresh isolate: {message}"
2439                                    );
2440                                    None
2441                                }
2442                            };
2443                            record_v8_session_phase("snapshot_get", phase_start.elapsed());
2444                            let mut iso = match snapshot_blob {
2445                                Some(blob) => {
2446                                    from_snapshot = true;
2447                                    eprintln!(
2448                                        "agentos-v8-runtime: restored session isolate from_snapshot=true"
2449                                    );
2450                                    let phase_start = Instant::now();
2451                                    // rusty_v8 0.130's CreateParams::snapshot_blob
2452                                    // takes owned 'static data, so this copy remains
2453                                    // per exec until the API can accept cached bytes.
2454                                    let snapshot_blob = (*blob).clone();
2455                                    record_v8_session_phase("blob_clone", phase_start.elapsed());
2456                                    let phase_start = Instant::now();
2457                                    let isolate = snapshot::create_isolate_from_snapshot(
2458                                        snapshot_blob,
2459                                        heap_limit_mb,
2460                                    );
2461                                    record_v8_session_phase("isolate_new", phase_start.elapsed());
2462                                    isolate
2463                                }
2464                                None => {
2465                                    from_snapshot = false;
2466                                    let phase_start = Instant::now();
2467                                    let isolate = isolate::create_isolate(heap_limit_mb);
2468                                    record_v8_session_phase("isolate_new", phase_start.elapsed());
2469                                    isolate
2470                                }
2471                            };
2472                            iso.set_host_import_module_dynamically_callback(
2473                                execution::dynamic_import_callback,
2474                            );
2475                            iso.set_host_initialize_import_meta_object_callback(
2476                                execution::import_meta_object_callback,
2477                            );
2478                            high_resolution_time_origin = Instant::now();
2479                            *isolate_handle
2480                                .lock()
2481                                .expect("session isolate handle lock poisoned") =
2482                                Some(iso.thread_safe_handle());
2483                            let ctx = isolate::create_context(&mut iso);
2484                            _v8_context = Some(ctx);
2485                            v8_isolate = Some(iso);
2486                            isolate_bridge_code = Some(effective_bridge_code.clone());
2487                            isolate_userland_code = Some(effective_userland_code.clone());
2488                        }
2489
2490                        let iso = v8_isolate.as_mut().unwrap();
2491                        iso.cancel_terminate_execution();
2492
2493                        // Create execution context: Context::new on a snapshot-restored
2494                        // isolate gives a fresh clone of the snapshot's default context
2495                        // (bridge IIFE already executed, all infrastructure set up).
2496                        // On a non-snapshot isolate, this gives a blank context.
2497                        let exec_context = isolate::create_context(iso);
2498
2499                        if high_resolution_time {
2500                            let scope = &mut v8::HandleScope::new(iso);
2501                            let ctx = v8::Local::new(scope, &exec_context);
2502                            let scope = &mut v8::ContextScope::new(scope, ctx);
2503                            execution::install_high_resolution_time_global(
2504                                scope,
2505                                &high_resolution_time_origin as *const Instant,
2506                            );
2507                        }
2508
2509                        // Inject globals from last InjectGlobals payload
2510                        if let Some(ref payload) = last_globals_payload {
2511                            let scope = &mut v8::HandleScope::new(iso);
2512                            let ctx = v8::Local::new(scope, &exec_context);
2513                            let scope = &mut v8::ContextScope::new(scope, ctx);
2514                            if let Err(error) =
2515                                execution::inject_globals_from_payload(scope, payload)
2516                            {
2517                                let result_frame = RuntimeEvent::ExecutionResult {
2518                                    session_id,
2519                                    exit_code: 1,
2520                                    exports: None,
2521                                    error: Some(ExecutionErrorBin {
2522                                        error_type: error.error_type,
2523                                        message: error.message,
2524                                        stack: error.stack,
2525                                        code: error.code.unwrap_or_default(),
2526                                    }),
2527                                };
2528                                send_event_with_generation(
2529                                    &event_tx,
2530                                    output_generation,
2531                                    result_frame,
2532                                );
2533                                continue;
2534                            }
2535                        }
2536
2537                        // Arm a per-execution abort channel so timeouts and external
2538                        // terminate requests can unblock sync bridge waits.
2539                        let (_active_execution_abort, abort_rx) =
2540                            ActiveExecutionAbort::arm(&execution_abort);
2541
2542                        // Async completions have a dedicated bounded lane.
2543                        // Synchronous calls register their own capacity-one
2544                        // waiter in the same call-specific registry.
2545                        let (async_response_tx, async_response_rx) =
2546                            crossbeam_channel::bounded(bridge::MAX_PENDING_PROMISES);
2547                        let bridge_ctx = BridgeCallContext::with_registry(
2548                            Box::new(ChannelRuntimeEventSender::new(
2549                                event_tx.clone(),
2550                                output_generation,
2551                            )),
2552                            session_id.clone(),
2553                            output_generation,
2554                            Arc::clone(&call_id_router),
2555                            Arc::clone(&shared_call_id),
2556                            async_response_tx,
2557                            abort_rx.clone(),
2558                            runtime.clone(),
2559                            Arc::clone(&pause_control),
2560                            bridge_call_timeout,
2561                        );
2562
2563                        // Replace stub bridge functions with real session-local ones
2564                        // (on snapshot context) or register from scratch (on fresh context).
2565                        // Both paths use the same function — global.set() works for both.
2566                        let _sync_store;
2567                        let _async_store;
2568                        let sync_bridge_fns = sync_bridge_fns();
2569                        let async_bridge_fns = async_bridge_fns();
2570                        {
2571                            let scope = &mut v8::HandleScope::new(iso);
2572                            let ctx = v8::Local::new(scope, &exec_context);
2573                            let scope = &mut v8::ContextScope::new(scope, ctx);
2574
2575                            (_sync_store, _async_store) = bridge::replace_bridge_fns(
2576                                scope,
2577                                &bridge_ctx as *const BridgeCallContext,
2578                                &pending as *const bridge::PendingPromises,
2579                                sync_bridge_fns,
2580                                async_bridge_fns,
2581                            );
2582                        }
2583
2584                        // Run post-restore init script (config, mutable state reset)
2585                        // after bridge fn replacement but before user code
2586                        if !post_restore_script.is_empty() {
2587                            let scope = &mut v8::HandleScope::new(iso);
2588                            let ctx = v8::Local::new(scope, &exec_context);
2589                            let scope = &mut v8::ContextScope::new(scope, ctx);
2590                            let (prs_code, prs_err) =
2591                                execution::run_init_script(scope, &post_restore_script);
2592                            if prs_code != 0 {
2593                                let result_frame = RuntimeEvent::ExecutionResult {
2594                                    session_id,
2595                                    exit_code: prs_code,
2596                                    exports: None,
2597                                    error: prs_err.map(|e| ExecutionErrorBin {
2598                                        error_type: e.error_type,
2599                                        message: e.message,
2600                                        stack: e.stack,
2601                                        code: e.code.unwrap_or_default(),
2602                                    }),
2603                                };
2604                                send_event_with_generation(
2605                                    &event_tx,
2606                                    output_generation,
2607                                    result_frame,
2608                                );
2609                                continue;
2610                            }
2611                        }
2612
2613                        if let Some(wasm_module_bytes) = wasm_module_bytes.as_ref() {
2614                            let scope = &mut v8::HandleScope::new(iso);
2615                            let ctx = v8::Local::new(scope, &exec_context);
2616                            let scope = &mut v8::ContextScope::new(scope, ctx);
2617                            if !install_wasm_module_bytes_global(scope, wasm_module_bytes) {
2618                                let result_frame = RuntimeEvent::ExecutionResult {
2619                                    session_id,
2620                                    exit_code: 1,
2621                                    exports: None,
2622                                    error: Some(ExecutionErrorBin {
2623                                        error_type: "Error".into(),
2624                                        message: "failed to install __agentOSWasmModuleBytes"
2625                                            .into(),
2626                                        stack: String::new(),
2627                                        code: String::new(),
2628                                    }),
2629                                };
2630                                send_event_with_generation(
2631                                    &event_tx,
2632                                    output_generation,
2633                                    result_frame,
2634                                );
2635                                continue;
2636                            }
2637                        }
2638
2639                        // Arm the TRUE CPU-TIME budget watchdog before running
2640                        // guest code when the caller passes a nonzero
2641                        // `limits.jsRuntime.cpuTimeLimitMs` (normalized: `0`/unset =>
2642                        // `None` => not armed at this runtime layer). The sidecar
2643                        // supplies the bounded default for VM executions.
2644                        //
2645                        // The watchdog counts ACTIVE JS CPU only (idle/await
2646                        // excluded) by polling the execution thread's CPU clock, so
2647                        // a guest that mostly awaits is NOT killed by it. The
2648                        // INDEPENDENT wall-clock backstop (armed just below) covers
2649                        // the idle/await case when the operator opts into it.
2650                        let mut cpu_budget_guard = match cpu_time_limit_ms {
2651                            Some(budget_ms) => {
2652                                // Enforcing a CPU budget requires the execution
2653                                // thread's CPU clock captured at session start. If
2654                                // it is unavailable we cannot honor the operator's
2655                                // requested cap — surface that rather than silently
2656                                // running uncapped.
2657                                let cpu_clock = match exec_thread_cpu_clock {
2658                                    Some(clock) => clock,
2659                                    None => {
2660                                        let result_frame = RuntimeEvent::ExecutionResult {
2661                                            session_id,
2662                                            exit_code: 1,
2663                                            exports: None,
2664                                            error: Some(ExecutionErrorBin {
2665                                                error_type: "Error".into(),
2666                                                message: format!(
2667                                                    "{}: per-thread CPU clock unavailable; cannot enforce limits.jsRuntime.cpuTimeLimitMs",
2668                                                    crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
2669                                                ),
2670                                                stack: String::new(),
2671                                                code: crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
2672                                                    .into(),
2673                                            }),
2674                                        };
2675                                        send_event_with_generation(
2676                                            &event_tx,
2677                                            output_generation,
2678                                            result_frame,
2679                                        );
2680                                        continue;
2681                                    }
2682                                };
2683                                let handle = iso.thread_safe_handle();
2684                                match crate::timeout::CpuBudgetGuard::new(
2685                                    &runtime,
2686                                    execution_task_owner.clone(),
2687                                    budget_ms,
2688                                    cpu_clock,
2689                                    handle,
2690                                    execution_abort.clone(),
2691                                ) {
2692                                    Ok(guard) => Some(guard),
2693                                    Err(message) => {
2694                                        let result_frame = RuntimeEvent::ExecutionResult {
2695                                            session_id,
2696                                            exit_code: 1,
2697                                            exports: None,
2698                                            error: Some(ExecutionErrorBin {
2699                                                error_type: "Error".into(),
2700                                                message,
2701                                                stack: String::new(),
2702                                                code:
2703                                                    crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
2704                                                        .into(),
2705                                            }),
2706                                        };
2707                                        send_event_with_generation(
2708                                            &event_tx,
2709                                            output_generation,
2710                                            result_frame,
2711                                        );
2712                                        continue;
2713                                    }
2714                                }
2715                            }
2716                            _ => None,
2717                        };
2718
2719                        // Arm the INDEPENDENT, opt-in WALL-CLOCK backstop alongside
2720                        // the CPU budget. Unlike the CPU budget, this counts elapsed
2721                        // real time INCLUDING idle/await, so it can cap a guest that
2722                        // blocks or awaits indefinitely. Armed only when the operator
2723                        // opts in via `limits.jsRuntime.wallClockLimitMs` (normalized:
2724                        // `0`/unset => `None` => not armed => NO wall-clock limit, so
2725                        // long-lived ACP adapters are never killed by a default).
2726                        // Whichever guard fires first calls `terminate_execution` and
2727                        // records its abort reason; the result frame reports which.
2728                        let mut wall_clock_guard = match wall_clock_limit_ms {
2729                            Some(limit_ms) => {
2730                                let handle = iso.thread_safe_handle();
2731                                match crate::timeout::TimeoutGuard::with_execution_abort(
2732                                    &runtime,
2733                                    execution_task_owner.clone(),
2734                                    limit_ms,
2735                                    handle,
2736                                    execution_abort.clone(),
2737                                ) {
2738                                    Ok(guard) => Some(guard),
2739                                    Err(message) => {
2740                                        let result_frame = RuntimeEvent::ExecutionResult {
2741                                            session_id,
2742                                            exit_code: 1,
2743                                            exports: None,
2744                                            error: Some(ExecutionErrorBin {
2745                                                error_type: "Error".into(),
2746                                                message,
2747                                                stack: String::new(),
2748                                                code:
2749                                                    crate::timeout::TIMEOUT_GUARD_START_ERROR_CODE
2750                                                        .into(),
2751                                            }),
2752                                        };
2753                                        send_event_with_generation(
2754                                            &event_tx,
2755                                            output_generation,
2756                                            result_frame,
2757                                        );
2758                                        continue;
2759                                    }
2760                                }
2761                            }
2762                            _ => None,
2763                        };
2764
2765                        // On snapshot-restored context, skip bridge IIFE (already in
2766                        // snapshot) and run user code only. On fresh context, run full
2767                        // bridge code + user code as before.
2768                        let bridge_code_for_exec = if from_snapshot {
2769                            ""
2770                        } else {
2771                            &effective_bridge_code
2772                        };
2773                        let file_path_opt = if file_path.is_empty() {
2774                            None
2775                        } else {
2776                            Some(file_path.as_str())
2777                        };
2778                        let phase_start = Instant::now();
2779                        let (mut code, mut exports, mut error) = if mode == 0 {
2780                            let scope = &mut v8::HandleScope::new(iso);
2781                            let ctx = v8::Local::new(scope, &exec_context);
2782                            let scope = &mut v8::ContextScope::new(scope, ctx);
2783                            let (c, e) = execution::execute_script_with_options(
2784                                scope,
2785                                Some(&bridge_ctx),
2786                                bridge_code_for_exec,
2787                                &user_code,
2788                                file_path_opt,
2789                                &mut bridge_cache,
2790                            );
2791                            (c, None, e)
2792                        } else {
2793                            let scope = &mut v8::HandleScope::new(iso);
2794                            let ctx = v8::Local::new(scope, &exec_context);
2795                            let scope = &mut v8::ContextScope::new(scope, ctx);
2796                            execution::execute_module(
2797                                scope,
2798                                &bridge_ctx,
2799                                bridge_code_for_exec,
2800                                &user_code,
2801                                file_path_opt,
2802                                &mut bridge_cache,
2803                            )
2804                        };
2805
2806                        // Re-check async ESM completion once immediately so
2807                        // pure-microtask top-level await settles without
2808                        // needing a bridge event-loop round-trip.
2809                        if mode != 0 && error.is_none() {
2810                            let scope = &mut v8::HandleScope::new(iso);
2811                            let ctx = v8::Local::new(scope, &exec_context);
2812                            let scope = &mut v8::ContextScope::new(scope, ctx);
2813                            if let Some((next_code, next_exports, next_error)) =
2814                                execution::finalize_pending_module_evaluation(scope)
2815                            {
2816                                code = next_code;
2817                                exports = next_exports;
2818                                error = next_error;
2819                            }
2820                        }
2821                        record_v8_session_phase("user_code_execute", phase_start.elapsed());
2822
2823                        // Run event loop while bridge work or async ESM
2824                        // evaluation is still pending. For ESM modules (mode != 0),
2825                        // always enter the event loop even if no pending promises
2826                        // are visible yet — the module body may have registered
2827                        // timers, stdin listeners, or child_process handles that
2828                        // need event loop pumping to deliver their callbacks.
2829                        let should_enter_event_loop = !pending.is_empty()
2830                            || execution::has_pending_module_evaluation()
2831                            || execution::has_pending_script_evaluation();
2832                        let event_loop_status = if should_enter_event_loop {
2833                            let scope = &mut v8::HandleScope::new(iso);
2834                            let ctx = v8::Local::new(scope, &exec_context);
2835                            let scope = &mut v8::ContextScope::new(scope, ctx);
2836                            run_event_loop_with_readiness(
2837                                scope,
2838                                EventLoopSources {
2839                                    commands: &rx,
2840                                    readiness: &ready_broker,
2841                                    readiness_wakes: &ready_rx,
2842                                    bridge_responses: Some(&async_response_rx),
2843                                    abort: Some(&abort_rx),
2844                                    pause: Some(&pause_control),
2845                                },
2846                                &pending,
2847                            )
2848                        } else {
2849                            EventLoopStatus::Completed
2850                        };
2851
2852                        let mut terminated =
2853                            matches!(event_loop_status, EventLoopStatus::Terminated);
2854                        if let EventLoopStatus::Failed(next_code, next_error) = event_loop_status {
2855                            code = next_code;
2856                            error = Some(next_error);
2857                        }
2858
2859                        // Finalize any entry-module top-level await that was
2860                        // waiting on bridge-driven async work (timers/network).
2861                        if !terminated && mode != 0 && error.is_none() {
2862                            let scope = &mut v8::HandleScope::new(iso);
2863                            let ctx = v8::Local::new(scope, &exec_context);
2864                            let scope = &mut v8::ContextScope::new(scope, ctx);
2865                            if let Some((next_code, next_exports, next_error)) =
2866                                execution::finalize_pending_module_evaluation(scope)
2867                            {
2868                                code = next_code;
2869                                exports = next_exports;
2870                                error = next_error;
2871                            }
2872                        }
2873
2874                        // Keep the session alive while handles (timers, child
2875                        // processes, stdin listeners) are active. Long-lived
2876                        // ACP adapters often run as plain scripts, so this
2877                        // cannot be limited to ESM entrypoints.
2878                        if !terminated && error.is_none() {
2879                            // Destruction can race with the short gap before the
2880                            // active-handle pass. Observe its durable abort before
2881                            // calling into an isolate another thread terminated.
2882                            if execution_abort_requested(&abort_rx) {
2883                                terminated = true;
2884                            } else {
2885                                // Phase 1: call _waitForActiveHandles() once. Repeating
2886                                // this after it resolves can re-capture an idle HTTP
2887                                // keep-alive socket and prevent an otherwise-complete
2888                                // one-shot script from ever exiting.
2889                                {
2890                                    let scope = &mut v8::HandleScope::new(iso);
2891                                    let ctx = v8::Local::new(scope, &exec_context);
2892                                    let scope = &mut v8::ContextScope::new(scope, ctx);
2893                                    let global = ctx.global(scope);
2894                                    let key =
2895                                        v8::String::new(scope, "_waitForActiveHandles").unwrap();
2896                                    if let Some(func) = global.get(scope, key.into()) {
2897                                        if func.is_function() {
2898                                            let func =
2899                                                v8::Local::<v8::Function>::try_from(func).unwrap();
2900                                            let recv = v8::undefined(scope).into();
2901                                            if let Some(result) = func.call(scope, recv, &[]) {
2902                                                if result.is_promise() {
2903                                                    let promise =
2904                                                        v8::Local::<v8::Promise>::try_from(result)
2905                                                            .unwrap();
2906                                                    if promise.state() == v8::PromiseState::Pending
2907                                                    {
2908                                                        execution::set_pending_script_evaluation(
2909                                                            scope, promise,
2910                                                        );
2911                                                    }
2912                                                }
2913                                            }
2914                                        }
2915                                    }
2916                                }
2917
2918                                // Phase 2: pump the event loop for that quiescence wait.
2919                                if !pending.is_empty() || execution::has_pending_script_evaluation()
2920                                {
2921                                    let scope = &mut v8::HandleScope::new(iso);
2922                                    let ctx = v8::Local::new(scope, &exec_context);
2923                                    let scope = &mut v8::ContextScope::new(scope, ctx);
2924                                    let event_loop_status = run_event_loop_with_readiness(
2925                                        scope,
2926                                        EventLoopSources {
2927                                            commands: &rx,
2928                                            readiness: &ready_broker,
2929                                            readiness_wakes: &ready_rx,
2930                                            bridge_responses: Some(&async_response_rx),
2931                                            abort: Some(&abort_rx),
2932                                            pause: Some(&pause_control),
2933                                        },
2934                                        &pending,
2935                                    );
2936
2937                                    if matches!(event_loop_status, EventLoopStatus::Terminated) {
2938                                        terminated = true;
2939                                    }
2940                                    if let EventLoopStatus::Failed(next_code, next_error) =
2941                                        event_loop_status
2942                                    {
2943                                        code = next_code;
2944                                        error = Some(next_error);
2945                                    }
2946                                }
2947                            }
2948                        }
2949
2950                        if !terminated && mode == 0 && error.is_none() {
2951                            let scope = &mut v8::HandleScope::new(iso);
2952                            let ctx = v8::Local::new(scope, &exec_context);
2953                            let scope = &mut v8::ContextScope::new(scope, ctx);
2954                            if let Some((next_code, next_error)) =
2955                                execution::finalize_pending_script_evaluation(scope)
2956                            {
2957                                code = next_code;
2958                                error = next_error;
2959                            }
2960                        }
2961
2962                        // Async callbacks may assign process.exitCode after the entry script's
2963                        // initial synchronous completion. Re-read it after all active handles
2964                        // and pending script evaluation have drained so spawn() and exec()
2965                        // report the same final Node process status.
2966                        if !terminated && error.is_none() {
2967                            let scope = &mut v8::HandleScope::new(iso);
2968                            let ctx = v8::Local::new(scope, &exec_context);
2969                            let scope = &mut v8::ContextScope::new(scope, ctx);
2970                            if let Some(process_exit_code) =
2971                                execution::extract_global_process_exit_code(scope)
2972                            {
2973                                code = process_exit_code;
2974                            }
2975                        }
2976
2977                        // Determine which execution budget (if any) fired. Both the
2978                        // CPU-time budget and the wall-clock backstop can be armed;
2979                        // whichever fired first recorded its abort reason. Prefer the
2980                        // recorded abort reason (first-writer-wins) so the result
2981                        // attributes termination to the guard that actually fired.
2982                        let abort_reason = execution_abort_reason(&execution_abort);
2983                        let wall_clock_timed_out =
2984                            wall_clock_guard.as_ref().is_some_and(|g| g.timed_out())
2985                                || matches!(
2986                                    abort_reason,
2987                                    Some(ExecutionAbortReason::WallClockTimedOut)
2988                                );
2989                        let cpu_budget_exceeded =
2990                            cpu_budget_guard.as_ref().is_some_and(|g| g.exceeded())
2991                                || matches!(
2992                                    abort_reason,
2993                                    Some(ExecutionAbortReason::CpuBudgetExceeded)
2994                                );
2995                        // If both happened to fire, the recorded abort reason is the
2996                        // authoritative first-fired guard; fall back to wall-clock
2997                        // only when no CPU-budget reason was recorded.
2998                        let cpu_budget_exceeded = cpu_budget_exceeded
2999                            && !matches!(
3000                                abort_reason,
3001                                Some(ExecutionAbortReason::WallClockTimedOut)
3002                            );
3003                        let wall_clock_timed_out = wall_clock_timed_out && !cpu_budget_exceeded;
3004
3005                        // Cancel both watchdogs (joins their threads).
3006                        if let Some(ref mut guard) = cpu_budget_guard {
3007                            guard.cancel();
3008                        }
3009                        drop(cpu_budget_guard);
3010                        if let Some(ref mut guard) = wall_clock_guard {
3011                            guard.cancel();
3012                        }
3013                        drop(wall_clock_guard);
3014
3015                        if matches!(abort_reason, Some(ExecutionAbortReason::Terminated)) {
3016                            terminated = true;
3017                            code = 1;
3018                            exports = None;
3019                            error = None;
3020                        }
3021                        if terminated || cpu_budget_exceeded || wall_clock_timed_out {
3022                            iso.cancel_terminate_execution();
3023                        }
3024
3025                        // Send ExecutionResult
3026                        let result_frame = if cpu_budget_exceeded {
3027                            if let Some(budget_ms) = cpu_time_limit_ms {
3028                                let capacity = budget_ms as usize;
3029                                warn_limit_exhausted(TrackedLimit::V8CpuTimeMs, capacity, capacity);
3030                            }
3031                            RuntimeEvent::ExecutionResult {
3032                                session_id,
3033                                exit_code: 1,
3034                                exports: None,
3035                                error: Some(ExecutionErrorBin {
3036                                    error_type: "Error".into(),
3037                                    message: "Script execution exceeded the CPU-time budget \
3038                                         (limits.jsRuntime.cpuTimeLimitMs)"
3039                                        .into(),
3040                                    stack: String::new(),
3041                                    code: "ERR_SCRIPT_CPU_BUDGET_EXCEEDED".into(),
3042                                }),
3043                            }
3044                        } else if wall_clock_timed_out {
3045                            if let Some(limit_ms) = wall_clock_limit_ms {
3046                                let capacity = limit_ms as usize;
3047                                warn_limit_exhausted(
3048                                    TrackedLimit::V8WallClockMs,
3049                                    capacity,
3050                                    capacity,
3051                                );
3052                            }
3053                            RuntimeEvent::ExecutionResult {
3054                                session_id,
3055                                exit_code: 1,
3056                                exports: None,
3057                                error: Some(ExecutionErrorBin {
3058                                    error_type: "Error".into(),
3059                                    message: "Script execution exceeded the wall-clock limit \
3060                                         (limits.jsRuntime.wallClockLimitMs)"
3061                                        .into(),
3062                                    stack: String::new(),
3063                                    code: "ERR_SCRIPT_WALL_CLOCK_EXCEEDED".into(),
3064                                }),
3065                            }
3066                        } else if terminated {
3067                            RuntimeEvent::ExecutionResult {
3068                                session_id,
3069                                exit_code: 1,
3070                                exports: None,
3071                                error: Some(ExecutionErrorBin {
3072                                    error_type: "Error".into(),
3073                                    message: "Execution terminated".into(),
3074                                    stack: String::new(),
3075                                    code: String::new(),
3076                                }),
3077                            }
3078                        } else {
3079                            RuntimeEvent::ExecutionResult {
3080                                session_id,
3081                                exit_code: code,
3082                                exports,
3083                                error: error.map(|e| ExecutionErrorBin {
3084                                    error_type: e.error_type,
3085                                    message: e.message,
3086                                    stack: e.stack,
3087                                    code: e.code.unwrap_or_default(),
3088                                }),
3089                            }
3090                        };
3091
3092                        execution::clear_pending_module_evaluation();
3093                        execution::clear_pending_script_evaluation();
3094                        execution::clear_module_state();
3095
3096                        send_event_with_generation(&event_tx, output_generation, result_frame);
3097                    }
3098                    #[cfg(test)]
3099                    {
3100                        let _ = (mode, file_path, bridge_code, post_restore_script, user_code);
3101                    }
3102                }
3103                SessionMessage::BridgeResponse(_)
3104                | SessionMessage::StreamEvent(_)
3105                | SessionMessage::TerminateExecution => {
3106                    handle_late_session_message(&event_tx, &session_id, output_generation, msg);
3107                }
3108            },
3109        }
3110    }
3111
3112    // Drop V8 resources (only present in non-test mode)
3113    #[cfg(not(test))]
3114    {
3115        *isolate_handle
3116            .lock()
3117            .expect("session isolate handle lock poisoned") = None;
3118        // Reset pending promise-resolver Globals BEFORE the isolate is dropped on
3119        // thread teardown. run_event_loop can exit early (Shutdown / timeout-abort)
3120        // with resolvers still registered, so without this the Globals would drop
3121        // after their isolate — leaking across session create/destroy churn and
3122        // violating the V8 lifetime contract.
3123        reset_pending_promises(&mut pending);
3124        drop(_v8_context.take());
3125        isolate::drop_isolate(v8_isolate.take());
3126    }
3127
3128    // `_slot_permit` releases only after all thread-affine V8 state above has
3129    // been destroyed. A detached generation cannot leak its permit early.
3130}
3131
3132/// Sync bridge functions block V8 while the host processes the call
3133/// (applySync/applySyncPromise). Async bridge functions return a Promise to V8.
3134struct BridgeFnPartitions {
3135    sync: Vec<&'static str>,
3136    async_fns: Vec<&'static str>,
3137}
3138
3139pub(crate) fn sync_bridge_fns() -> &'static [&'static str] {
3140    &bridge_fn_partitions().sync
3141}
3142
3143pub(crate) fn async_bridge_fns() -> &'static [&'static str] {
3144    &bridge_fn_partitions().async_fns
3145}
3146
3147fn bridge_fn_partitions() -> &'static BridgeFnPartitions {
3148    static PARTITIONS: OnceLock<BridgeFnPartitions> = OnceLock::new();
3149    PARTITIONS.get_or_init(|| BridgeFnPartitions {
3150        sync: bridge_fns_for(|convention| {
3151            matches!(
3152                convention,
3153                BridgeCallConvention::Sync | BridgeCallConvention::SyncPromise
3154            )
3155        }),
3156        async_fns: bridge_fns_for(|convention| convention == BridgeCallConvention::Async),
3157    })
3158}
3159
3160fn bridge_fns_for(filter: impl Fn(BridgeCallConvention) -> bool) -> Vec<&'static str> {
3161    bridge_contract()
3162        .groups
3163        .iter()
3164        .filter(|group| filter(group.convention))
3165        .flat_map(|group| group.names.iter().map(String::as_str))
3166        .collect()
3167}
3168
3169/// Reset every pending promise-resolver `v8::Global` handle held by `pending`.
3170///
3171/// `v8::Global` handles MUST be reset/dropped *before* the `v8::Isolate` that
3172/// created them is torn down. The session reuses a single `PendingPromises`
3173/// registry across executions and across isolate rebuilds, and `run_event_loop`
3174/// can exit early (Shutdown at the `SessionCommand::Shutdown` arm, or
3175/// timeout-abort via the `abort_rx` branch) while resolvers are still
3176/// registered. On those paths the registry can outlive an isolate. Call this
3177/// immediately before every isolate drop (rebuild and thread teardown) so the
3178/// `Global<PromiseResolver>` handles are dropped while their isolate is still
3179/// alive — preventing both a leak across session create/destroy churn (bounded
3180/// by `MAX_PENDING_PROMISES`) and a V8 lifetime-contract violation.
3181#[doc(hidden)]
3182pub fn reset_pending_promises(pending: &mut crate::bridge::PendingPromises) {
3183    // Swap in an empty registry and drop the populated one in place. Dropping a
3184    // `PendingPromises` resets all of its `Global<PromiseResolver>` handles.
3185    drop(std::mem::take(pending));
3186}
3187
3188/// Run the session event loop: dispatch incoming messages to V8.
3189///
3190/// Called after script/module execution when there are pending async promises.
3191/// Polls the ordinary session channel for events/control and the dedicated
3192/// async bridge-response lane, dispatching bounded work into V8.
3193///
3194/// When `abort_rx` is provided (timeout is configured), uses `select!` to
3195/// also monitor the abort channel — if the timeout fires and drops the sender,
3196/// the abort channel unblocks and terminates execution.
3197///
3198/// Returns true if execution completed normally, false if terminated.
3199#[doc(hidden)]
3200pub fn run_event_loop(
3201    scope: &mut v8::HandleScope,
3202    rx: &Receiver<SessionCommand>,
3203    pending: &crate::bridge::PendingPromises,
3204    abort_rx: Option<&crossbeam_channel::Receiver<()>>,
3205    bridge_rx: Option<&crossbeam_channel::Receiver<BridgeResponse>>,
3206    pause_control: Option<&SessionPauseControl>,
3207) -> EventLoopStatus {
3208    let (ready_broker, ready_rx) = match SessionReadiness::disabled(1) {
3209        Ok(readiness) => readiness,
3210        Err(error) => {
3211            eprintln!("{error}");
3212            return EventLoopStatus::Terminated;
3213        }
3214    };
3215    run_event_loop_with_readiness(
3216        scope,
3217        EventLoopSources {
3218            commands: rx,
3219            readiness: &ready_broker,
3220            readiness_wakes: &ready_rx,
3221            bridge_responses: bridge_rx,
3222            abort: abort_rx,
3223            pause: pause_control,
3224        },
3225        pending,
3226    )
3227}
3228
3229struct EventLoopSources<'a> {
3230    commands: &'a Receiver<SessionCommand>,
3231    readiness: &'a SessionReadiness,
3232    readiness_wakes: &'a Receiver<ReadyWake>,
3233    bridge_responses: Option<&'a Receiver<BridgeResponse>>,
3234    abort: Option<&'a Receiver<()>>,
3235    pause: Option<&'a SessionPauseControl>,
3236}
3237
3238fn run_event_loop_with_readiness(
3239    scope: &mut v8::HandleScope,
3240    sources: EventLoopSources<'_>,
3241    pending: &crate::bridge::PendingPromises,
3242) -> EventLoopStatus {
3243    let EventLoopSources {
3244        commands: rx,
3245        readiness: ready_broker,
3246        readiness_wakes: ready_rx,
3247        bridge_responses: bridge_rx,
3248        abort: abort_rx,
3249        pause: pause_control,
3250    } = sources;
3251    let mut bridge_lane_open = bridge_rx.is_some();
3252    loop {
3253        // An out-of-band isolate termination may arrive between event-loop
3254        // passes. Check the durable abort lane before any V8 API call; querying
3255        // promises or timers on an already-terminated isolate can otherwise
3256        // spin forever and prevent explicit session destruction from joining.
3257        if abort_rx.is_some_and(execution_abort_requested) {
3258            scope.terminate_execution();
3259            return EventLoopStatus::Terminated;
3260        }
3261        if pending.is_empty()
3262            && !execution::pending_module_evaluation_needs_wait(scope)
3263            && !execution::pending_script_evaluation_needs_wait(scope)
3264            && pending_guest_timer_count(scope) == 0
3265            && pending_guest_immediate_count(scope) == 0
3266        {
3267            break;
3268        }
3269        if let Some(control) = pause_control {
3270            control.wait_while_paused();
3271        }
3272        pump_v8_message_loop(scope);
3273
3274        // Bound completion work per turn so a response flood cannot starve
3275        // ordinary stream/control events.
3276        if bridge_lane_open {
3277            let responses = bridge_rx.expect("open bridge lane must have a receiver");
3278            for _ in 0..64 {
3279                let response = match responses.try_recv() {
3280                    Ok(response) => response,
3281                    Err(crossbeam_channel::TryRecvError::Empty) => break,
3282                    Err(crossbeam_channel::TryRecvError::Disconnected) => {
3283                        bridge_lane_open = false;
3284                        break;
3285                    }
3286                };
3287                let status = dispatch_event_loop_frame(
3288                    scope,
3289                    SessionMessage::BridgeResponse(response),
3290                    pending,
3291                );
3292                if !matches!(status, EventLoopStatus::Completed) {
3293                    return status;
3294                }
3295            }
3296        }
3297
3298        // Drain one JavaScript turn before blocking. A V8 microtask checkpoint
3299        // already drains recursively queued Promise continuations; the platform
3300        // pump likewise drains every currently runnable foreground task and
3301        // checkpoints after each one. Repeating both operations 100 times added
3302        // a fixed empty-work floor to every readiness and bridge completion.
3303        scope.perform_microtask_checkpoint();
3304        pump_v8_message_loop(scope);
3305
3306        if pending_guest_immediate_count(scope) > 0 {
3307            match try_recv_session_command(scope, rx, ready_rx, ready_broker, bridge_rx, abort_rx) {
3308                Ok(Some(cmd)) => {
3309                    let status = dispatch_session_command(scope, cmd, pending, ready_broker);
3310                    if !matches!(status, EventLoopStatus::Completed) {
3311                        return status;
3312                    }
3313                }
3314                Ok(None) => {
3315                    let status = drain_guest_immediates(scope);
3316                    if !matches!(status, EventLoopStatus::Completed) {
3317                        return status;
3318                    }
3319                }
3320                Err(status) => return status,
3321            }
3322            scope.perform_microtask_checkpoint();
3323            pump_v8_message_loop(scope);
3324        }
3325
3326        // Re-check exit conditions after microtask flush — the microtask may
3327        // have resolved all pending promises or registered new handles.
3328        if pending.is_empty()
3329            && !execution::pending_module_evaluation_needs_wait(scope)
3330            && !execution::pending_script_evaluation_needs_wait(scope)
3331            && pending_guest_timer_count(scope) == 0
3332            && pending_guest_immediate_count(scope) == 0
3333        {
3334            break;
3335        }
3336
3337        // Receive next command with interleaved microtask processing.
3338        // Instead of blocking indefinitely, use a short timeout so we can
3339        // periodically flush microtasks (like Node.js's libuv + DrainTasks pattern).
3340        let cmd = loop {
3341            if pending_guest_immediate_count(scope) > 0 {
3342                match try_recv_session_command(
3343                    scope,
3344                    rx,
3345                    ready_rx,
3346                    ready_broker,
3347                    bridge_rx,
3348                    abort_rx,
3349                ) {
3350                    Ok(Some(cmd)) => break cmd,
3351                    Ok(None) => {
3352                        let status = drain_guest_immediates(scope);
3353                        if !matches!(status, EventLoopStatus::Completed) {
3354                            return status;
3355                        }
3356                        scope.perform_microtask_checkpoint();
3357                        pump_v8_message_loop(scope);
3358                        continue;
3359                    }
3360                    Err(status) => return status,
3361                }
3362            }
3363            if bridge_lane_open {
3364                let responses = bridge_rx.expect("open bridge lane must have a receiver");
3365                match responses.try_recv() {
3366                    Ok(response) => {
3367                        break SessionCommand::Message(SessionMessage::BridgeResponse(response));
3368                    }
3369                    Err(crossbeam_channel::TryRecvError::Empty) => {}
3370                    Err(crossbeam_channel::TryRecvError::Disconnected) => {
3371                        bridge_lane_open = false;
3372                    }
3373                }
3374            }
3375            // Preserve admission order between ordinary commands and readiness.
3376            // Level-triggered socket readiness can immediately rearm after every
3377            // batch; consuming that wake first on every pass starves stdin and
3378            // control commands until an idle keep-alive socket expires.
3379            match rx.try_recv() {
3380                Ok(command) => break command,
3381                Err(crossbeam_channel::TryRecvError::Empty) => {}
3382                Err(crossbeam_channel::TryRecvError::Disconnected) => {
3383                    return EventLoopStatus::Completed;
3384                }
3385            }
3386            if let Ok(wake) = ready_rx.try_recv() {
3387                match ready_batch_command(ready_broker, wake) {
3388                    Ok(command) => break command,
3389                    Err(error) => {
3390                        eprintln!("{error}");
3391                        continue;
3392                    }
3393                }
3394            }
3395            // All externally driven work must be registered with the blocking
3396            // selector. The 1 ms timeout exists only to pump V8 platform work;
3397            // it must not become the delivery cadence for direct bridge
3398            // responses, readiness, ordinary commands, or abort.
3399            let mut selector = Select::new();
3400            let ordinary_index = selector.recv(rx);
3401            let ready_index = selector.recv(ready_rx);
3402            let bridge_selection = if bridge_lane_open {
3403                bridge_rx.map(|responses| (selector.recv(responses), responses))
3404            } else {
3405                None
3406            };
3407            let abort_selection = abort_rx.map(|abort| (selector.recv(abort), abort));
3408            let recv_result = match selector.select_timeout(Duration::from_millis(1)) {
3409                Ok(operation) => {
3410                    let index = operation.index();
3411                    if index == ordinary_index {
3412                        operation.recv(rx).ok()
3413                    } else if index == ready_index {
3414                        match operation.recv(ready_rx) {
3415                            Ok(wake) => match ready_batch_command(ready_broker, wake) {
3416                                Ok(command) => Some(command),
3417                                Err(error) => {
3418                                    eprintln!("{error}");
3419                                    None
3420                                }
3421                            },
3422                            Err(_) => None,
3423                        }
3424                    } else if let Some((bridge_index, responses)) = bridge_selection {
3425                        if index == bridge_index {
3426                            match operation.recv(responses) {
3427                                Ok(response) => Some(SessionCommand::Message(
3428                                    SessionMessage::BridgeResponse(response),
3429                                )),
3430                                Err(_) => {
3431                                    bridge_lane_open = false;
3432                                    None
3433                                }
3434                            }
3435                        } else if let Some((abort_index, abort)) = abort_selection {
3436                            debug_assert_eq!(index, abort_index);
3437                            let _ = operation.recv(abort);
3438                            scope.terminate_execution();
3439                            return EventLoopStatus::Terminated;
3440                        } else {
3441                            unreachable!("event-loop selector returned an unknown operation")
3442                        }
3443                    } else if let Some((abort_index, abort)) = abort_selection {
3444                        debug_assert_eq!(index, abort_index);
3445                        let _ = operation.recv(abort);
3446                        scope.terminate_execution();
3447                        return EventLoopStatus::Terminated;
3448                    } else {
3449                        unreachable!("event-loop selector returned an unknown operation")
3450                    }
3451                }
3452                Err(_) => None,
3453            };
3454            if let Some(cmd) = recv_result {
3455                break cmd;
3456            }
3457            if let Some(control) = pause_control {
3458                control.wait_while_paused();
3459            }
3460            // No command received — flush microtasks and re-check direct
3461            // response and exit conditions.
3462            scope.perform_microtask_checkpoint();
3463            pump_v8_message_loop(scope);
3464            // Check if we should exit
3465            if pending.is_empty()
3466                && !execution::pending_module_evaluation_needs_wait(scope)
3467                && !execution::pending_script_evaluation_needs_wait(scope)
3468                && pending_guest_timer_count(scope) == 0
3469                && pending_guest_immediate_count(scope) == 0
3470            {
3471                return EventLoopStatus::Completed;
3472            }
3473        };
3474
3475        let status = dispatch_session_command(scope, cmd, pending, ready_broker);
3476        if !matches!(status, EventLoopStatus::Completed) {
3477            return status;
3478        }
3479    }
3480    EventLoopStatus::Completed
3481}
3482
3483fn execution_abort_requested(abort: &crossbeam_channel::Receiver<()>) -> bool {
3484    !matches!(
3485        abort.try_recv(),
3486        Err(crossbeam_channel::TryRecvError::Empty)
3487    )
3488}
3489
3490fn try_recv_session_command(
3491    scope: &mut v8::HandleScope,
3492    rx: &Receiver<SessionCommand>,
3493    ready_rx: &Receiver<ReadyWake>,
3494    ready_broker: &SessionReadiness,
3495    bridge_rx: Option<&crossbeam_channel::Receiver<BridgeResponse>>,
3496    abort_rx: Option<&crossbeam_channel::Receiver<()>>,
3497) -> Result<Option<SessionCommand>, EventLoopStatus> {
3498    if let Some(responses) = bridge_rx {
3499        match responses.try_recv() {
3500            Ok(response) => {
3501                return Ok(Some(SessionCommand::Message(
3502                    SessionMessage::BridgeResponse(response),
3503                )));
3504            }
3505            Err(crossbeam_channel::TryRecvError::Empty) => {}
3506            Err(crossbeam_channel::TryRecvError::Disconnected) => {}
3507        }
3508    }
3509    if let Some(abort) = abort_rx {
3510        match abort.try_recv() {
3511            Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected) => {
3512                scope.terminate_execution();
3513                return Err(EventLoopStatus::Terminated);
3514            }
3515            Err(crossbeam_channel::TryRecvError::Empty) => {}
3516        }
3517    }
3518    match rx.try_recv() {
3519        Ok(command) => return Ok(Some(command)),
3520        Err(crossbeam_channel::TryRecvError::Empty) => {}
3521        Err(crossbeam_channel::TryRecvError::Disconnected) => return Ok(None),
3522    }
3523    match ready_rx.try_recv() {
3524        Ok(wake) => match ready_batch_command(ready_broker, wake) {
3525            Ok(command) => return Ok(Some(command)),
3526            Err(error) => {
3527                eprintln!("{error}");
3528            }
3529        },
3530        Err(crossbeam_channel::TryRecvError::Empty) => {}
3531        Err(crossbeam_channel::TryRecvError::Disconnected) => {}
3532    }
3533    if let Some(abort) = abort_rx {
3534        crossbeam_channel::select! {
3535            recv(abort) -> _ => {
3536                scope.terminate_execution();
3537                Err(EventLoopStatus::Terminated)
3538            },
3539            recv(rx) -> result => Ok(result.ok()),
3540            default => Ok(None),
3541        }
3542    } else {
3543        match rx.try_recv() {
3544            Ok(cmd) => Ok(Some(cmd)),
3545            Err(crossbeam_channel::TryRecvError::Empty) => Ok(None),
3546            Err(crossbeam_channel::TryRecvError::Disconnected) => Ok(None),
3547        }
3548    }
3549}
3550
3551fn dispatch_session_command(
3552    scope: &mut v8::HandleScope,
3553    cmd: SessionCommand,
3554    pending: &crate::bridge::PendingPromises,
3555    ready_broker: &SessionReadiness,
3556) -> EventLoopStatus {
3557    match cmd {
3558        SessionCommand::Message(frame) => dispatch_event_loop_frame(scope, frame, pending),
3559        SessionCommand::ReadyBatch(batch) => dispatch_ready_batch(scope, batch, ready_broker),
3560        SessionCommand::SetModuleReader(reader) => {
3561            execution::install_session_guest_reader(Some(reader));
3562            EventLoopStatus::Completed
3563        }
3564        SessionCommand::Shutdown => EventLoopStatus::Terminated,
3565    }
3566}
3567
3568/// Dispatch one bounded readiness turn, then complete its wake on every exit.
3569///
3570/// Callback exceptions are execution failures, but they must not strand the
3571/// session broker in `WakeState::Outstanding`. Keeping dispatch in an inner
3572/// function gives this wrapper finally-style completion semantics: every inner
3573/// return reaches `complete_ready_batch_dispatch` before control returns to the
3574/// reusable session loop.
3575fn dispatch_ready_batch(
3576    scope: &mut v8::HandleScope,
3577    batch: RuntimeReadyBatch,
3578    ready_broker: &SessionReadiness,
3579) -> EventLoopStatus {
3580    let mut delivered = Vec::with_capacity(batch.entries.len());
3581    let dispatch_status =
3582        dispatch_ready_batch_callbacks(scope, &batch, ready_broker, &mut delivered);
3583    complete_ready_batch_dispatch(ready_broker, &batch, &delivered, dispatch_status)
3584}
3585
3586fn dispatch_ready_batch_callbacks(
3587    scope: &mut v8::HandleScope,
3588    batch: &RuntimeReadyBatch,
3589    ready_broker: &SessionReadiness,
3590    delivered: &mut Vec<ReadyObservation>,
3591) -> EventLoopStatus {
3592    for entry in &batch.entries {
3593        let tc = &mut v8::TryCatch::new(scope);
3594        let dispatch = crate::stream::dispatch_readiness(
3595            tc,
3596            entry.capability_id,
3597            entry.capability_generation,
3598            entry.flags,
3599        );
3600        tc.perform_microtask_checkpoint();
3601        if let Some(exception) = tc.exception() {
3602            let (code, error) = execution::exception_to_result(tc, exception);
3603            return EventLoopStatus::Failed(code, error);
3604        }
3605        match dispatch {
3606            crate::stream::ReadinessDispatch::Delivered => delivered.push(*entry),
3607            crate::stream::ReadinessDispatch::TargetMissing => {
3608                // The bridge exists but this capability may not be registered
3609                // yet (for example, readiness raced the connect response).
3610                // Leave the observation unacknowledged so the durable
3611                // sidecar state schedules another coalesced wake.
3612            }
3613            crate::stream::ReadinessDispatch::BridgeMissing => {
3614                return EventLoopStatus::Failed(
3615                            1,
3616                            ExecutionError {
3617                                error_type: String::from("Error"),
3618                                message: String::from(
3619                                    "ERR_AGENTOS_READY_DISPATCH_MISSING: guest bridge does not expose _agentOSReadyDispatch",
3620                                ),
3621                                stack: String::new(),
3622                                code: Some(String::from("ERR_AGENTOS_READY_DISPATCH_MISSING")),
3623                            },
3624                        );
3625            }
3626        }
3627    }
3628    if batch.signals_ready {
3629        let signals = match ready_broker.drain_signals(batch) {
3630            Ok(signals) => signals,
3631            Err(error) => return readiness_dispatch_failure(error),
3632        };
3633        for signal in signals {
3634            let Some(signal_name) = signal_name_for_stream_event(signal) else {
3635                continue;
3636            };
3637            let tc = &mut v8::TryCatch::new(scope);
3638            crate::stream::dispatch_signal_event(tc, signal_name, signal);
3639            tc.perform_microtask_checkpoint();
3640            if let Some(exception) = tc.exception() {
3641                let (code, error) = execution::exception_to_result(tc, exception);
3642                return EventLoopStatus::Failed(code, error);
3643            }
3644            if let Some(error) = execution::take_unhandled_promise_rejection(tc) {
3645                return EventLoopStatus::Failed(1, error);
3646            }
3647        }
3648    }
3649    if batch.timers_ready {
3650        let timers = match ready_broker.drain_timers(batch) {
3651            Ok(timers) => timers,
3652            Err(error) => return readiness_dispatch_failure(error),
3653        };
3654        for timer_id in timers {
3655            let tc = &mut v8::TryCatch::new(scope);
3656            crate::stream::dispatch_timer_event(tc, timer_id);
3657            tc.perform_microtask_checkpoint();
3658            if let Some(exception) = tc.exception() {
3659                let (code, error) = execution::exception_to_result(tc, exception);
3660                return EventLoopStatus::Failed(code, error);
3661            }
3662            if let Some(error) = execution::take_unhandled_promise_rejection(tc) {
3663                return EventLoopStatus::Failed(1, error);
3664            }
3665        }
3666    }
3667    EventLoopStatus::Completed
3668}
3669
3670fn complete_ready_batch_dispatch(
3671    ready_broker: &SessionReadiness,
3672    batch: &RuntimeReadyBatch,
3673    delivered: &[ReadyObservation],
3674    dispatch_status: EventLoopStatus,
3675) -> EventLoopStatus {
3676    if let Err(error) = ready_broker.complete_batch(batch, delivered) {
3677        if matches!(&dispatch_status, EventLoopStatus::Completed) {
3678            return readiness_dispatch_failure(error);
3679        }
3680        eprintln!(
3681            "ERR_AGENTOS_READY_COMPLETE_AFTER_DISPATCH_FAILURE: could not complete readiness wake after guest dispatch failed: {error}"
3682        );
3683    }
3684    dispatch_status
3685}
3686
3687fn readiness_dispatch_failure(message: String) -> EventLoopStatus {
3688    EventLoopStatus::Failed(
3689        1,
3690        ExecutionError {
3691            error_type: String::from("Error"),
3692            message,
3693            stack: String::new(),
3694            code: Some(String::from("ERR_AGENTOS_READY_COMPLETE")),
3695        },
3696    )
3697}
3698
3699fn signal_name_for_stream_event(signal: i32) -> Option<&'static str> {
3700    match signal {
3701        1 => Some("SIGHUP"),
3702        2 => Some("SIGINT"),
3703        10 => Some("SIGUSR1"),
3704        14 => Some("SIGALRM"),
3705        18 => Some("SIGCONT"),
3706        15 => Some("SIGTERM"),
3707        17 => Some("SIGCHLD"),
3708        28 => Some("SIGWINCH"),
3709        _ => None,
3710    }
3711}
3712
3713fn pending_guest_timer_count(scope: &mut v8::HandleScope) -> usize {
3714    let tc = &mut v8::TryCatch::new(scope);
3715    let context = tc.get_current_context();
3716    let global = context.global(tc);
3717    let key = match v8::String::new(tc, "_getPendingTimerCount") {
3718        Some(key) => key,
3719        None => return 0,
3720    };
3721    let Some(func_value) = global.get(tc, key.into()) else {
3722        return 0;
3723    };
3724    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
3725        return 0;
3726    };
3727    let Some(result) = func.call(tc, global.into(), &[]) else {
3728        return 0;
3729    };
3730
3731    result
3732        .integer_value(tc)
3733        .and_then(|count| usize::try_from(count).ok())
3734        .unwrap_or(0)
3735}
3736
3737fn pending_guest_immediate_count(scope: &mut v8::HandleScope) -> usize {
3738    let tc = &mut v8::TryCatch::new(scope);
3739    let context = tc.get_current_context();
3740    let global = context.global(tc);
3741    let key = match v8::String::new(tc, "_getPendingImmediateCount") {
3742        Some(key) => key,
3743        None => return 0,
3744    };
3745    let Some(func_value) = global.get(tc, key.into()) else {
3746        return 0;
3747    };
3748    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
3749        return 0;
3750    };
3751    let Some(result) = func.call(tc, global.into(), &[]) else {
3752        return 0;
3753    };
3754
3755    result
3756        .integer_value(tc)
3757        .and_then(|count| usize::try_from(count).ok())
3758        .unwrap_or(0)
3759}
3760
3761fn drain_guest_immediates(scope: &mut v8::HandleScope) -> EventLoopStatus {
3762    let tc = &mut v8::TryCatch::new(scope);
3763    let context = tc.get_current_context();
3764    let global = context.global(tc);
3765    let key = match v8::String::new(tc, "_drainImmediates") {
3766        Some(key) => key,
3767        None => return EventLoopStatus::Completed,
3768    };
3769    let Some(func_value) = global.get(tc, key.into()) else {
3770        return EventLoopStatus::Completed;
3771    };
3772    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
3773        return EventLoopStatus::Completed;
3774    };
3775    let _ = func.call(tc, global.into(), &[]);
3776    tc.perform_microtask_checkpoint();
3777    if let Some(exception) = tc.exception() {
3778        let (code, err) = execution::exception_to_result(tc, exception);
3779        return EventLoopStatus::Failed(code, err);
3780    }
3781    if let Some(err) = execution::take_unhandled_promise_rejection(tc) {
3782        return EventLoopStatus::Failed(1, err);
3783    }
3784    EventLoopStatus::Completed
3785}
3786
3787fn pump_v8_message_loop(scope: &mut v8::HandleScope) {
3788    let platform = v8::V8::get_current_platform();
3789    while v8::Platform::pump_message_loop(&platform, scope, false) {
3790        scope.perform_microtask_checkpoint();
3791    }
3792}
3793
3794/// Dispatch a single session message within the event loop.
3795/// Returns the event-loop status after handling the frame.
3796#[derive(Debug)]
3797#[doc(hidden)]
3798pub enum EventLoopStatus {
3799    Completed,
3800    Terminated,
3801    Failed(i32, ExecutionError),
3802}
3803
3804fn dispatch_event_loop_frame(
3805    scope: &mut v8::HandleScope,
3806    frame: SessionMessage,
3807    pending: &crate::bridge::PendingPromises,
3808) -> EventLoopStatus {
3809    match frame {
3810        SessionMessage::BridgeResponse(BridgeResponse {
3811            call_id,
3812            status,
3813            payload,
3814            reservation: _reservation,
3815        }) => {
3816            let (result, error) = if status == 1 {
3817                (None, Some(String::from_utf8_lossy(&payload).to_string()))
3818            } else if status == 2 || !payload.is_empty() {
3819                // status=0: V8-serialized, status=2: raw binary (Uint8Array)
3820                (Some(payload), None)
3821            } else {
3822                (None, None)
3823            };
3824            let _ = crate::bridge::resolve_pending_promise(
3825                scope, pending, call_id, status, result, error,
3826            );
3827            // Microtasks already flushed in resolve_pending_promise
3828            EventLoopStatus::Completed
3829        }
3830        SessionMessage::StreamEvent(StreamEvent {
3831            event_type,
3832            payload,
3833        }) => {
3834            let tc = &mut v8::TryCatch::new(scope);
3835            crate::stream::dispatch_stream_event(tc, &event_type, &payload);
3836            tc.perform_microtask_checkpoint();
3837            if let Some(exception) = tc.exception() {
3838                let (code, err) = execution::exception_to_result(tc, exception);
3839                return EventLoopStatus::Failed(code, err);
3840            }
3841            if let Some(err) = execution::take_unhandled_promise_rejection(tc) {
3842                return EventLoopStatus::Failed(1, err);
3843            }
3844            EventLoopStatus::Completed
3845        }
3846        SessionMessage::TerminateExecution => {
3847            scope.terminate_execution();
3848            EventLoopStatus::Terminated
3849        }
3850        _ => {
3851            // Ignore other messages during event loop
3852            EventLoopStatus::Completed
3853        }
3854    }
3855}
3856
3857#[cfg(test)]
3858mod tests {
3859    use super::*;
3860    use std::collections::HashSet;
3861
3862    const TEST_READY_BATCH_HANDLES: usize = 64;
3863
3864    /// Helper to create a SessionManager for tests
3865    fn test_manager(max: usize) -> SessionManager {
3866        test_manager_with_events(max).0
3867    }
3868
3869    fn test_manager_with_events(max: usize) -> (SessionManager, Receiver<RuntimeEventEnvelope>) {
3870        let (tx, _rx) = crossbeam_channel::unbounded();
3871        let router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
3872        let snap_cache = Arc::new(SnapshotCache::new(4));
3873        let runtime =
3874            agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default())
3875                .expect("create test process runtime")
3876                .context();
3877        let manager = SessionManager::new(max, tx, router, snap_cache, runtime);
3878        (manager, _rx)
3879    }
3880
3881    #[test]
3882    fn zero_cpu_time_limit_is_normalized_to_no_timeout() {
3883        assert_eq!(normalize_cpu_time_limit_ms(None), None);
3884        assert_eq!(normalize_cpu_time_limit_ms(Some(0)), None);
3885        assert_eq!(normalize_cpu_time_limit_ms(Some(1)), Some(1));
3886    }
3887
3888    #[test]
3889    fn vm_executor_permits_report_active_and_high_water_metrics() {
3890        let control: SlotControl = Arc::new((Mutex::new(0), Condvar::new()));
3891        let metrics = RuntimeMetrics::new();
3892
3893        let first = SessionSlotPermit::try_acquire(&control, 2, metrics.clone())
3894            .expect("acquire first VM executor");
3895        let second = SessionSlotPermit::try_acquire(&control, 2, metrics.clone())
3896            .expect("acquire second VM executor");
3897        let active = metrics.snapshot().executors[ExecutorMetricClass::Vm.index()].active;
3898        assert_eq!(active.current, 2);
3899        assert_eq!(active.high_water, 2);
3900
3901        drop(first);
3902        assert_eq!(
3903            metrics.snapshot().executors[ExecutorMetricClass::Vm.index()]
3904                .active
3905                .current,
3906            1
3907        );
3908
3909        drop(second);
3910        let released = metrics.snapshot().executors[ExecutorMetricClass::Vm.index()].active;
3911        assert_eq!(released.current, 0);
3912        assert_eq!(released.high_water, 2);
3913    }
3914
3915    #[test]
3916    fn configured_executor_and_command_bounds_drive_session_manager() {
3917        const SUBPROCESS_ENV: &str = "AGENTOS_V8_CONFIGURED_SESSION_MANAGER_SUBPROCESS";
3918        if std::env::var_os(SUBPROCESS_ENV).is_none() {
3919            let test_name =
3920                "session::tests::configured_executor_and_command_bounds_drive_session_manager";
3921            let output =
3922                std::process::Command::new(std::env::current_exe().expect("current test binary"))
3923                    .arg(test_name)
3924                    .arg("--exact")
3925                    .arg("--nocapture")
3926                    .env(SUBPROCESS_ENV, "1")
3927                    .output()
3928                    .unwrap_or_else(|error| panic!("spawn isolated test {test_name}: {error}"));
3929            assert!(
3930                output.status.success(),
3931                "isolated test {test_name} failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
3932                output.status.code(),
3933                String::from_utf8_lossy(&output.stdout),
3934                String::from_utf8_lossy(&output.stderr),
3935            );
3936            return;
3937        }
3938        let mut config = agentos_runtime::RuntimeConfig {
3939            max_active_vm_executors: 3,
3940            vm_executor_teardown_timeout_ms: 23,
3941            ..agentos_runtime::RuntimeConfig::default()
3942        };
3943        config.resources.max_handle_commands = 7;
3944        let runtime = agentos_runtime::SidecarRuntime::process(&config)
3945            .expect("configured process runtime")
3946            .context();
3947        let (event_tx, _event_rx) = crossbeam_channel::unbounded();
3948        let router: CallIdRouter = Arc::new(BridgeCallRegistry::with_default_limit());
3949        let mut manager = SessionManager::new(
3950            runtime.max_active_vm_executors(),
3951            event_tx,
3952            router,
3953            Arc::new(SnapshotCache::new(1)),
3954            runtime,
3955        );
3956
3957        assert_eq!(manager.max_concurrency, 3);
3958        assert_eq!(manager.executor_teardown_timeout, Duration::from_millis(23));
3959        manager
3960            .create_session("configured-bounds".into(), None, None, None)
3961            .expect("create bounded session");
3962        assert_eq!(manager.sessions["configured-bounds"].command_capacity, 7);
3963        manager
3964            .destroy_session("configured-bounds")
3965            .expect("destroy bounded session");
3966    }
3967
3968    fn expect_late_message_warning(
3969        rx: &Receiver<RuntimeEventEnvelope>,
3970        session_id: &str,
3971        error_code: &str,
3972        detail_fragment: &str,
3973    ) {
3974        let event = rx
3975            .recv_timeout(std::time::Duration::from_millis(200))
3976            .expect("late-message warning");
3977        match event.event {
3978            RuntimeEvent::Log {
3979                session_id: observed_session_id,
3980                channel,
3981                message,
3982            } => {
3983                assert_eq!(observed_session_id, session_id);
3984                assert_eq!(channel, 1, "late warnings should use stderr channel");
3985                assert!(
3986                    message.contains(error_code),
3987                    "warning should contain error code {error_code}, got {message}"
3988                );
3989                assert!(
3990                    message.contains(detail_fragment),
3991                    "warning should mention {detail_fragment}, got {message}"
3992                );
3993            }
3994            other => panic!("expected late-message warning log, got {other:?}"),
3995        }
3996    }
3997
3998    #[test]
3999    fn bridge_contract_function_partitions_cover_contract() {
4000        let contract = bridge_contract();
4001
4002        let expected_sync = contract
4003            .groups
4004            .iter()
4005            .filter(|group| {
4006                matches!(
4007                    group.convention,
4008                    BridgeCallConvention::Sync | BridgeCallConvention::SyncPromise
4009                )
4010            })
4011            .flat_map(|group| group.names.iter().map(String::as_str))
4012            .collect::<HashSet<_>>();
4013        let expected_async = contract
4014            .groups
4015            .iter()
4016            .filter(|group| group.convention == BridgeCallConvention::Async)
4017            .flat_map(|group| group.names.iter().map(String::as_str))
4018            .collect::<HashSet<_>>();
4019
4020        let sync_names = sync_bridge_fns();
4021        let async_names = async_bridge_fns();
4022        let registered_sync = sync_names.iter().copied().collect::<HashSet<_>>();
4023        let registered_async = async_names.iter().copied().collect::<HashSet<_>>();
4024
4025        assert_eq!(
4026            registered_sync, expected_sync,
4027            "sync bridge function partition drifted from crates/bridge/bridge-contract.json"
4028        );
4029        assert_eq!(
4030            registered_async, expected_async,
4031            "async bridge function partition drifted from crates/bridge/bridge-contract.json"
4032        );
4033        assert!(
4034            registered_sync.is_disjoint(&registered_async),
4035            "sync and async bridge function partitions must not overlap"
4036        );
4037    }
4038
4039    #[test]
4040    fn session_management() {
4041        // Consolidated test to avoid V8 inter-test SIGSEGV issues.
4042        // Covers: lifecycle and concurrency queuing.
4043
4044        // --- Part 1: Single session create/destroy ---
4045        {
4046            let mut mgr = test_manager(4);
4047
4048            mgr.create_session("session-aaa".into(), None, None, None)
4049                .expect("create session A");
4050            assert_eq!(mgr.session_count(), 1);
4051
4052            // Wait for thread to acquire slot and create isolate
4053            std::thread::sleep(std::time::Duration::from_millis(200));
4054
4055            // Destroy session A
4056            mgr.destroy_session("session-aaa")
4057                .expect("destroy session A");
4058            assert_eq!(mgr.session_count(), 0);
4059        }
4060
4061        // --- Part 2: Multiple sessions ---
4062        {
4063            let mut mgr = test_manager(4);
4064
4065            mgr.create_session("session-bbb".into(), None, None, None)
4066                .expect("create session B");
4067            mgr.create_session("session-ccc".into(), Some(16), None, None)
4068                .expect("create session C");
4069            assert_eq!(mgr.session_count(), 2);
4070
4071            std::thread::sleep(std::time::Duration::from_millis(200));
4072
4073            // Duplicate session ID is rejected
4074            let err = mgr.create_session("session-bbb".into(), None, None, None);
4075            assert!(err.is_err());
4076            assert!(err.unwrap_err().contains("already exists"));
4077
4078            // Sending to a missing session still fails.
4079            let err = mgr.send_to_session("missing", SessionMessage::TerminateExecution);
4080            assert!(err.is_err());
4081            assert!(err.unwrap_err().contains("does not exist"));
4082
4083            // Destroy non-existent session
4084            let err = mgr.destroy_session("no-such-session");
4085            assert!(err.is_err());
4086            assert!(err.unwrap_err().contains("does not exist"));
4087
4088            mgr.destroy_sessions(["session-bbb".into(), "session-ccc".into()]);
4089            assert_eq!(mgr.session_count(), 0);
4090        }
4091
4092        // --- Part 3: Max concurrency admission before thread creation ---
4093        {
4094            let mut mgr = test_manager(2);
4095
4096            mgr.create_session("s1".into(), None, None, None)
4097                .expect("create s1");
4098            mgr.create_session("s2".into(), None, None, None)
4099                .expect("create s2");
4100            let error = mgr
4101                .create_session("s3".into(), None, None, None)
4102                .expect_err("third executor must be rejected before thread creation");
4103            assert!(error.contains("ERR_AGENTOS_VM_EXECUTOR_LIMIT"));
4104
4105            // Allow threads to acquire slots
4106            std::thread::sleep(std::time::Duration::from_millis(300));
4107
4108            // Only two admitted executor threads exist.
4109            assert_eq!(mgr.active_slot_count(), 2);
4110            assert_eq!(mgr.session_count(), 2);
4111
4112            // Destroy s1, then a new generation can acquire the released slot.
4113            mgr.destroy_session("s1").expect("destroy s1");
4114            mgr.create_session("s3".into(), None, None, None)
4115                .expect("create s3 after release");
4116            std::thread::sleep(std::time::Duration::from_millis(300));
4117            assert_eq!(mgr.active_slot_count(), 2);
4118            assert_eq!(mgr.session_count(), 2);
4119
4120            // Destroy remaining
4121            mgr.destroy_sessions(["s2".into(), "s3".into()]);
4122            std::thread::sleep(std::time::Duration::from_millis(100));
4123            assert_eq!(mgr.session_count(), 0);
4124            assert_eq!(mgr.active_slot_count(), 0);
4125        }
4126    }
4127
4128    #[test]
4129    fn detach_session_clears_call_id_routes_for_session() {
4130        let mut mgr = test_manager(1);
4131        mgr.create_session_with_output_generation(
4132            "session-route".into(),
4133            None,
4134            None,
4135            None,
4136            Some(7),
4137            None,
4138        )
4139        .expect("create session");
4140        let _waiter = mgr
4141            .call_id_router()
4142            .register_sync(&mgr.runtime, 0, 1, 42, "session-route", Some(7))
4143            .expect("register bridge call target");
4144
4145        assert!(
4146            mgr.detach_session_if_output_generation("session-route", 7)
4147                .expect("detach session"),
4148            "matching output generation should detach session"
4149        );
4150        assert!(
4151            mgr.call_id_router().pending_len() == 0,
4152            "detach should clear stale bridge call routes for the session"
4153        );
4154        assert_eq!(
4155            mgr.quarantined.len(),
4156            1,
4157            "detached executor join ownership must remain in the manager"
4158        );
4159        for handle in mgr.take_session_shutdown_handles() {
4160            handle.join().expect("join quarantined executor");
4161        }
4162        assert_eq!(mgr.active_slot_count(), 0);
4163    }
4164
4165    #[test]
4166    fn begin_destroy_session_removes_entry_before_finish() {
4167        let mut mgr = test_manager(1);
4168        mgr.create_session("two-phase".into(), None, None, None)
4169            .expect("create session");
4170
4171        let first_shutdown = mgr
4172            .begin_destroy_session("two-phase")
4173            .expect("begin destroy session");
4174        assert_eq!(
4175            mgr.session_count(),
4176            0,
4177            "entry should be removed before the shutdown is finished"
4178        );
4179
4180        // Removing the registry entry does not release the executor permit.
4181        // Until the old thread joins, a successor generation is quarantined.
4182        let error = mgr
4183            .create_session("two-phase".into(), None, None, None)
4184            .expect_err("old generation must retain its executor permit");
4185        assert!(error.contains("ERR_AGENTOS_VM_EXECUTOR_LIMIT"));
4186        first_shutdown.finish();
4187
4188        mgr.create_session("two-phase".into(), None, None, None)
4189            .expect("re-create session after old generation joins");
4190        let second_shutdown = mgr
4191            .begin_destroy_session("two-phase")
4192            .expect("begin destroy re-created session");
4193        second_shutdown.finish();
4194        assert_eq!(mgr.session_count(), 0);
4195    }
4196
4197    #[test]
4198    fn shutdown_bypasses_a_full_ordinary_command_lane() {
4199        let mut mgr = test_manager(1);
4200        let command_capacity = mgr
4201            .runtime
4202            .resources()
4203            .usage(ResourceClass::HandleCommands)
4204            .limit
4205            .expect("configured command capacity");
4206        let (tx, rx) = crossbeam_channel::bounded(command_capacity);
4207        for index in 0..command_capacity {
4208            tx.send(SessionCommand::Message(SessionMessage::StreamEvent(
4209                StreamEvent {
4210                    event_type: format!("ordinary-{index}"),
4211                    payload: Vec::new(),
4212                },
4213            )))
4214            .expect("fill ordinary command lane");
4215        }
4216        let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded(1);
4217        let join_handle = thread::spawn(move || {
4218            shutdown_rx.recv().expect("dedicated shutdown token");
4219            drop(rx);
4220        });
4221        let (ready_broker, _ready_rx) =
4222            SessionReadiness::new(1, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4223                .expect("create session readiness");
4224        let session_resources = Arc::clone(mgr.runtime.resources());
4225        mgr.sessions.insert(
4226            String::from("full-command-lane"),
4227            SessionEntry {
4228                output_generation: None,
4229                tx,
4230                command_capacity,
4231                shutdown_tx,
4232                join_handle: Some(join_handle),
4233                isolate_handle: Arc::new(Mutex::new(None)),
4234                execution_abort: new_execution_abort(),
4235                pause_control: Arc::new(SessionPauseControl::default()),
4236                ready_broker,
4237                session_resources,
4238            },
4239        );
4240
4241        mgr.begin_destroy_session("full-command-lane")
4242            .expect("begin destroy overloaded session")
4243            .finish();
4244        assert_eq!(mgr.session_count(), 0);
4245    }
4246
4247    #[test]
4248    fn execution_abort_is_durable_when_signaled_before_waiter_arm() {
4249        let execution_abort = new_execution_abort();
4250        signal_execution_abort_durable(&execution_abort, ExecutionAbortReason::Terminated);
4251
4252        let (_guard, receiver) = ActiveExecutionAbort::arm(&execution_abort);
4253        assert_eq!(
4254            receiver.recv_timeout(Duration::from_millis(10)),
4255            Err(crossbeam_channel::RecvTimeoutError::Disconnected),
4256            "a waiter armed after termination must observe it immediately"
4257        );
4258    }
4259
4260    #[test]
4261    fn session_shutdown_finish_clears_late_call_routes() {
4262        let mut mgr = test_manager(1);
4263        mgr.create_session("late-route".into(), None, None, None)
4264            .expect("create session");
4265
4266        let shutdown = mgr
4267            .begin_destroy_session("late-route")
4268            .expect("begin destroy session");
4269        // Simulate a route the session thread registered between the pre-join
4270        // route clear and thread exit.
4271        let _waiter = mgr
4272            .call_id_router()
4273            .register_sync(&mgr.runtime, 0, 1, 42, "late-route", None)
4274            .expect("register late bridge call target");
4275
4276        shutdown.finish();
4277        assert!(
4278            mgr.call_id_router().pending_len() == 0,
4279            "finish should clear call routes registered during shutdown"
4280        );
4281    }
4282
4283    #[test]
4284    fn late_terminate_execution_is_logged_instead_of_silently_dropped() {
4285        let (mut mgr, rx) = test_manager_with_events(1);
4286        mgr.create_session("late-terminate".into(), None, None, None)
4287            .expect("create session");
4288
4289        mgr.send_to_session("late-terminate", SessionMessage::TerminateExecution)
4290            .expect("send late terminate");
4291
4292        expect_late_message_warning(
4293            &rx,
4294            "late-terminate",
4295            LATE_TERMINATE_EXECUTION_ERROR_CODE,
4296            "TerminateExecution",
4297        );
4298
4299        mgr.destroy_session("late-terminate")
4300            .expect("destroy session");
4301    }
4302
4303    #[test]
4304    fn late_stdin_end_is_an_expected_stale_control_event() {
4305        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4306        handle_late_session_message(
4307            &RuntimeEventSender::from(event_tx),
4308            "completed-session",
4309            Some(7),
4310            SessionMessage::StreamEvent(StreamEvent {
4311                event_type: String::from("stdin_end"),
4312                payload: Vec::new(),
4313            }),
4314        );
4315
4316        assert!(
4317            event_rx.try_recv().is_err(),
4318            "an idempotent stdin EOF racing process exit must not become guest stderr"
4319        );
4320    }
4321
4322    #[test]
4323    fn late_bridge_response_is_logged_instead_of_silently_dropped() {
4324        let (mut mgr, rx) = test_manager_with_events(1);
4325        mgr.create_session("late-bridge".into(), None, None, None)
4326            .expect("create session");
4327
4328        mgr.send_to_session(
4329            "late-bridge",
4330            SessionMessage::BridgeResponse(BridgeResponse {
4331                call_id: 41,
4332                status: 0,
4333                payload: vec![0xAA, 0xBB],
4334                reservation: None,
4335            }),
4336        )
4337        .expect("send late bridge response");
4338
4339        expect_late_message_warning(
4340            &rx,
4341            "late-bridge",
4342            LATE_BRIDGE_RESPONSE_ERROR_CODE,
4343            "BridgeResponse",
4344        );
4345
4346        mgr.destroy_session("late-bridge").expect("destroy session");
4347    }
4348
4349    #[test]
4350    fn control_only_readiness_wake_becomes_a_command_and_rearms() {
4351        let mgr = test_manager(1);
4352        let (broker, wake_rx) = SessionReadiness::new(23, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4353            .expect("create session readiness");
4354
4355        broker.publish_timer(91).expect("publish first timer");
4356        let first_wake = wake_rx.try_recv().expect("first timer wake");
4357        let first_batch = match ready_batch_command(&broker, first_wake).expect("timer command") {
4358            SessionCommand::ReadyBatch(batch) => batch,
4359            _ => panic!("timer wake must produce a readiness command"),
4360        };
4361        assert!(first_batch.entries.is_empty());
4362        assert!(first_batch.timers_ready);
4363        assert_eq!(
4364            broker
4365                .drain_timers(&first_batch)
4366                .expect("drain first timer"),
4367            vec![91]
4368        );
4369        broker
4370            .complete_batch(&first_batch, &[])
4371            .expect("complete first timer wake");
4372
4373        broker.publish_timer(92).expect("publish second timer");
4374        let second_wake = wake_rx
4375            .try_recv()
4376            .expect("completing a control-only batch must rearm the wake lane");
4377        let second_batch = match ready_batch_command(&broker, second_wake).expect("second command")
4378        {
4379            SessionCommand::ReadyBatch(batch) => batch,
4380            _ => panic!("second timer wake must produce a readiness command"),
4381        };
4382        assert!(second_batch.entries.is_empty());
4383        assert!(second_batch.timers_ready);
4384    }
4385
4386    #[test]
4387    fn admitted_session_command_precedes_later_signal_wake() {
4388        let mgr = test_manager(1);
4389        let (broker, ready_rx) = SessionReadiness::new(31, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4390            .expect("create session readiness");
4391        let (tx, rx) = crossbeam_channel::bounded(2);
4392        let (_shutdown_tx, shutdown_rx) = crossbeam_channel::bounded(1);
4393
4394        tx.send(SessionCommand::Message(SessionMessage::StreamEvent(
4395            StreamEvent {
4396                event_type: String::from("execute-admitted-first"),
4397                payload: Vec::new(),
4398            },
4399        )))
4400        .expect("queue ordinary session command");
4401        broker.publish_signal(15).expect("publish later SIGTERM");
4402
4403        assert!(matches!(
4404            recv_session_command(&rx, &shutdown_rx, &ready_rx, &broker),
4405            Some(SessionCommand::Message(SessionMessage::StreamEvent(_)))
4406        ));
4407        let Some(SessionCommand::ReadyBatch(batch)) =
4408            recv_session_command(&rx, &shutdown_rx, &ready_rx, &broker)
4409        else {
4410            panic!("later signal wake must remain queued after the admitted command");
4411        };
4412        assert!(batch.signals_ready);
4413        assert_eq!(
4414            broker.drain_signals(&batch).expect("drain signal"),
4415            vec![15]
4416        );
4417        broker
4418            .complete_batch(&batch, &[])
4419            .expect("complete signal wake");
4420    }
4421
4422    #[test]
4423    fn readiness_flood_uses_one_wake_and_carries_only_capability_identity() {
4424        let mgr = test_manager(1);
4425        let (broker, wake_rx) = SessionReadiness::new(7, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4426            .expect("create session readiness");
4427        for _ in 0..1_000_000 {
4428            broker
4429                .publish(41, 3, ReadyFlags::READABLE)
4430                .expect("publish readiness");
4431        }
4432
4433        let wake = wake_rx.try_recv().expect("one coalesced wake");
4434        assert!(
4435            wake_rx.try_recv().is_err(),
4436            "wake lane must have capacity one"
4437        );
4438        let batch = broker.take_batch(wake).expect("take readiness batch");
4439        assert_eq!(batch.entries.len(), 1);
4440        assert_eq!(batch.entries[0].capability_id, 41);
4441        assert_eq!(batch.entries[0].capability_generation, 3);
4442        assert_eq!(batch.entries[0].flags, ReadyFlags::READABLE);
4443        assert_eq!(batch.entries[0].revision, 1_000_000);
4444        broker
4445            .complete_batch(&batch, &batch.entries)
4446            .expect("complete readiness");
4447        assert_eq!(
4448            broker.broker.pending_handle_count().expect("pending count"),
4449            0
4450        );
4451    }
4452
4453    #[test]
4454    fn readiness_batch_honors_vm_reactor_work_quantum_override() {
4455        let mgr = test_manager(1);
4456        let (broker, wake_rx) =
4457            SessionReadiness::new(29, &mgr.runtime, 2).expect("create bounded readiness");
4458        for capability_id in 1..=3 {
4459            broker
4460                .publish(capability_id, 1, ReadyFlags::READABLE)
4461                .expect("publish readiness");
4462        }
4463
4464        let first_wake = wake_rx.recv().expect("first coalesced wake");
4465        let first_batch = broker.take_batch(first_wake).expect("first bounded batch");
4466        assert_eq!(
4467            first_batch.entries.len(),
4468            2,
4469            "limits.reactor.workQuantum must cap one V8 readiness turn"
4470        );
4471        broker
4472            .complete_batch(&first_batch, &first_batch.entries)
4473            .expect("complete first bounded batch");
4474
4475        let second_wake = wake_rx.recv().expect("replacement wake for remaining work");
4476        let second_batch = broker
4477            .take_batch(second_wake)
4478            .expect("second bounded batch");
4479        assert_eq!(second_batch.entries.len(), 1);
4480    }
4481
4482    #[test]
4483    fn readiness_batch_rejects_zero_vm_reactor_work_quantum() {
4484        let mgr = test_manager(1);
4485        let error = SessionReadiness::new(30, &mgr.runtime, 0)
4486            .expect_err("zero work quantum must fail closed");
4487        assert!(error.contains("limits.reactor.workQuantum"));
4488    }
4489
4490    #[test]
4491    fn readiness_rejects_a_stale_capability_generation_without_replacing_state() {
4492        let mgr = test_manager(1);
4493        let (broker, wake_rx) = SessionReadiness::new(13, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4494            .expect("create session readiness");
4495        broker
4496            .publish(9, 4, ReadyFlags::READABLE)
4497            .expect("publish live capability");
4498        let error = broker
4499            .publish(9, 3, ReadyFlags::CLOSE)
4500            .expect_err("stale capability generation must fail");
4501        assert!(error.contains("ERR_AGENTOS_READY_STALE_CAPABILITY"));
4502        assert!(wake_rx.len() <= 1, "wake lane must stay capacity one");
4503        let wake = wake_rx.recv().expect("durable wake");
4504        let batch = broker.take_batch(wake).expect("take readiness batch");
4505        assert_eq!(batch.entries.len(), 1);
4506        assert_eq!(batch.entries[0].capability_generation, 4);
4507        assert_eq!(batch.entries[0].flags, ReadyFlags::READABLE);
4508    }
4509
4510    #[test]
4511    fn readiness_before_guest_registration_remains_pending_until_dispatch_succeeds() {
4512        let mgr = test_manager(1);
4513        let (broker, wake_rx) = SessionReadiness::new(17, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4514            .expect("create session readiness");
4515        broker
4516            .publish(33, 8, ReadyFlags::READABLE)
4517            .expect("publish readiness before guest registration");
4518
4519        let first_wake = wake_rx.recv().expect("initial wake");
4520        let first_batch = broker.take_batch(first_wake).expect("initial batch");
4521        broker
4522            .complete_batch(&first_batch, &[])
4523            .expect("preserve readiness when guest target is absent");
4524        assert_eq!(
4525            broker.broker.pending_handle_count().expect("pending count"),
4526            1
4527        );
4528
4529        let retry_wake = wake_rx.recv().expect("retry wake after registration");
4530        let retry_batch = broker.take_batch(retry_wake).expect("retry batch");
4531        broker
4532            .complete_batch(&retry_batch, &retry_batch.entries)
4533            .expect("acknowledge readiness after target runs");
4534        assert_eq!(
4535            broker.broker.pending_handle_count().expect("pending count"),
4536            0
4537        );
4538    }
4539
4540    #[test]
4541    fn readiness_dispatch_failure_completes_wake_before_session_reuse() {
4542        let mgr = test_manager(1);
4543        let (broker, wake_rx) = SessionReadiness::new(18, &mgr.runtime, TEST_READY_BATCH_HANDLES)
4544            .expect("create reusable session readiness");
4545        broker
4546            .publish(34, 9, ReadyFlags::READABLE)
4547            .expect("publish readiness before failing dispatch");
4548
4549        let first_wake = wake_rx.recv().expect("initial wake");
4550        let first_batch = broker.take_batch(first_wake).expect("initial batch");
4551        let status = complete_ready_batch_dispatch(
4552            &broker,
4553            &first_batch,
4554            &[],
4555            EventLoopStatus::Failed(
4556                1,
4557                ExecutionError {
4558                    error_type: String::from("Error"),
4559                    message: String::from("injected readiness handler failure"),
4560                    stack: String::new(),
4561                    code: Some(String::from("ERR_TEST_READY_DISPATCH")),
4562                },
4563            ),
4564        );
4565        assert!(matches!(status, EventLoopStatus::Failed(1, _)));
4566
4567        let retry_wake = wake_rx
4568            .try_recv()
4569            .expect("failed dispatch must complete and rearm the wake for session reuse");
4570        let retry_batch = broker.take_batch(retry_wake).expect("reused-session batch");
4571        assert_eq!(retry_batch.entries, first_batch.entries);
4572        broker
4573            .complete_batch(&retry_batch, &retry_batch.entries)
4574            .expect("reused session can acknowledge the retried readiness");
4575    }
4576
4577    /// Regression test for the pending-promise-resolver leak / V8 lifetime-contract
4578    /// violation: when `run_event_loop` exits early (Shutdown or timeout-abort) the
4579    /// `PendingPromises` registry can still hold `Global<PromiseResolver>` handles,
4580    /// and the session-thread teardown must reset them *before* dropping the isolate.
4581    ///
4582    /// This drives the real cleanup seam (`reset_pending_promises`) used on every
4583    /// isolate-drop path. It populates the registry with live resolver Globals (as a
4584    /// terminated execution would leave behind), runs the cleanup while the isolate
4585    /// is still alive, and asserts the registry is empty (every Global dropped).
4586    ///
4587    /// Fast + bounded (a handful of resolvers, then the safeguard fires) — it asserts
4588    /// the cleanup happens, it does not saturate `MAX_PENDING_PROMISES`.
4589    #[test]
4590    fn reset_pending_promises_drops_resolver_globals_before_isolate_teardown() {
4591        use crate::bridge::{register_async_bridge_fns, PendingPromises};
4592        use crate::host_call::BridgeCallContext;
4593        use crate::isolate;
4594        use std::process::Command;
4595
4596        // V8 isolates must be created in an isolated process: doing it inline in a
4597        // parallel `cargo test` thread races the process-global V8 platform and
4598        // segfaults. Re-exec this one test as a subprocess (matching the crate's
4599        // bridge_v8_hardening_* / vm_context_registry convention).
4600        const SUBPROCESS_ENV: &str = "AGENTOS_V8_RESET_PENDING_PROMISES_SUBPROCESS";
4601        if std::env::var_os(SUBPROCESS_ENV).is_none() {
4602            let output = Command::new(std::env::current_exe().expect("current test binary"))
4603                .arg("session::tests::reset_pending_promises_drops_resolver_globals_before_isolate_teardown")
4604                .arg("--exact")
4605                .arg("--nocapture")
4606                .env(SUBPROCESS_ENV, "1")
4607                .output()
4608                .expect("spawn reset-pending-promises subprocess");
4609            assert!(
4610                output.status.success(),
4611                "reset-pending-promises subprocess failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
4612                output.status.code(),
4613                String::from_utf8_lossy(&output.stdout),
4614                String::from_utf8_lossy(&output.stderr)
4615            );
4616            return;
4617        }
4618
4619        isolate::init_v8_platform();
4620
4621        let mut v8_isolate = isolate::create_isolate(None);
4622        let context = isolate::create_context(&mut v8_isolate);
4623        let scope = &mut v8::HandleScope::new(&mut v8_isolate);
4624        let context = v8::Local::new(scope, &context);
4625        let scope = &mut v8::ContextScope::new(scope, context);
4626
4627        let bridge_ctx = BridgeCallContext::new(
4628            Box::new(std::io::sink()),
4629            Box::new(std::io::empty()),
4630            String::from("reset-pending-test"),
4631        );
4632        let mut pending = PendingPromises::new();
4633
4634        // Each `_asyncFn(i)` call synchronously registers a pending promise
4635        // resolver Global in `pending` and returns an unresolved Promise —
4636        // exactly what remains registered when the event loop exits early on
4637        // Shutdown / timeout-abort.
4638        const REGISTERED: usize = 8;
4639        let _async_fns = register_async_bridge_fns(
4640            scope,
4641            &bridge_ctx as *const BridgeCallContext,
4642            &pending as *const PendingPromises,
4643            &["_asyncFn"],
4644        );
4645        let source = format!("for (let i = 0; i < {REGISTERED}; i++) {{ _asyncFn(i); }}");
4646        {
4647            let tc = &mut v8::TryCatch::new(scope);
4648            let code = v8::String::new(tc, &source).unwrap();
4649            let script = v8::Script::compile(tc, code, None).unwrap();
4650            assert!(
4651                script.run(tc).is_some(),
4652                "async bridge calls should register resolvers, not throw"
4653            );
4654            assert!(!tc.has_caught(), "async bridge calls should not throw");
4655        }
4656        assert_eq!(
4657            pending.len(),
4658            REGISTERED,
4659            "each _asyncFn call must register a pending resolver Global"
4660        );
4661
4662        // The cleanup invoked on every session-thread isolate-drop path. It must
4663        // empty the registry (resetting every Global<PromiseResolver>) while the
4664        // isolate is still alive.
4665        reset_pending_promises(&mut pending);
4666
4667        assert_eq!(
4668            pending.len(),
4669            0,
4670            "reset_pending_promises must drop all pending resolver Globals before isolate teardown"
4671        );
4672
4673        // Isolate is still alive here: the Globals were reset above, so dropping
4674        // the scope/isolate below honors the V8 lifetime contract.
4675    }
4676}