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