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