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