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, VecDeque};
6use std::sync::atomic::AtomicU64;
7use std::sync::{Arc, Condvar, Mutex, OnceLock};
8use std::thread;
9#[cfg(not(test))]
10use std::time::{Duration, Instant};
11
12#[cfg(not(test))]
13use agentos_bridge::queue_tracker::{warn_limit_exhausted, TrackedLimit};
14use agentos_bridge::{bridge_contract, BridgeCallConvention};
15use crossbeam_channel::{Receiver, Sender};
16
17use crate::execution;
18#[cfg(not(test))]
19use crate::host_call::{BridgeCallContext, ChannelRuntimeEventSender};
20use crate::host_call::{CallIdRouter, SharedCallIdCounter};
21use crate::ipc::ExecutionError;
22#[cfg(not(test))]
23use crate::ipc_binary::ExecutionErrorBin;
24use crate::runtime_protocol::{
25    BridgeResponse, RuntimeEvent, SessionMessage, StreamEvent, WarmSessionHint,
26};
27use crate::snapshot::{snapshot_cache_key, SnapshotCache, SnapshotCacheKey};
28#[cfg(not(test))]
29use crate::{bridge, isolate, snapshot};
30
31/// Commands sent to a session thread
32pub enum SessionCommand {
33    /// Shut down the session and destroy the isolate
34    Shutdown,
35    /// Forward a typed session message to the session thread for processing
36    Message(SessionMessage),
37    /// Install a direct module-source reader on the session thread. Carried as a
38    /// live object over the in-process command channel (NOT a serialized frame),
39    /// so subsequent module loads on this thread read source directly instead of
40    /// round-tripping the bridge. Sent just before an Execute message.
41    SetModuleReader(Box<dyn crate::execution::GuestModuleReader>),
42}
43
44#[cfg(not(test))]
45type SharedIsolateHandle = Arc<Mutex<Option<v8::IsolateHandle>>>;
46#[cfg(test)]
47type SharedIsolateHandle = Arc<Mutex<Option<()>>>;
48
49/// Sender for typed runtime events produced by session threads.
50pub type RuntimeEventSender = crossbeam_channel::Sender<RuntimeEventEnvelope>;
51
52#[derive(Debug, Clone, PartialEq)]
53pub struct RuntimeEventEnvelope {
54    pub output_generation: Option<u64>,
55    pub event: RuntimeEvent,
56}
57
58const LATE_TERMINATE_EXECUTION_ERROR_CODE: &str = "ERR_LATE_TERMINATE_EXECUTION";
59const LATE_STREAM_EVENT_ERROR_CODE: &str = "ERR_LATE_STREAM_EVENT";
60const LATE_BRIDGE_RESPONSE_ERROR_CODE: &str = "ERR_LATE_BRIDGE_RESPONSE";
61const DEFERRED_COMMAND_LIMIT_ERROR_CODE: &str = "ERR_SESSION_DEFERRED_COMMAND_LIMIT";
62const SESSION_COMMAND_CHANNEL_CAPACITY: usize = 256;
63const MAX_DEFERRED_SESSION_COMMANDS: usize = SESSION_COMMAND_CHANNEL_CAPACITY;
64const MAX_DEFERRED_SYNC_MESSAGES: usize = SESSION_COMMAND_CHANNEL_CAPACITY;
65
66#[derive(Clone, Debug, Eq, Hash, PartialEq)]
67struct WarmPoolKey {
68    snapshot_key_digest: SnapshotCacheKey,
69    heap_limit_mb: u32,
70}
71
72struct ParkedWorker {
73    assignment_tx: Sender<SessionAssignment>,
74    join_handle: thread::JoinHandle<()>,
75}
76
77#[derive(Default)]
78struct WarmWorkerPoolState {
79    workers: HashMap<WarmPoolKey, Vec<ParkedWorker>>,
80    refilling: HashSet<WarmPoolKey>,
81}
82
83#[derive(Default)]
84struct WarmWorkerPool {
85    state: Mutex<WarmWorkerPoolState>,
86}
87
88struct SessionAssignment {
89    heap_limit_mb: Option<u32>,
90    cpu_time_limit_ms: Option<u32>,
91    wall_clock_limit_ms: Option<u32>,
92    rx: Receiver<SessionCommand>,
93    slot_control: SlotControl,
94    max_concurrency: usize,
95    event_tx: RuntimeEventSender,
96    call_id_router: CallIdRouter,
97    shared_call_id: SharedCallIdCounter,
98    snapshot_cache: Arc<SnapshotCache>,
99    isolate_handle: SharedIsolateHandle,
100    execution_abort: SharedExecutionAbort,
101    session_id: String,
102    output_generation: Option<u64>,
103}
104
105#[cfg(not(test))]
106struct PrecreatedIsolate {
107    isolate: v8::OwnedIsolate,
108    context: v8::Global<v8::Context>,
109    bridge_code: String,
110    userland_code: String,
111}
112
113#[cfg(test)]
114struct PrecreatedIsolate;
115
116#[cfg(not(test))]
117#[derive(Default)]
118struct V8SessionPhaseStats {
119    calls: u64,
120    total_ns: u128,
121    max_ns: u128,
122}
123
124#[cfg(not(test))]
125static V8_SESSION_PHASES: OnceLock<Mutex<BTreeMap<String, V8SessionPhaseStats>>> = OnceLock::new();
126
127#[cfg(not(test))]
128fn v8_session_phases_enabled() -> bool {
129    std::env::var("AGENTOS_V8_SESSION_PHASES").as_deref() == Ok("1")
130}
131
132#[cfg(not(test))]
133fn record_v8_session_phase(stage: &str, elapsed: Duration) {
134    if !v8_session_phases_enabled() {
135        return;
136    }
137    let phases = V8_SESSION_PHASES.get_or_init(|| Mutex::new(BTreeMap::new()));
138    let Ok(mut phases) = phases.lock() else {
139        return;
140    };
141    let stats = phases.entry(stage.to_string()).or_default();
142    stats.calls += 1;
143    let elapsed_ns = elapsed.as_nanos();
144    stats.total_ns += elapsed_ns;
145    stats.max_ns = stats.max_ns.max(elapsed_ns);
146
147    let Some(path) = std::env::var_os("AGENTOS_V8_SESSION_PHASES_FILE") else {
148        return;
149    };
150    let mut output = String::new();
151    for (stage, stats) in phases.iter() {
152        let total_us = stats.total_ns / 1_000;
153        let avg_us = if stats.calls == 0 {
154            0
155        } else {
156            total_us / u128::from(stats.calls)
157        };
158        let max_us = stats.max_ns / 1_000;
159        output.push_str(&format!(
160            "stage={stage} calls={} total_us={total_us} avg_us={avg_us} max_us={max_us}\n",
161            stats.calls
162        ));
163    }
164    let _ = std::fs::write(path, output);
165}
166
167#[cfg(not(test))]
168fn record_warm_worker_hit() {
169    record_v8_session_phase("warm_worker_hit", Duration::ZERO);
170}
171
172#[cfg(test)]
173fn record_warm_worker_hit() {}
174
175#[cfg(not(test))]
176fn record_warm_worker_miss() {
177    record_v8_session_phase("warm_worker_miss", Duration::ZERO);
178}
179
180#[cfg(test)]
181fn record_warm_worker_miss() {}
182
183fn warm_worker_capacity_per_key() -> usize {
184    std::env::var("AGENTOS_V8_WARM_ISOLATES")
185        .ok()
186        .and_then(|value| value.parse::<usize>().ok())
187        .unwrap_or(2)
188}
189
190fn effective_heap_limit_mb(heap_limit_mb: Option<u32>) -> u32 {
191    heap_limit_mb.unwrap_or(crate::isolate::DEFAULT_HEAP_LIMIT_MB)
192}
193
194fn warm_pool_key(
195    bridge_code: &str,
196    userland_code: &str,
197    heap_limit_mb: Option<u32>,
198) -> WarmPoolKey {
199    WarmPoolKey {
200        snapshot_key_digest: snapshot_cache_key(
201            bridge_code,
202            (!userland_code.is_empty()).then_some(userland_code),
203        ),
204        heap_limit_mb: effective_heap_limit_mb(heap_limit_mb),
205    }
206}
207
208fn warm_key_prefix(key: &WarmPoolKey) -> String {
209    key.snapshot_key_digest[..4]
210        .iter()
211        .map(|byte| format!("{byte:02x}"))
212        .collect()
213}
214
215impl WarmWorkerPool {
216    fn claim(&self, key: &WarmPoolKey) -> Option<ParkedWorker> {
217        let mut state = self.state.lock().expect("warm worker pool lock poisoned");
218        state.workers.get_mut(key).and_then(Vec::pop)
219    }
220
221    fn shutdown_handles(&self) -> Vec<thread::JoinHandle<()>> {
222        let mut state = self.state.lock().expect("warm worker pool lock poisoned");
223        state.refilling.clear();
224        state
225            .workers
226            .drain()
227            .flat_map(|(_, workers)| workers)
228            .map(|worker| {
229                drop(worker.assignment_tx);
230                worker.join_handle
231            })
232            .collect()
233    }
234
235    fn ensure_count(
236        self: &Arc<Self>,
237        snapshot_cache: Arc<SnapshotCache>,
238        slot_control: SlotControl,
239        bridge_code: String,
240        userland_code: String,
241        heap_limit_mb: Option<u32>,
242        requested_count: usize,
243    ) {
244        let capacity = warm_worker_capacity_per_key();
245        if capacity == 0 || requested_count == 0 {
246            return;
247        }
248
249        let target_count = requested_count.min(capacity);
250        let key = warm_pool_key(&bridge_code, &userland_code, heap_limit_mb);
251        {
252            let mut state = self.state.lock().expect("warm worker pool lock poisoned");
253            let current = state.workers.get(&key).map_or(0, Vec::len);
254            if current >= target_count || state.refilling.contains(&key) {
255                return;
256            }
257            state.refilling.insert(key.clone());
258        }
259
260        let pool = Arc::clone(self);
261        let spawn_key = key.clone();
262        let _ = thread::Builder::new()
263            .name(String::from("secure-exec-v8-warm-refill"))
264            .spawn(move || {
265                pool.refill_until(
266                    snapshot_cache,
267                    slot_control,
268                    spawn_key,
269                    bridge_code,
270                    userland_code,
271                    heap_limit_mb,
272                    target_count,
273                );
274            })
275            .map_err(|error| {
276                eprintln!("agentos-v8-runtime: warm worker refill spawn failed: {error}");
277                self.state
278                    .lock()
279                    .expect("warm worker pool lock poisoned")
280                    .refilling
281                    .remove(&key);
282            });
283    }
284
285    // Internal pool-refill plumbing; args mirror the parked-worker construction.
286    #[allow(clippy::too_many_arguments)]
287    fn refill_until(
288        &self,
289        snapshot_cache: Arc<SnapshotCache>,
290        slot_control: SlotControl,
291        key: WarmPoolKey,
292        bridge_code: String,
293        userland_code: String,
294        heap_limit_mb: Option<u32>,
295        target_count: usize,
296    ) {
297        loop {
298            let capacity = warm_worker_capacity_per_key();
299            if capacity == 0 {
300                break;
301            }
302            let desired = target_count.min(capacity);
303            {
304                let state = self.state.lock().expect("warm worker pool lock poisoned");
305                if state.workers.get(&key).map_or(0, Vec::len) >= desired {
306                    break;
307                }
308            }
309
310            let Some(worker) = spawn_warm_worker(
311                Arc::clone(&snapshot_cache),
312                Arc::clone(&slot_control),
313                key.clone(),
314                bridge_code.clone(),
315                userland_code.clone(),
316                heap_limit_mb,
317            ) else {
318                break;
319            };
320
321            let mut state = self.state.lock().expect("warm worker pool lock poisoned");
322            let workers = state.workers.entry(key.clone()).or_default();
323            if workers.len() >= desired {
324                drop(worker.assignment_tx);
325                let _ = worker.join_handle.join();
326                break;
327            }
328            workers.push(worker);
329            eprintln!(
330                "agentos-v8-runtime: warm worker refilled key={} heap={} pool_size={}",
331                warm_key_prefix(&key),
332                key.heap_limit_mb,
333                workers.len()
334            );
335        }
336
337        self.state
338            .lock()
339            .expect("warm worker pool lock poisoned")
340            .refilling
341            .remove(&key);
342    }
343}
344
345#[cfg(not(test))]
346fn spawn_warm_worker(
347    snapshot_cache: Arc<SnapshotCache>,
348    slot_control: SlotControl,
349    key: WarmPoolKey,
350    bridge_code: String,
351    userland_code: String,
352    heap_limit_mb: Option<u32>,
353) -> Option<ParkedWorker> {
354    let (assignment_tx, assignment_rx) = crossbeam_channel::bounded::<SessionAssignment>(1);
355    let (ready_tx, ready_rx) = crossbeam_channel::bounded::<Result<(), String>>(1);
356    let worker_bridge_code = bridge_code.clone();
357    let worker_userland_code = userland_code.clone();
358    let join_handle = match thread::Builder::new()
359        .name(String::from("secure-exec-v8-warm-worker"))
360        .spawn(move || {
361            let precreated = precreate_warm_isolate(
362                snapshot_cache,
363                slot_control,
364                worker_bridge_code,
365                worker_userland_code,
366                heap_limit_mb,
367            );
368            match precreated {
369                Ok(precreated) => {
370                    let _ = ready_tx.send(Ok(()));
371                    if let Ok(assignment) = assignment_rx.recv() {
372                        session_thread(assignment, Some(precreated));
373                    }
374                }
375                Err(error) => {
376                    let _ = ready_tx.send(Err(error));
377                }
378            }
379        }) {
380        Ok(handle) => handle,
381        Err(error) => {
382            eprintln!("agentos-v8-runtime: warm worker spawn failed: {error}");
383            return None;
384        }
385    };
386
387    match ready_rx.recv() {
388        Ok(Ok(())) => Some(ParkedWorker {
389            assignment_tx,
390            join_handle,
391        }),
392        Ok(Err(error)) => {
393            eprintln!(
394                "agentos-v8-runtime: warm worker refill failed key={} heap={}: {error}",
395                warm_key_prefix(&key),
396                key.heap_limit_mb
397            );
398            let _ = join_handle.join();
399            None
400        }
401        Err(error) => {
402            eprintln!(
403                "agentos-v8-runtime: warm worker refill failed key={} heap={}: {error}",
404                warm_key_prefix(&key),
405                key.heap_limit_mb
406            );
407            let _ = join_handle.join();
408            None
409        }
410    }
411}
412
413#[cfg(test)]
414fn spawn_warm_worker(
415    _snapshot_cache: Arc<SnapshotCache>,
416    _slot_control: SlotControl,
417    _key: WarmPoolKey,
418    _bridge_code: String,
419    _userland_code: String,
420    _heap_limit_mb: Option<u32>,
421) -> Option<ParkedWorker> {
422    None
423}
424
425#[cfg(not(test))]
426fn precreate_warm_isolate(
427    snapshot_cache: Arc<SnapshotCache>,
428    slot_control: SlotControl,
429    bridge_code: String,
430    userland_code: String,
431    heap_limit_mb: Option<u32>,
432) -> Result<PrecreatedIsolate, String> {
433    isolate::init_v8_platform();
434    let snapshot_blob = snapshot_cache.get_or_create_with_userland(
435        &bridge_code,
436        (!userland_code.is_empty()).then_some(userland_code.as_str()),
437    )?;
438    let snapshot_blob = (*snapshot_blob).clone();
439    let _idle_slots = lock_idle_slots(&slot_control);
440    let mut isolate = snapshot::create_isolate_from_snapshot(snapshot_blob, heap_limit_mb);
441    isolate.set_host_import_module_dynamically_callback(execution::dynamic_import_callback);
442    isolate.set_host_initialize_import_meta_object_callback(execution::import_meta_object_callback);
443    let context = isolate::create_context(&mut isolate);
444    Ok(PrecreatedIsolate {
445        isolate,
446        context,
447        bridge_code,
448        userland_code,
449    })
450}
451
452#[cfg(not(test))]
453fn lock_idle_slots(slot_control: &SlotControl) -> std::sync::MutexGuard<'_, usize> {
454    let (lock, cvar) = &**slot_control;
455    let mut count = lock.lock().unwrap();
456    while *count != 0 {
457        count = cvar.wait(count).unwrap();
458    }
459    count
460}
461
462/// Normalize an opt-in CPU-time budget: `Some(0)` means "disabled" and folds to
463/// `None` so the CPU-budget watchdog is NOT armed. The runtime layer does not
464/// invent a default here: secure-exec sidecar VM executions pass the typed
465/// `limits.jsRuntime.cpuTimeLimitMs` default, while lower-level callers can pass
466/// `None`/`0` deliberately.
467fn normalize_cpu_time_limit_ms(cpu_time_limit_ms: Option<u32>) -> Option<u32> {
468    cpu_time_limit_ms.filter(|budget_ms| *budget_ms > 0)
469}
470
471/// Normalize an opt-in WALL-CLOCK backstop: `Some(0)` means "disabled" and folds
472/// to `None` so the wall-clock `TimeoutGuard` is NOT armed. There is no default —
473/// when the caller passes `None`/`0`, the guest runs with no wall-clock limit
474/// (opt-in by design, so long-lived ACP adapters are never killed by a default).
475/// This is INDEPENDENT of the CPU-time budget: setting one does not arm the other.
476fn normalize_wall_clock_limit_ms(wall_clock_limit_ms: Option<u32>) -> Option<u32> {
477    wall_clock_limit_ms.filter(|limit_ms| *limit_ms > 0)
478}
479
480/// Internal entry for a running session
481struct SessionEntry {
482    /// Output receiver generation current when this session was created.
483    output_generation: Option<u64>,
484    /// Channel to send commands to the session thread
485    tx: Sender<SessionCommand>,
486    /// Thread join handle
487    join_handle: Option<thread::JoinHandle<()>>,
488    /// Thread-safe V8 isolate handle for out-of-band termination.
489    #[cfg_attr(test, allow(dead_code))]
490    isolate_handle: SharedIsolateHandle,
491    /// Current execution abort handle used to wake sync bridge waits.
492    execution_abort: SharedExecutionAbort,
493}
494
495/// Deferred shutdown work for a session that has already been removed from
496/// the manager. `finish()` joins the session thread and clears any call
497/// routes the thread registered while shutting down. Callers must release
498/// the SessionManager lock before calling `finish()`. Joining under the lock
499/// deadlocks: the dispatch thread needs the lock to drain the event channel,
500/// and the joined thread can be parked on a full event channel send.
501pub struct SessionShutdown {
502    session_id: String,
503    join_handle: Option<thread::JoinHandle<()>>,
504    call_id_router: CallIdRouter,
505}
506
507impl SessionShutdown {
508    pub fn finish(mut self) {
509        if let Some(handle) = self.join_handle.take() {
510            let _ = handle.join();
511        }
512        self.call_id_router
513            .lock()
514            .expect("call_id router lock poisoned")
515            .retain(|_, routed_session_id| routed_session_id != &self.session_id);
516    }
517}
518
519/// Concurrency slot tracker shared across session threads
520type SlotControl = Arc<(Mutex<usize>, Condvar)>;
521
522/// Shared deferred message queue for non-BridgeResponse frames consumed by
523/// sync bridge calls. The event loop drains these before blocking on the channel.
524pub(crate) type DeferredQueue = Arc<Mutex<VecDeque<SessionMessage>>>;
525
526#[derive(Clone, Copy, Debug, Eq, PartialEq)]
527pub(crate) enum ExecutionAbortReason {
528    /// Caller explicitly terminated the execution (e.g. session destroy).
529    Terminated,
530    /// The opt-in WALL-CLOCK backstop (`TimeoutGuard`) elapsed. Counts elapsed
531    /// real time INCLUDING idle/await, so it can cap a guest that blocks/awaits
532    /// indefinitely. Armed only when `limits.jsRuntime.wallClockLimitMs` is set;
533    /// independent of the CPU-time budget.
534    #[cfg_attr(test, allow(dead_code))]
535    WallClockTimedOut,
536    /// The TRUE CPU-TIME budget (`CpuBudgetGuard`) was exhausted by active JS CPU.
537    #[cfg_attr(test, allow(dead_code))]
538    CpuBudgetExceeded,
539}
540
541struct ExecutionAbortState {
542    sender: Option<crossbeam_channel::Sender<()>>,
543    reason: Option<ExecutionAbortReason>,
544}
545
546pub(crate) struct SharedExecutionAbort(Arc<Mutex<Option<ExecutionAbortState>>>);
547
548impl Clone for SharedExecutionAbort {
549    fn clone(&self) -> Self {
550        Self(Arc::clone(&self.0))
551    }
552}
553
554/// Create a new empty deferred queue.
555pub(crate) fn new_deferred_queue() -> DeferredQueue {
556    Arc::new(Mutex::new(VecDeque::new()))
557}
558
559pub(crate) fn new_execution_abort() -> SharedExecutionAbort {
560    SharedExecutionAbort(Arc::new(Mutex::new(None)))
561}
562
563pub(crate) struct ActiveExecutionAbort {
564    shared: SharedExecutionAbort,
565}
566
567impl ActiveExecutionAbort {
568    pub(crate) fn arm(shared: &SharedExecutionAbort) -> (Self, crossbeam_channel::Receiver<()>) {
569        let (tx, rx) = crossbeam_channel::bounded::<()>(0);
570        let mut guard = shared.0.lock().unwrap();
571        *guard = Some(ExecutionAbortState {
572            sender: Some(tx),
573            reason: None,
574        });
575        (
576            Self {
577                shared: shared.clone(),
578            },
579            rx,
580        )
581    }
582}
583
584impl Drop for ActiveExecutionAbort {
585    fn drop(&mut self) {
586        *self.shared.0.lock().unwrap() = None;
587    }
588}
589
590pub(crate) fn signal_execution_abort(shared: &SharedExecutionAbort, reason: ExecutionAbortReason) {
591    if let Some(state) = shared.0.lock().unwrap().as_mut() {
592        state.reason.get_or_insert(reason);
593        state.sender.take();
594    }
595}
596
597#[cfg(not(test))]
598fn execution_abort_reason(shared: &SharedExecutionAbort) -> Option<ExecutionAbortReason> {
599    shared
600        .0
601        .lock()
602        .unwrap()
603        .as_ref()
604        .and_then(|state| state.reason)
605}
606
607/// Manages V8 sessions with concurrency limiting.
608/// Each session runs on a dedicated OS thread with its own V8 isolate.
609pub struct SessionManager {
610    sessions: HashMap<String, SessionEntry>,
611    max_concurrency: usize,
612    slot_control: SlotControl,
613    /// Typed runtime event sender shared across session threads.
614    event_tx: RuntimeEventSender,
615    /// Call_id → session_id routing table for BridgeResponse dispatch
616    call_id_router: CallIdRouter,
617    /// Shared call_id counter — all sessions use this to generate globally unique
618    /// call_ids, preventing collisions in the call_id_router
619    shared_call_id: SharedCallIdCounter,
620    /// Shared snapshot cache for fast isolate creation from pre-compiled bridge code
621    snapshot_cache: Arc<SnapshotCache>,
622    /// Ready-to-claim isolate workers keyed by snapshot digest and heap cap.
623    warm_pool: Arc<WarmWorkerPool>,
624}
625
626impl SessionManager {
627    pub fn new(
628        max_concurrency: usize,
629        event_tx: RuntimeEventSender,
630        call_id_router: CallIdRouter,
631        snapshot_cache: Arc<SnapshotCache>,
632    ) -> Self {
633        SessionManager {
634            sessions: HashMap::new(),
635            max_concurrency,
636            slot_control: Arc::new((Mutex::new(0), Condvar::new())),
637            event_tx,
638            call_id_router,
639            shared_call_id: Arc::new(AtomicU64::new(1)),
640            snapshot_cache,
641            warm_pool: Arc::new(WarmWorkerPool::default()),
642        }
643    }
644
645    /// Get the snapshot cache for pre-warming from WarmSnapshot messages.
646    #[allow(dead_code)]
647    pub fn snapshot_cache(&self) -> &Arc<SnapshotCache> {
648        &self.snapshot_cache
649    }
650
651    pub fn pre_warm_workers(
652        &self,
653        bridge_code: String,
654        userland_code: String,
655        heap_limit_mb: Option<u32>,
656        count: usize,
657    ) {
658        self.warm_pool.ensure_count(
659            Arc::clone(&self.snapshot_cache),
660            Arc::clone(&self.slot_control),
661            bridge_code,
662            userland_code,
663            heap_limit_mb,
664            count,
665        );
666    }
667
668    /// Create a new session.
669    /// Spawns a dedicated thread with a V8 isolate. If max concurrency is
670    /// reached, the session thread will block until a slot becomes available.
671    pub fn create_session(
672        &mut self,
673        session_id: String,
674        heap_limit_mb: Option<u32>,
675        cpu_time_limit_ms: Option<u32>,
676        wall_clock_limit_ms: Option<u32>,
677    ) -> Result<(), String> {
678        self.create_session_with_output_generation(
679            session_id,
680            heap_limit_mb,
681            cpu_time_limit_ms,
682            wall_clock_limit_ms,
683            None,
684            None,
685        )
686    }
687
688    pub fn create_session_with_output_generation(
689        &mut self,
690        session_id: String,
691        heap_limit_mb: Option<u32>,
692        cpu_time_limit_ms: Option<u32>,
693        wall_clock_limit_ms: Option<u32>,
694        output_generation: Option<u64>,
695        warm_hint: Option<WarmSessionHint>,
696    ) -> Result<(), String> {
697        if self.sessions.contains_key(&session_id) {
698            return Err(format!("session {} already exists", session_id));
699        }
700
701        let cpu_time_limit_ms = normalize_cpu_time_limit_ms(cpu_time_limit_ms);
702        let wall_clock_limit_ms = normalize_wall_clock_limit_ms(wall_clock_limit_ms);
703        let (tx, rx) = crossbeam_channel::bounded(SESSION_COMMAND_CHANNEL_CAPACITY);
704        let isolate_handle = Arc::new(Mutex::new(None));
705        let execution_abort = new_execution_abort();
706        let assignment = SessionAssignment {
707            heap_limit_mb,
708            cpu_time_limit_ms,
709            wall_clock_limit_ms,
710            rx,
711            slot_control: Arc::clone(&self.slot_control),
712            max_concurrency: self.max_concurrency,
713            event_tx: self.event_tx.clone(),
714            call_id_router: Arc::clone(&self.call_id_router),
715            shared_call_id: Arc::clone(&self.shared_call_id),
716            snapshot_cache: Arc::clone(&self.snapshot_cache),
717            isolate_handle: Arc::clone(&isolate_handle),
718            execution_abort: execution_abort.clone(),
719            session_id: session_id.clone(),
720            output_generation,
721        };
722
723        let join_handle = match self.claim_warm_worker(warm_hint.as_ref(), assignment) {
724            Ok((join_handle, true)) => {
725                if let Some(hint) = warm_hint {
726                    self.warm_pool.ensure_count(
727                        Arc::clone(&self.snapshot_cache),
728                        Arc::clone(&self.slot_control),
729                        hint.bridge_code,
730                        hint.userland_code,
731                        hint.heap_limit_mb,
732                        warm_worker_capacity_per_key(),
733                    );
734                }
735                join_handle
736            }
737            Ok((join_handle, false)) => join_handle,
738            Err(assignment) => spawn_session_thread(assignment)
739                .map_err(|e| format!("failed to spawn session thread: {}", e))?,
740        };
741
742        self.sessions.insert(
743            session_id,
744            SessionEntry {
745                output_generation,
746                tx,
747                join_handle: Some(join_handle),
748                isolate_handle,
749                execution_abort,
750            },
751        );
752
753        Ok(())
754    }
755
756    // The Err variant intentionally carries the whole SessionAssignment back to
757    // the caller for the fallback spawn path — it is moved, not copied.
758    #[allow(clippy::result_large_err)]
759    fn claim_warm_worker(
760        &self,
761        warm_hint: Option<&WarmSessionHint>,
762        assignment: SessionAssignment,
763    ) -> Result<(thread::JoinHandle<()>, bool), SessionAssignment> {
764        let Some(hint) = warm_hint else {
765            return Err(assignment);
766        };
767        if warm_worker_capacity_per_key() == 0 {
768            record_warm_worker_miss();
769            return Err(assignment);
770        }
771
772        let key = warm_pool_key(&hint.bridge_code, &hint.userland_code, hint.heap_limit_mb);
773        let Some(worker) = self.warm_pool.claim(&key) else {
774            record_warm_worker_miss();
775            eprintln!(
776                "agentos-v8-runtime: warm worker pool-empty key={} heap={}",
777                warm_key_prefix(&key),
778                key.heap_limit_mb
779            );
780            return Err(assignment);
781        };
782
783        match worker.assignment_tx.send(assignment) {
784            Ok(()) => {
785                record_warm_worker_hit();
786                eprintln!(
787                    "agentos-v8-runtime: warm worker claimed key={} heap={}",
788                    warm_key_prefix(&key),
789                    key.heap_limit_mb
790                );
791                Ok((worker.join_handle, true))
792            }
793            Err(error) => {
794                record_warm_worker_miss();
795                let _ = worker.join_handle.join();
796                Err(error.0)
797            }
798        }
799    }
800
801    pub fn destroy_session_if_output_generation(
802        &mut self,
803        session_id: &str,
804        output_generation: u64,
805    ) -> Result<bool, String> {
806        match self.begin_destroy_session_if_output_generation(session_id, output_generation)? {
807            Some(shutdown) => {
808                shutdown.finish();
809                Ok(true)
810            }
811            None => Ok(false),
812        }
813    }
814
815    pub fn begin_destroy_session_if_output_generation(
816        &mut self,
817        session_id: &str,
818        output_generation: u64,
819    ) -> Result<Option<SessionShutdown>, String> {
820        if self
821            .sessions
822            .get(session_id)
823            .is_none_or(|entry| entry.output_generation != Some(output_generation))
824        {
825            return Ok(None);
826        }
827
828        self.begin_destroy_session(session_id).map(Some)
829    }
830
831    pub fn detach_session_if_output_generation(
832        &mut self,
833        session_id: &str,
834        output_generation: u64,
835    ) -> Result<bool, String> {
836        if self
837            .sessions
838            .get(session_id)
839            .is_none_or(|entry| entry.output_generation != Some(output_generation))
840        {
841            return Ok(false);
842        }
843
844        self.detach_session(session_id)?;
845        Ok(true)
846    }
847
848    fn detach_session(&mut self, session_id: &str) -> Result<(), String> {
849        let entry = self
850            .sessions
851            .get(session_id)
852            .ok_or_else(|| format!("session {} does not exist", session_id))?;
853
854        #[cfg(not(test))]
855        if let Some(handle) = entry
856            .isolate_handle
857            .lock()
858            .ok()
859            .and_then(|guard| guard.as_ref().cloned())
860        {
861            handle.terminate_execution();
862        }
863        signal_execution_abort(&entry.execution_abort, ExecutionAbortReason::Terminated);
864        self.clear_call_routes_for_session(session_id);
865        let mut entry = self.sessions.remove(session_id).unwrap();
866        let _ = entry.tx.try_send(SessionCommand::Shutdown);
867        drop(entry.tx);
868        let _ = entry.join_handle.take();
869        Ok(())
870    }
871
872    /// Destroy a session inline. Joins the session thread before returning, so
873    /// this must not be called while a shared lock on the manager is held. Lock
874    /// holders use `begin_destroy_session` and call `finish()` after unlocking.
875    pub fn destroy_session(&mut self, session_id: &str) -> Result<(), String> {
876        self.begin_destroy_session(session_id)?.finish();
877        Ok(())
878    }
879
880    /// First phase of destroying a session: terminate execution, signal abort,
881    /// send shutdown, clear call routes, and remove the entry. The returned
882    /// shutdown joins the session thread and must be finished after the
883    /// SessionManager lock is released.
884    pub fn begin_destroy_session(&mut self, session_id: &str) -> Result<SessionShutdown, String> {
885        if !self.sessions.contains_key(session_id) {
886            return Err(format!("session {} does not exist", session_id));
887        }
888
889        self.clear_call_routes_for_session(session_id);
890        let mut entry = self
891            .sessions
892            .remove(session_id)
893            .expect("checked session exists");
894
895        #[cfg(not(test))]
896        if let Some(handle) = entry
897            .isolate_handle
898            .lock()
899            .ok()
900            .and_then(|guard| guard.as_ref().cloned())
901        {
902            handle.terminate_execution();
903        }
904        signal_execution_abort(&entry.execution_abort, ExecutionAbortReason::Terminated);
905        // Send shutdown, then drop the entry (and with it the sender) so the
906        // session thread's rx.recv() returns Err if Shutdown was consumed by
907        // an inner loop.
908        let _ = entry.tx.try_send(SessionCommand::Shutdown);
909        let join_handle = entry.join_handle.take();
910        drop(entry);
911        Ok(SessionShutdown {
912            session_id: session_id.to_owned(),
913            join_handle,
914            call_id_router: Arc::clone(&self.call_id_router),
915        })
916    }
917
918    pub(crate) fn take_session_shutdown_handles(&mut self) -> Vec<thread::JoinHandle<()>> {
919        self.call_id_router
920            .lock()
921            .expect("call_id router lock poisoned")
922            .clear();
923
924        let mut handles: Vec<_> = self
925            .sessions
926            .drain()
927            .filter_map(|(_, mut entry)| {
928                #[cfg(not(test))]
929                if let Some(handle) = entry
930                    .isolate_handle
931                    .lock()
932                    .ok()
933                    .and_then(|guard| guard.as_ref().cloned())
934                {
935                    handle.terminate_execution();
936                }
937                signal_execution_abort(&entry.execution_abort, ExecutionAbortReason::Terminated);
938                let _ = entry.tx.try_send(SessionCommand::Shutdown);
939                drop(entry.tx);
940                entry.join_handle.take()
941            })
942            .collect();
943        handles.extend(self.warm_pool.shutdown_handles());
944        handles
945    }
946
947    pub(crate) fn clear_call_route(&self, call_id: u64) {
948        self.call_id_router
949            .lock()
950            .expect("call_id router lock poisoned")
951            .remove(&call_id);
952    }
953
954    fn clear_call_routes_for_session(&self, session_id: &str) {
955        self.call_id_router
956            .lock()
957            .expect("call_id router lock poisoned")
958            .retain(|_, routed_session_id| routed_session_id != session_id);
959    }
960
961    /// Resolve a session's command sender and apply message side effects that
962    /// must happen under the manager lock (isolate termination, abort signal).
963    /// The caller sends on the returned channel after releasing the lock so a
964    /// full command channel cannot block the manager mutex.
965    pub fn session_command_sender(
966        &self,
967        session_id: &str,
968        msg: &SessionMessage,
969    ) -> Result<Sender<SessionCommand>, String> {
970        let entry = self
971            .sessions
972            .get(session_id)
973            .ok_or_else(|| format!("session {} does not exist", session_id))?;
974
975        #[cfg(not(test))]
976        if matches!(msg, SessionMessage::TerminateExecution) {
977            if let Some(handle) = entry
978                .isolate_handle
979                .lock()
980                .ok()
981                .and_then(|guard| guard.as_ref().cloned())
982            {
983                handle.terminate_execution();
984            }
985        }
986        if matches!(msg, SessionMessage::TerminateExecution) {
987            signal_execution_abort(&entry.execution_abort, ExecutionAbortReason::Terminated);
988        }
989
990        Ok(entry.tx.clone())
991    }
992
993    /// Get a session's command sender without a message (used for control commands
994    /// like SetModuleReader that aren't a SessionMessage). Dispatch-thread only.
995    pub fn session_sender(&self, session_id: &str) -> Result<Sender<SessionCommand>, String> {
996        self.sessions
997            .get(session_id)
998            .map(|entry| entry.tx.clone())
999            .ok_or_else(|| format!("session {} does not exist", session_id))
1000    }
1001
1002    /// Send a message to a session. Blocks on the session command channel, so
1003    /// this must not be called while a shared lock on the manager is held.
1004    pub fn send_to_session(&self, session_id: &str, msg: SessionMessage) -> Result<(), String> {
1005        let sender = self.session_command_sender(session_id, &msg)?;
1006        sender
1007            .send(SessionCommand::Message(msg))
1008            .map_err(|e| format!("session thread disconnected: {}", e))
1009    }
1010
1011    /// Destroy a set of sessions inline, ignoring sessions that were already
1012    /// removed. Joins session threads, so this must not be called while a
1013    /// shared lock on the manager is held.
1014    pub fn destroy_sessions<I>(&mut self, session_ids: I)
1015    where
1016        I: IntoIterator<Item = String>,
1017    {
1018        for shutdown in self.begin_destroy_sessions(session_ids) {
1019            shutdown.finish();
1020        }
1021    }
1022
1023    /// Begin destroying a set of sessions, ignoring sessions that were already
1024    /// removed. Finish each returned shutdown after releasing the manager lock.
1025    pub fn begin_destroy_sessions<I>(&mut self, session_ids: I) -> Vec<SessionShutdown>
1026    where
1027        I: IntoIterator<Item = String>,
1028    {
1029        session_ids
1030            .into_iter()
1031            .filter_map(|sid| self.begin_destroy_session(&sid).ok())
1032            .collect()
1033    }
1034
1035    /// Number of registered sessions (including those waiting for a slot).
1036    #[allow(dead_code)]
1037    pub fn session_count(&self) -> usize {
1038        self.sessions.len()
1039    }
1040
1041    /// Return all session IDs.
1042    #[allow(dead_code)]
1043    pub fn all_sessions(&self) -> Vec<String> {
1044        self.sessions.keys().cloned().collect()
1045    }
1046
1047    /// Number of sessions that have acquired a concurrency slot.
1048    #[allow(dead_code)]
1049    pub fn active_slot_count(&self) -> usize {
1050        let (lock, _) = &*self.slot_control;
1051        *lock.lock().unwrap()
1052    }
1053
1054    /// Get the call_id routing table for BridgeResponse dispatch.
1055    pub fn call_id_router(&self) -> &CallIdRouter {
1056        &self.call_id_router
1057    }
1058}
1059
1060/// Send a typed runtime event without re-serializing it on the session thread.
1061#[cfg(not(test))]
1062fn send_event_with_generation(
1063    event_tx: &RuntimeEventSender,
1064    output_generation: Option<u64>,
1065    event: RuntimeEvent,
1066) {
1067    if let Err(error) = event_tx.send(RuntimeEventEnvelope {
1068        output_generation,
1069        event,
1070    }) {
1071        eprintln!("failed to send runtime event: {error}");
1072    }
1073}
1074
1075fn send_late_message_warning(
1076    event_tx: &RuntimeEventSender,
1077    session_id: &str,
1078    output_generation: Option<u64>,
1079    error_code: &str,
1080    detail: String,
1081) {
1082    let warning = RuntimeEvent::Log {
1083        session_id: session_id.to_string(),
1084        channel: 1,
1085        message: format!("[{error_code}] {detail}"),
1086    };
1087    if let Err(error) = event_tx.send(RuntimeEventEnvelope {
1088        output_generation,
1089        event: warning,
1090    }) {
1091        eprintln!("failed to send late-session warning: {error}");
1092    }
1093}
1094
1095fn handle_late_session_message(
1096    event_tx: &RuntimeEventSender,
1097    session_id: &str,
1098    output_generation: Option<u64>,
1099    message: SessionMessage,
1100) {
1101    match message {
1102        SessionMessage::BridgeResponse(BridgeResponse {
1103            call_id,
1104            status,
1105            payload,
1106        }) => send_late_message_warning(
1107            event_tx,
1108            session_id,
1109            output_generation,
1110            LATE_BRIDGE_RESPONSE_ERROR_CODE,
1111            format!(
1112                "dropping BridgeResponse after execution completed (call_id={call_id}, status={status}, payload_len={})",
1113                payload.len()
1114            ),
1115        ),
1116        SessionMessage::StreamEvent(StreamEvent {
1117            event_type,
1118            payload,
1119        }) => {
1120            // Timer and socket-readiness events are wake hints, not data; both
1121            // race execution completion by design (edge-triggered readiness can
1122            // fire as the guest finishes) and carry nothing to lose, so drop
1123            // them silently instead of warning.
1124            if event_type == "timer" || event_type == "net_socket" {
1125                return;
1126            }
1127            send_late_message_warning(
1128                event_tx,
1129                session_id,
1130                output_generation,
1131                LATE_STREAM_EVENT_ERROR_CODE,
1132                format!(
1133                    "dropping StreamEvent after execution completed (event_type={event_type}, payload_len={})",
1134                    payload.len()
1135                ),
1136            )
1137        }
1138        SessionMessage::TerminateExecution => send_late_message_warning(
1139            event_tx,
1140            session_id,
1141            output_generation,
1142            LATE_TERMINATE_EXECUTION_ERROR_CODE,
1143            String::from("dropping TerminateExecution after execution completed"),
1144        ),
1145        SessionMessage::InjectGlobals { .. } | SessionMessage::Execute { .. } => {}
1146    }
1147}
1148
1149fn defer_session_command_before_slot(
1150    deferred_commands: &mut VecDeque<SessionCommand>,
1151    event_tx: &RuntimeEventSender,
1152    session_id: &str,
1153    output_generation: Option<u64>,
1154    command: SessionCommand,
1155) -> bool {
1156    if deferred_commands.len() < MAX_DEFERRED_SESSION_COMMANDS {
1157        deferred_commands.push_back(command);
1158        return true;
1159    }
1160
1161    send_late_message_warning(
1162        event_tx,
1163        session_id,
1164        output_generation,
1165        DEFERRED_COMMAND_LIMIT_ERROR_CODE,
1166        format!(
1167            "dropping queued session before slot acquisition because deferred command queue exceeded limit of {MAX_DEFERRED_SESSION_COMMANDS}"
1168        ),
1169    );
1170    false
1171}
1172
1173#[cfg(not(test))]
1174fn install_wasm_module_bytes_global<'s>(scope: &mut v8::HandleScope<'s>, bytes: &[u8]) -> bool {
1175    let global = scope.get_current_context().global(scope);
1176    let Some(name) = v8::String::new(scope, "__agentOSWasmModuleBytes") else {
1177        return false;
1178    };
1179    let len = bytes.len();
1180    let backing_store = v8::ArrayBuffer::new_backing_store_from_bytes(bytes.to_vec());
1181    let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_store.make_shared());
1182    let Some(bytes_value) = v8::Uint8Array::new(scope, array_buffer, 0, len) else {
1183        return false;
1184    };
1185    global.set(scope, name.into(), bytes_value.into()).is_some()
1186}
1187
1188/// Session thread: acquires a concurrency slot, defers V8 isolate creation
1189/// to first Execute (when bridge code is known for snapshot lookup), and
1190/// processes commands until shutdown.
1191fn spawn_session_thread(assignment: SessionAssignment) -> std::io::Result<thread::JoinHandle<()>> {
1192    let name_prefix = if assignment.session_id.len() > 8 {
1193        assignment.session_id[..8].to_string()
1194    } else {
1195        assignment.session_id.clone()
1196    };
1197    thread::Builder::new()
1198        .name(format!("session-{}", name_prefix))
1199        .spawn(move || session_thread(assignment, None))
1200}
1201
1202#[allow(clippy::too_many_arguments)]
1203fn session_thread(
1204    assignment: SessionAssignment,
1205    #[cfg_attr(test, allow(unused_variables))] precreated_isolate: Option<PrecreatedIsolate>,
1206) {
1207    let SessionAssignment {
1208        heap_limit_mb,
1209        cpu_time_limit_ms,
1210        wall_clock_limit_ms,
1211        rx,
1212        slot_control,
1213        max_concurrency,
1214        event_tx,
1215        call_id_router,
1216        shared_call_id,
1217        snapshot_cache,
1218        isolate_handle,
1219        execution_abort,
1220        session_id,
1221        output_generation,
1222    } = assignment;
1223    #[cfg(test)]
1224    let _ = (
1225        heap_limit_mb,
1226        cpu_time_limit_ms,
1227        wall_clock_limit_ms,
1228        call_id_router,
1229        shared_call_id,
1230        snapshot_cache,
1231        isolate_handle,
1232        execution_abort,
1233    );
1234
1235    // Acquire concurrency slot, but keep polling the session channel so a queued
1236    // session can still shut down cleanly before it ever gets a slot.
1237    let mut deferred_commands = VecDeque::new();
1238    let acquired_slot = {
1239        let (lock, cvar) = &*slot_control;
1240        let mut count = lock.lock().unwrap();
1241        loop {
1242            if *count < max_concurrency {
1243                *count += 1;
1244                break true;
1245            }
1246
1247            let (next_count, _) = cvar
1248                .wait_timeout(count, std::time::Duration::from_millis(50))
1249                .unwrap();
1250            count = next_count;
1251
1252            match rx.try_recv() {
1253                Ok(SessionCommand::Shutdown)
1254                | Err(crossbeam_channel::TryRecvError::Disconnected) => {
1255                    break false;
1256                }
1257                Ok(command) => {
1258                    if !defer_session_command_before_slot(
1259                        &mut deferred_commands,
1260                        &event_tx,
1261                        &session_id,
1262                        output_generation,
1263                        command,
1264                    ) {
1265                        break false;
1266                    }
1267                }
1268                Err(crossbeam_channel::TryRecvError::Empty) => {}
1269            }
1270        }
1271    };
1272
1273    if !acquired_slot {
1274        return;
1275    }
1276
1277    // Capture THIS session thread's per-thread CPU clock once. The clock id is
1278    // stable for the thread's lifetime and can be polled from the watchdog
1279    // thread; this is what lets the CPU-budget guard measure active JS CPU time
1280    // (excluding idle/await) without running on the execution thread itself.
1281    // Guest JS always runs on this thread, so this clock is the execution clock.
1282    #[cfg(all(not(test), unix))]
1283    let exec_thread_cpu_clock = crate::timeout::current_thread_cpu_clock();
1284    #[cfg(all(not(test), not(unix)))]
1285    let exec_thread_cpu_clock: Option<crate::timeout::ThreadCpuClock> = None;
1286
1287    // Isolate creation is normally deferred to first Execute (when bridge code is
1288    // known for snapshot cache lookup). A claimed warm worker enters here with
1289    // the snapshot isolate already created on this same thread.
1290    #[cfg(not(test))]
1291    let (
1292        mut v8_isolate,
1293        mut _v8_context,
1294        mut from_snapshot,
1295        mut isolate_bridge_code,
1296        mut isolate_userland_code,
1297    ) = match precreated_isolate {
1298        Some(precreated) => (
1299            Some(precreated.isolate),
1300            Some(precreated.context),
1301            true,
1302            Some(precreated.bridge_code),
1303            Some(precreated.userland_code),
1304        ),
1305        None => (None, None, false, None, None),
1306    };
1307
1308    #[cfg(not(test))]
1309    let mut pending = bridge::PendingPromises::new();
1310
1311    // Store latest InjectGlobals V8 payload for re-injection into fresh contexts
1312    #[cfg(not(test))]
1313    let mut last_globals_payload: Option<Vec<u8>> = None;
1314
1315    // Bridge code cache for V8 code caching across executions
1316    #[cfg(not(test))]
1317    let mut bridge_cache: Option<execution::BridgeCodeCache> = None;
1318
1319    // Cached bridge code string to skip resending over IPC
1320    #[cfg(not(test))]
1321    let mut last_bridge_code: Option<String> = None;
1322    // Cached agent-SDK userland bundle (same 0-length = use cached convention).
1323    #[cfg(not(test))]
1324    let mut last_userland_code: Option<String> = None;
1325
1326    // A session can reuse its isolate across Executes only while the effective
1327    // bridge code stays the same. Fresh contexts cloned from a snapshot inherit
1328    // the snapshot's bridge IIFE, so a bridge-code change must rebuild the
1329    // isolate before the next execution or the session will keep restoring the
1330    // old snapshot forever. The userland bundle is part of the same guard.
1331    #[cfg(not(test))]
1332    let mut high_resolution_time_origin = Instant::now();
1333
1334    #[cfg(not(test))]
1335    if let Some(iso) = v8_isolate.as_mut() {
1336        *isolate_handle
1337            .lock()
1338            .expect("session isolate handle lock poisoned") = Some(iso.thread_safe_handle());
1339    }
1340
1341    // Process commands until shutdown or channel close
1342    loop {
1343        let next_command = if let Some(command) = deferred_commands.pop_front() {
1344            Ok(command)
1345        } else {
1346            rx.recv()
1347        };
1348
1349        match next_command {
1350            Ok(SessionCommand::Shutdown) | Err(_) => break,
1351            Ok(SessionCommand::SetModuleReader(reader)) => {
1352                execution::install_session_guest_reader(Some(reader));
1353            }
1354            Ok(SessionCommand::Message(msg)) => match msg {
1355                SessionMessage::InjectGlobals { payload } => {
1356                    #[cfg(not(test))]
1357                    {
1358                        // Store V8-serialized config for injection into fresh context at Execute time
1359                        last_globals_payload = Some(payload);
1360                    }
1361                    #[cfg(test)]
1362                    {
1363                        let _ = payload;
1364                    }
1365                }
1366                SessionMessage::Execute {
1367                    mode,
1368                    file_path,
1369                    bridge_code,
1370                    post_restore_script,
1371                    userland_code,
1372                    high_resolution_time,
1373                    user_code,
1374                    wasm_module_bytes,
1375                } => {
1376                    // `userland_code` is consumed only by the non-test snapshot
1377                    // path below; keep it bound (without a warning) under `test`.
1378                    #[cfg(test)]
1379                    let _ = &userland_code;
1380                    #[cfg(test)]
1381                    let _ = high_resolution_time;
1382                    #[cfg(test)]
1383                    let _ = &wasm_module_bytes;
1384                    #[cfg(not(test))]
1385                    {
1386                        let session_id = session_id.clone();
1387                        // Use cached bridge code when host sends empty (0-length = use cached)
1388                        let should_update_cached_bridge_code = !bridge_code.is_empty();
1389                        let effective_bridge_code = if bridge_code.is_empty() {
1390                            last_bridge_code.as_deref().unwrap_or("").to_string()
1391                        } else {
1392                            bridge_code
1393                        };
1394                        // Same 0-length = use-cached convention for the userland bundle.
1395                        let should_update_cached_userland_code = !userland_code.is_empty();
1396                        let effective_userland_code = if userland_code.is_empty() {
1397                            last_userland_code.as_deref().unwrap_or("").to_string()
1398                        } else {
1399                            userland_code
1400                        };
1401
1402                        if let Err(message) =
1403                            snapshot::validate_bridge_code_size(&effective_bridge_code)
1404                        {
1405                            let result_frame = RuntimeEvent::ExecutionResult {
1406                                session_id,
1407                                exit_code: 1,
1408                                exports: None,
1409                                error: Some(ExecutionErrorBin {
1410                                    error_type: "Error".into(),
1411                                    message,
1412                                    stack: String::new(),
1413                                    code: snapshot::V8_BRIDGE_CODE_LIMIT_ERROR_CODE.into(),
1414                                }),
1415                            };
1416                            send_event_with_generation(&event_tx, output_generation, result_frame);
1417                            continue;
1418                        }
1419
1420                        if should_update_cached_bridge_code {
1421                            last_bridge_code = Some(effective_bridge_code.clone());
1422                        }
1423                        if should_update_cached_userland_code {
1424                            last_userland_code = Some(effective_userland_code.clone());
1425                        }
1426
1427                        if v8_isolate.is_some()
1428                            && (isolate_bridge_code.as_deref()
1429                                != Some(effective_bridge_code.as_str())
1430                                || isolate_userland_code.as_deref()
1431                                    != Some(effective_userland_code.as_str()))
1432                        {
1433                            *isolate_handle
1434                                .lock()
1435                                .expect("session isolate handle lock poisoned") = None;
1436                            // Reset pending promise-resolver Globals BEFORE this
1437                            // isolate is dropped. The registry is reused across
1438                            // isolate rebuilds, and a prior execution that was
1439                            // terminated early (Shutdown / timeout-abort) can
1440                            // leave resolvers registered, so they would otherwise
1441                            // outlive the isolate that created them.
1442                            reset_pending_promises(&mut pending);
1443                            drop(_v8_context.take());
1444                            isolate::drop_isolate(v8_isolate.take());
1445                            from_snapshot = false;
1446                            isolate_bridge_code = None;
1447                            isolate_userland_code = None;
1448                        }
1449
1450                        // Deferred isolate creation: create on first Execute using snapshot cache
1451                        if v8_isolate.is_none() {
1452                            isolate::init_v8_platform();
1453                            // The snapshot captures the bridge AND (when present) the
1454                            // agent-SDK userland bundle, keyed process-wide by both, so
1455                            // the SDK is evaluated once per sidecar and reused here.
1456                            let phase_start = Instant::now();
1457                            let snapshot_blob = match snapshot_cache.get_or_create_with_userland(
1458                                &effective_bridge_code,
1459                                (!effective_userland_code.is_empty())
1460                                    .then_some(effective_userland_code.as_str()),
1461                            ) {
1462                                Ok(blob) => Some(blob),
1463                                Err(message) => {
1464                                    // Snapshot creation runs in a helper subprocess; if
1465                                    // that fails (unsupported platform, spawn failure),
1466                                    // degrade to a fresh isolate that evaluates the
1467                                    // bridge in-context rather than failing the session.
1468                                    eprintln!(
1469                                        "agentos-v8-runtime: snapshot creation failed, \
1470                                         falling back to fresh isolate: {message}"
1471                                    );
1472                                    None
1473                                }
1474                            };
1475                            record_v8_session_phase("snapshot_get", phase_start.elapsed());
1476                            let mut iso = match snapshot_blob {
1477                                Some(blob) => {
1478                                    from_snapshot = true;
1479                                    eprintln!(
1480                                        "agentos-v8-runtime: restored session isolate from_snapshot=true"
1481                                    );
1482                                    let phase_start = Instant::now();
1483                                    // rusty_v8 0.130's CreateParams::snapshot_blob
1484                                    // takes owned 'static data, so this copy remains
1485                                    // per exec until the API can accept cached bytes.
1486                                    let snapshot_blob = (*blob).clone();
1487                                    record_v8_session_phase("blob_clone", phase_start.elapsed());
1488                                    let phase_start = Instant::now();
1489                                    let isolate = snapshot::create_isolate_from_snapshot(
1490                                        snapshot_blob,
1491                                        heap_limit_mb,
1492                                    );
1493                                    record_v8_session_phase("isolate_new", phase_start.elapsed());
1494                                    isolate
1495                                }
1496                                None => {
1497                                    from_snapshot = false;
1498                                    let phase_start = Instant::now();
1499                                    let isolate = isolate::create_isolate(heap_limit_mb);
1500                                    record_v8_session_phase("isolate_new", phase_start.elapsed());
1501                                    isolate
1502                                }
1503                            };
1504                            iso.set_host_import_module_dynamically_callback(
1505                                execution::dynamic_import_callback,
1506                            );
1507                            iso.set_host_initialize_import_meta_object_callback(
1508                                execution::import_meta_object_callback,
1509                            );
1510                            high_resolution_time_origin = Instant::now();
1511                            *isolate_handle
1512                                .lock()
1513                                .expect("session isolate handle lock poisoned") =
1514                                Some(iso.thread_safe_handle());
1515                            let ctx = isolate::create_context(&mut iso);
1516                            _v8_context = Some(ctx);
1517                            v8_isolate = Some(iso);
1518                            isolate_bridge_code = Some(effective_bridge_code.clone());
1519                            isolate_userland_code = Some(effective_userland_code.clone());
1520                        }
1521
1522                        let iso = v8_isolate.as_mut().unwrap();
1523                        iso.cancel_terminate_execution();
1524
1525                        // Create execution context: Context::new on a snapshot-restored
1526                        // isolate gives a fresh clone of the snapshot's default context
1527                        // (bridge IIFE already executed, all infrastructure set up).
1528                        // On a non-snapshot isolate, this gives a blank context.
1529                        let exec_context = isolate::create_context(iso);
1530
1531                        if high_resolution_time {
1532                            let scope = &mut v8::HandleScope::new(iso);
1533                            let ctx = v8::Local::new(scope, &exec_context);
1534                            let scope = &mut v8::ContextScope::new(scope, ctx);
1535                            execution::install_high_resolution_time_global(
1536                                scope,
1537                                &high_resolution_time_origin as *const Instant,
1538                            );
1539                        }
1540
1541                        // Inject globals from last InjectGlobals payload
1542                        if let Some(ref payload) = last_globals_payload {
1543                            let scope = &mut v8::HandleScope::new(iso);
1544                            let ctx = v8::Local::new(scope, &exec_context);
1545                            let scope = &mut v8::ContextScope::new(scope, ctx);
1546                            if let Err(error) =
1547                                execution::inject_globals_from_payload(scope, payload)
1548                            {
1549                                let result_frame = RuntimeEvent::ExecutionResult {
1550                                    session_id,
1551                                    exit_code: 1,
1552                                    exports: None,
1553                                    error: Some(ExecutionErrorBin {
1554                                        error_type: error.error_type,
1555                                        message: error.message,
1556                                        stack: error.stack,
1557                                        code: error.code.unwrap_or_default(),
1558                                    }),
1559                                };
1560                                send_event_with_generation(
1561                                    &event_tx,
1562                                    output_generation,
1563                                    result_frame,
1564                                );
1565                                continue;
1566                            }
1567                        }
1568
1569                        // Arm a per-execution abort channel so timeouts and external
1570                        // terminate requests can unblock sync bridge waits.
1571                        let (_active_execution_abort, abort_rx) =
1572                            ActiveExecutionAbort::arm(&execution_abort);
1573
1574                        // Create deferred queue for sync bridge call filtering
1575                        let deferred_queue = new_deferred_queue();
1576
1577                        // Create BridgeCallContext with channel sender (no shared mutex)
1578                        let channel_rx = ChannelResponseReceiver::with_abort(
1579                            rx.clone(),
1580                            abort_rx.clone(),
1581                            Arc::clone(&deferred_queue),
1582                        );
1583                        let bridge_ctx = BridgeCallContext::with_receiver(
1584                            Box::new(ChannelRuntimeEventSender::new(
1585                                event_tx.clone(),
1586                                output_generation,
1587                            )),
1588                            Box::new(channel_rx),
1589                            session_id.clone(),
1590                            Arc::clone(&call_id_router),
1591                            Arc::clone(&shared_call_id),
1592                        );
1593
1594                        // Replace stub bridge functions with real session-local ones
1595                        // (on snapshot context) or register from scratch (on fresh context).
1596                        // Both paths use the same function — global.set() works for both.
1597                        let _sync_store;
1598                        let _async_store;
1599                        let sync_bridge_fns = sync_bridge_fns();
1600                        let async_bridge_fns = async_bridge_fns();
1601                        {
1602                            let scope = &mut v8::HandleScope::new(iso);
1603                            let ctx = v8::Local::new(scope, &exec_context);
1604                            let scope = &mut v8::ContextScope::new(scope, ctx);
1605
1606                            (_sync_store, _async_store) = bridge::replace_bridge_fns(
1607                                scope,
1608                                &bridge_ctx as *const BridgeCallContext,
1609                                &pending as *const bridge::PendingPromises,
1610                                sync_bridge_fns,
1611                                async_bridge_fns,
1612                            );
1613                        }
1614
1615                        // Run post-restore init script (config, mutable state reset)
1616                        // after bridge fn replacement but before user code
1617                        if !post_restore_script.is_empty() {
1618                            let scope = &mut v8::HandleScope::new(iso);
1619                            let ctx = v8::Local::new(scope, &exec_context);
1620                            let scope = &mut v8::ContextScope::new(scope, ctx);
1621                            let (prs_code, prs_err) =
1622                                execution::run_init_script(scope, &post_restore_script);
1623                            if prs_code != 0 {
1624                                let result_frame = RuntimeEvent::ExecutionResult {
1625                                    session_id,
1626                                    exit_code: prs_code,
1627                                    exports: None,
1628                                    error: prs_err.map(|e| ExecutionErrorBin {
1629                                        error_type: e.error_type,
1630                                        message: e.message,
1631                                        stack: e.stack,
1632                                        code: e.code.unwrap_or_default(),
1633                                    }),
1634                                };
1635                                send_event_with_generation(
1636                                    &event_tx,
1637                                    output_generation,
1638                                    result_frame,
1639                                );
1640                                continue;
1641                            }
1642                        }
1643
1644                        if let Some(wasm_module_bytes) = wasm_module_bytes.as_ref() {
1645                            let scope = &mut v8::HandleScope::new(iso);
1646                            let ctx = v8::Local::new(scope, &exec_context);
1647                            let scope = &mut v8::ContextScope::new(scope, ctx);
1648                            if !install_wasm_module_bytes_global(scope, wasm_module_bytes) {
1649                                let result_frame = RuntimeEvent::ExecutionResult {
1650                                    session_id,
1651                                    exit_code: 1,
1652                                    exports: None,
1653                                    error: Some(ExecutionErrorBin {
1654                                        error_type: "Error".into(),
1655                                        message: "failed to install __agentOSWasmModuleBytes"
1656                                            .into(),
1657                                        stack: String::new(),
1658                                        code: String::new(),
1659                                    }),
1660                                };
1661                                send_event_with_generation(
1662                                    &event_tx,
1663                                    output_generation,
1664                                    result_frame,
1665                                );
1666                                continue;
1667                            }
1668                        }
1669
1670                        // Arm the TRUE CPU-TIME budget watchdog before running
1671                        // guest code when the caller passes a nonzero
1672                        // `limits.jsRuntime.cpuTimeLimitMs` (normalized: `0`/unset =>
1673                        // `None` => not armed at this runtime layer). The sidecar
1674                        // supplies the bounded default for VM executions.
1675                        //
1676                        // The watchdog counts ACTIVE JS CPU only (idle/await
1677                        // excluded) by polling the execution thread's CPU clock, so
1678                        // a guest that mostly awaits is NOT killed by it. The
1679                        // INDEPENDENT wall-clock backstop (armed just below) covers
1680                        // the idle/await case when the operator opts into it.
1681                        let mut cpu_budget_guard = match cpu_time_limit_ms {
1682                            Some(budget_ms) => {
1683                                // Enforcing a CPU budget requires the execution
1684                                // thread's CPU clock captured at session start. If
1685                                // it is unavailable we cannot honor the operator's
1686                                // requested cap — surface that rather than silently
1687                                // running uncapped.
1688                                let cpu_clock = match exec_thread_cpu_clock {
1689                                    Some(clock) => clock,
1690                                    None => {
1691                                        let result_frame = RuntimeEvent::ExecutionResult {
1692                                            session_id,
1693                                            exit_code: 1,
1694                                            exports: None,
1695                                            error: Some(ExecutionErrorBin {
1696                                                error_type: "Error".into(),
1697                                                message: format!(
1698                                                    "{}: per-thread CPU clock unavailable; cannot enforce limits.jsRuntime.cpuTimeLimitMs",
1699                                                    crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
1700                                                ),
1701                                                stack: String::new(),
1702                                                code: crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
1703                                                    .into(),
1704                                            }),
1705                                        };
1706                                        send_event_with_generation(
1707                                            &event_tx,
1708                                            output_generation,
1709                                            result_frame,
1710                                        );
1711                                        continue;
1712                                    }
1713                                };
1714                                let handle = iso.thread_safe_handle();
1715                                match crate::timeout::CpuBudgetGuard::new(
1716                                    budget_ms,
1717                                    cpu_clock,
1718                                    handle,
1719                                    execution_abort.clone(),
1720                                ) {
1721                                    Ok(guard) => Some(guard),
1722                                    Err(message) => {
1723                                        let result_frame = RuntimeEvent::ExecutionResult {
1724                                            session_id,
1725                                            exit_code: 1,
1726                                            exports: None,
1727                                            error: Some(ExecutionErrorBin {
1728                                                error_type: "Error".into(),
1729                                                message,
1730                                                stack: String::new(),
1731                                                code:
1732                                                    crate::timeout::CPU_BUDGET_GUARD_START_ERROR_CODE
1733                                                        .into(),
1734                                            }),
1735                                        };
1736                                        send_event_with_generation(
1737                                            &event_tx,
1738                                            output_generation,
1739                                            result_frame,
1740                                        );
1741                                        continue;
1742                                    }
1743                                }
1744                            }
1745                            _ => None,
1746                        };
1747
1748                        // Arm the INDEPENDENT, opt-in WALL-CLOCK backstop alongside
1749                        // the CPU budget. Unlike the CPU budget, this counts elapsed
1750                        // real time INCLUDING idle/await, so it can cap a guest that
1751                        // blocks or awaits indefinitely. Armed only when the operator
1752                        // opts in via `limits.jsRuntime.wallClockLimitMs` (normalized:
1753                        // `0`/unset => `None` => not armed => NO wall-clock limit, so
1754                        // long-lived ACP adapters are never killed by a default).
1755                        // Whichever guard fires first calls `terminate_execution` and
1756                        // records its abort reason; the result frame reports which.
1757                        let mut wall_clock_guard = match wall_clock_limit_ms {
1758                            Some(limit_ms) => {
1759                                let handle = iso.thread_safe_handle();
1760                                match crate::timeout::TimeoutGuard::with_execution_abort(
1761                                    limit_ms,
1762                                    handle,
1763                                    execution_abort.clone(),
1764                                ) {
1765                                    Ok(guard) => Some(guard),
1766                                    Err(message) => {
1767                                        let result_frame = RuntimeEvent::ExecutionResult {
1768                                            session_id,
1769                                            exit_code: 1,
1770                                            exports: None,
1771                                            error: Some(ExecutionErrorBin {
1772                                                error_type: "Error".into(),
1773                                                message,
1774                                                stack: String::new(),
1775                                                code:
1776                                                    crate::timeout::TIMEOUT_GUARD_START_ERROR_CODE
1777                                                        .into(),
1778                                            }),
1779                                        };
1780                                        send_event_with_generation(
1781                                            &event_tx,
1782                                            output_generation,
1783                                            result_frame,
1784                                        );
1785                                        continue;
1786                                    }
1787                                }
1788                            }
1789                            _ => None,
1790                        };
1791
1792                        // On snapshot-restored context, skip bridge IIFE (already in
1793                        // snapshot) and run user code only. On fresh context, run full
1794                        // bridge code + user code as before.
1795                        let bridge_code_for_exec = if from_snapshot {
1796                            ""
1797                        } else {
1798                            &effective_bridge_code
1799                        };
1800                        let file_path_opt = if file_path.is_empty() {
1801                            None
1802                        } else {
1803                            Some(file_path.as_str())
1804                        };
1805                        let phase_start = Instant::now();
1806                        let (mut code, mut exports, mut error) = if mode == 0 {
1807                            let scope = &mut v8::HandleScope::new(iso);
1808                            let ctx = v8::Local::new(scope, &exec_context);
1809                            let scope = &mut v8::ContextScope::new(scope, ctx);
1810                            let (c, e) = execution::execute_script_with_options(
1811                                scope,
1812                                Some(&bridge_ctx),
1813                                bridge_code_for_exec,
1814                                &user_code,
1815                                file_path_opt,
1816                                &mut bridge_cache,
1817                            );
1818                            (c, None, e)
1819                        } else {
1820                            let scope = &mut v8::HandleScope::new(iso);
1821                            let ctx = v8::Local::new(scope, &exec_context);
1822                            let scope = &mut v8::ContextScope::new(scope, ctx);
1823                            execution::execute_module(
1824                                scope,
1825                                &bridge_ctx,
1826                                bridge_code_for_exec,
1827                                &user_code,
1828                                file_path_opt,
1829                                &mut bridge_cache,
1830                            )
1831                        };
1832
1833                        // Re-check async ESM completion once immediately so
1834                        // pure-microtask top-level await settles without
1835                        // needing a bridge event-loop round-trip.
1836                        if mode != 0 && error.is_none() {
1837                            let scope = &mut v8::HandleScope::new(iso);
1838                            let ctx = v8::Local::new(scope, &exec_context);
1839                            let scope = &mut v8::ContextScope::new(scope, ctx);
1840                            if let Some((next_code, next_exports, next_error)) =
1841                                execution::finalize_pending_module_evaluation(scope)
1842                            {
1843                                code = next_code;
1844                                exports = next_exports;
1845                                error = next_error;
1846                            }
1847                        }
1848                        record_v8_session_phase("user_code_execute", phase_start.elapsed());
1849
1850                        // Run event loop while bridge work or async ESM
1851                        // evaluation is still pending. For ESM modules (mode != 0),
1852                        // always enter the event loop even if no pending promises
1853                        // are visible yet — the module body may have registered
1854                        // timers, stdin listeners, or child_process handles that
1855                        // need event loop pumping to deliver their callbacks.
1856                        let should_enter_event_loop = !pending.is_empty()
1857                            || execution::has_pending_module_evaluation()
1858                            || execution::has_pending_script_evaluation()
1859                            || !deferred_queue.lock().unwrap().is_empty();
1860                        let event_loop_status = if should_enter_event_loop {
1861                            let scope = &mut v8::HandleScope::new(iso);
1862                            let ctx = v8::Local::new(scope, &exec_context);
1863                            let scope = &mut v8::ContextScope::new(scope, ctx);
1864                            run_event_loop(
1865                                scope,
1866                                &rx,
1867                                &pending,
1868                                Some(&abort_rx),
1869                                Some(&deferred_queue),
1870                            )
1871                        } else {
1872                            EventLoopStatus::Completed
1873                        };
1874
1875                        let mut terminated =
1876                            matches!(event_loop_status, EventLoopStatus::Terminated);
1877                        if let EventLoopStatus::Failed(next_code, next_error) = event_loop_status {
1878                            code = next_code;
1879                            error = Some(next_error);
1880                        }
1881
1882                        // Finalize any entry-module top-level await that was
1883                        // waiting on bridge-driven async work (timers/network).
1884                        if !terminated && mode != 0 && error.is_none() {
1885                            let scope = &mut v8::HandleScope::new(iso);
1886                            let ctx = v8::Local::new(scope, &exec_context);
1887                            let scope = &mut v8::ContextScope::new(scope, ctx);
1888                            if let Some((next_code, next_exports, next_error)) =
1889                                execution::finalize_pending_module_evaluation(scope)
1890                            {
1891                                code = next_code;
1892                                exports = next_exports;
1893                                error = next_error;
1894                            }
1895                        }
1896
1897                        // Keep the session alive while handles (timers, child
1898                        // processes, stdin listeners) are active. Long-lived
1899                        // ACP adapters often run as plain scripts, so this
1900                        // cannot be limited to ESM entrypoints.
1901                        if !terminated && error.is_none() {
1902                            // Phase 1: call _waitForActiveHandles() to register a pending promise
1903                            {
1904                                let scope = &mut v8::HandleScope::new(iso);
1905                                let ctx = v8::Local::new(scope, &exec_context);
1906                                let scope = &mut v8::ContextScope::new(scope, ctx);
1907                                let global = ctx.global(scope);
1908                                let key = v8::String::new(scope, "_waitForActiveHandles").unwrap();
1909                                if let Some(func) = global.get(scope, key.into()) {
1910                                    if func.is_function() {
1911                                        let func =
1912                                            v8::Local::<v8::Function>::try_from(func).unwrap();
1913                                        let recv = v8::undefined(scope).into();
1914                                        if let Some(result) = func.call(scope, recv, &[]) {
1915                                            if result.is_promise() {
1916                                                let promise =
1917                                                    v8::Local::<v8::Promise>::try_from(result)
1918                                                        .unwrap();
1919                                                if promise.state() == v8::PromiseState::Pending {
1920                                                    execution::set_pending_script_evaluation(
1921                                                        scope, promise,
1922                                                    );
1923                                                }
1924                                            }
1925                                        }
1926                                    }
1927                                }
1928                            }
1929
1930                            // Phase 2: pump event loop for active handles
1931                            if !pending.is_empty()
1932                                || execution::has_pending_script_evaluation()
1933                                || !deferred_queue.lock().unwrap().is_empty()
1934                            {
1935                                let scope = &mut v8::HandleScope::new(iso);
1936                                let ctx = v8::Local::new(scope, &exec_context);
1937                                let scope = &mut v8::ContextScope::new(scope, ctx);
1938                                let event_loop_status = run_event_loop(
1939                                    scope,
1940                                    &rx,
1941                                    &pending,
1942                                    Some(&abort_rx),
1943                                    Some(&deferred_queue),
1944                                );
1945
1946                                if matches!(event_loop_status, EventLoopStatus::Terminated) {
1947                                    terminated = true;
1948                                }
1949                                if let EventLoopStatus::Failed(next_code, next_error) =
1950                                    event_loop_status
1951                                {
1952                                    code = next_code;
1953                                    error = Some(next_error);
1954                                }
1955                            }
1956                        }
1957
1958                        if !terminated && mode == 0 && error.is_none() {
1959                            let scope = &mut v8::HandleScope::new(iso);
1960                            let ctx = v8::Local::new(scope, &exec_context);
1961                            let scope = &mut v8::ContextScope::new(scope, ctx);
1962                            if let Some((next_code, next_error)) =
1963                                execution::finalize_pending_script_evaluation(scope)
1964                            {
1965                                code = next_code;
1966                                error = next_error;
1967                            }
1968                        }
1969
1970                        // Determine which execution budget (if any) fired. Both the
1971                        // CPU-time budget and the wall-clock backstop can be armed;
1972                        // whichever fired first recorded its abort reason. Prefer the
1973                        // recorded abort reason (first-writer-wins) so the result
1974                        // attributes termination to the guard that actually fired.
1975                        let abort_reason = execution_abort_reason(&execution_abort);
1976                        let wall_clock_timed_out =
1977                            wall_clock_guard.as_ref().is_some_and(|g| g.timed_out())
1978                                || matches!(
1979                                    abort_reason,
1980                                    Some(ExecutionAbortReason::WallClockTimedOut)
1981                                );
1982                        let cpu_budget_exceeded =
1983                            cpu_budget_guard.as_ref().is_some_and(|g| g.exceeded())
1984                                || matches!(
1985                                    abort_reason,
1986                                    Some(ExecutionAbortReason::CpuBudgetExceeded)
1987                                );
1988                        // If both happened to fire, the recorded abort reason is the
1989                        // authoritative first-fired guard; fall back to wall-clock
1990                        // only when no CPU-budget reason was recorded.
1991                        let cpu_budget_exceeded = cpu_budget_exceeded
1992                            && !matches!(
1993                                abort_reason,
1994                                Some(ExecutionAbortReason::WallClockTimedOut)
1995                            );
1996                        let wall_clock_timed_out = wall_clock_timed_out && !cpu_budget_exceeded;
1997
1998                        // Cancel both watchdogs (joins their threads).
1999                        if let Some(ref mut guard) = cpu_budget_guard {
2000                            guard.cancel();
2001                        }
2002                        drop(cpu_budget_guard);
2003                        if let Some(ref mut guard) = wall_clock_guard {
2004                            guard.cancel();
2005                        }
2006                        drop(wall_clock_guard);
2007
2008                        if matches!(abort_reason, Some(ExecutionAbortReason::Terminated)) {
2009                            terminated = true;
2010                            code = 1;
2011                            exports = None;
2012                            error = None;
2013                        }
2014                        if terminated || cpu_budget_exceeded || wall_clock_timed_out {
2015                            iso.cancel_terminate_execution();
2016                        }
2017
2018                        // Send ExecutionResult
2019                        let result_frame = if cpu_budget_exceeded {
2020                            if let Some(budget_ms) = cpu_time_limit_ms {
2021                                let capacity = budget_ms as usize;
2022                                warn_limit_exhausted(TrackedLimit::V8CpuTimeMs, capacity, capacity);
2023                            }
2024                            RuntimeEvent::ExecutionResult {
2025                                session_id,
2026                                exit_code: 1,
2027                                exports: None,
2028                                error: Some(ExecutionErrorBin {
2029                                    error_type: "Error".into(),
2030                                    message: "Script execution exceeded the CPU-time budget \
2031                                         (limits.jsRuntime.cpuTimeLimitMs)"
2032                                        .into(),
2033                                    stack: String::new(),
2034                                    code: "ERR_SCRIPT_CPU_BUDGET_EXCEEDED".into(),
2035                                }),
2036                            }
2037                        } else if wall_clock_timed_out {
2038                            if let Some(limit_ms) = wall_clock_limit_ms {
2039                                let capacity = limit_ms as usize;
2040                                warn_limit_exhausted(
2041                                    TrackedLimit::V8WallClockMs,
2042                                    capacity,
2043                                    capacity,
2044                                );
2045                            }
2046                            RuntimeEvent::ExecutionResult {
2047                                session_id,
2048                                exit_code: 1,
2049                                exports: None,
2050                                error: Some(ExecutionErrorBin {
2051                                    error_type: "Error".into(),
2052                                    message: "Script execution exceeded the wall-clock limit \
2053                                         (limits.jsRuntime.wallClockLimitMs)"
2054                                        .into(),
2055                                    stack: String::new(),
2056                                    code: "ERR_SCRIPT_WALL_CLOCK_EXCEEDED".into(),
2057                                }),
2058                            }
2059                        } else if terminated {
2060                            RuntimeEvent::ExecutionResult {
2061                                session_id,
2062                                exit_code: 1,
2063                                exports: None,
2064                                error: Some(ExecutionErrorBin {
2065                                    error_type: "Error".into(),
2066                                    message: "Execution terminated".into(),
2067                                    stack: String::new(),
2068                                    code: String::new(),
2069                                }),
2070                            }
2071                        } else {
2072                            RuntimeEvent::ExecutionResult {
2073                                session_id,
2074                                exit_code: code,
2075                                exports,
2076                                error: error.map(|e| ExecutionErrorBin {
2077                                    error_type: e.error_type,
2078                                    message: e.message,
2079                                    stack: e.stack,
2080                                    code: e.code.unwrap_or_default(),
2081                                }),
2082                            }
2083                        };
2084
2085                        execution::clear_pending_module_evaluation();
2086                        execution::clear_pending_script_evaluation();
2087                        execution::clear_module_state();
2088
2089                        send_event_with_generation(&event_tx, output_generation, result_frame);
2090                    }
2091                    #[cfg(test)]
2092                    {
2093                        let _ = (mode, file_path, bridge_code, post_restore_script, user_code);
2094                    }
2095                }
2096                SessionMessage::BridgeResponse(_)
2097                | SessionMessage::StreamEvent(_)
2098                | SessionMessage::TerminateExecution => {
2099                    handle_late_session_message(&event_tx, &session_id, output_generation, msg);
2100                }
2101            },
2102        }
2103    }
2104
2105    // Drop V8 resources (only present in non-test mode)
2106    #[cfg(not(test))]
2107    {
2108        *isolate_handle
2109            .lock()
2110            .expect("session isolate handle lock poisoned") = None;
2111        // Reset pending promise-resolver Globals BEFORE the isolate is dropped on
2112        // thread teardown. run_event_loop can exit early (Shutdown / timeout-abort)
2113        // with resolvers still registered, so without this the Globals would drop
2114        // after their isolate — leaking across session create/destroy churn and
2115        // violating the V8 lifetime contract.
2116        reset_pending_promises(&mut pending);
2117        drop(_v8_context.take());
2118        isolate::drop_isolate(v8_isolate.take());
2119    }
2120
2121    // Release concurrency slot
2122    {
2123        let (lock, cvar) = &*slot_control;
2124        let mut count = lock.lock().unwrap();
2125        *count -= 1;
2126        cvar.notify_one();
2127    }
2128}
2129
2130/// Sync bridge functions block V8 while the host processes the call
2131/// (applySync/applySyncPromise). Async bridge functions return a Promise to V8.
2132struct BridgeFnPartitions {
2133    sync: Vec<&'static str>,
2134    async_fns: Vec<&'static str>,
2135}
2136
2137pub(crate) fn sync_bridge_fns() -> &'static [&'static str] {
2138    &bridge_fn_partitions().sync
2139}
2140
2141pub(crate) fn async_bridge_fns() -> &'static [&'static str] {
2142    &bridge_fn_partitions().async_fns
2143}
2144
2145fn bridge_fn_partitions() -> &'static BridgeFnPartitions {
2146    static PARTITIONS: OnceLock<BridgeFnPartitions> = OnceLock::new();
2147    PARTITIONS.get_or_init(|| BridgeFnPartitions {
2148        sync: bridge_fns_for(|convention| {
2149            matches!(
2150                convention,
2151                BridgeCallConvention::Sync | BridgeCallConvention::SyncPromise
2152            )
2153        }),
2154        async_fns: bridge_fns_for(|convention| convention == BridgeCallConvention::Async),
2155    })
2156}
2157
2158fn bridge_fns_for(filter: impl Fn(BridgeCallConvention) -> bool) -> Vec<&'static str> {
2159    bridge_contract()
2160        .groups
2161        .iter()
2162        .filter(|group| filter(group.convention))
2163        .flat_map(|group| group.names.iter().map(String::as_str))
2164        .collect()
2165}
2166
2167/// Reset every pending promise-resolver `v8::Global` handle held by `pending`.
2168///
2169/// `v8::Global` handles MUST be reset/dropped *before* the `v8::Isolate` that
2170/// created them is torn down. The session reuses a single `PendingPromises`
2171/// registry across executions and across isolate rebuilds, and `run_event_loop`
2172/// can exit early (Shutdown at the `SessionCommand::Shutdown` arm, or
2173/// timeout-abort via the `abort_rx` branch) while resolvers are still
2174/// registered. On those paths the registry can outlive an isolate. Call this
2175/// immediately before every isolate drop (rebuild and thread teardown) so the
2176/// `Global<PromiseResolver>` handles are dropped while their isolate is still
2177/// alive — preventing both a leak across session create/destroy churn (bounded
2178/// by `MAX_PENDING_PROMISES`) and a V8 lifetime-contract violation.
2179#[doc(hidden)]
2180pub fn reset_pending_promises(pending: &mut crate::bridge::PendingPromises) {
2181    // Swap in an empty registry and drop the populated one in place. Dropping a
2182    // `PendingPromises` resets all of its `Global<PromiseResolver>` handles.
2183    drop(std::mem::take(pending));
2184}
2185
2186/// Run the session event loop: dispatch incoming messages to V8.
2187///
2188/// Called after script/module execution when there are pending async promises.
2189/// Polls the session channel for BridgeResponse, StreamEvent, and
2190/// TerminateExecution messages, dispatching each into V8 with microtask flush.
2191///
2192/// When `deferred` is provided, drains queued messages from sync bridge calls
2193/// before blocking on the channel. This prevents StreamEvent loss when sync
2194/// bridge calls consume non-BridgeResponse messages from the shared channel.
2195///
2196/// When `abort_rx` is provided (timeout is configured), uses `select!` to
2197/// also monitor the abort channel — if the timeout fires and drops the sender,
2198/// the abort channel unblocks and terminates execution.
2199///
2200/// Returns true if execution completed normally, false if terminated.
2201#[doc(hidden)]
2202pub fn run_event_loop(
2203    scope: &mut v8::HandleScope,
2204    rx: &Receiver<SessionCommand>,
2205    pending: &crate::bridge::PendingPromises,
2206    abort_rx: Option<&crossbeam_channel::Receiver<()>>,
2207    deferred: Option<&DeferredQueue>,
2208) -> EventLoopStatus {
2209    while !pending.is_empty()
2210        || execution::pending_module_evaluation_needs_wait(scope)
2211        || execution::pending_script_evaluation_needs_wait(scope)
2212        || pending_guest_timer_count(scope) > 0
2213        || pending_guest_immediate_count(scope) > 0
2214        || deferred
2215            .map(|dq| !dq.lock().unwrap().is_empty())
2216            .unwrap_or(false)
2217    {
2218        pump_v8_message_loop(scope);
2219
2220        // Drain deferred messages queued by sync bridge calls before blocking
2221        if let Some(dq) = deferred {
2222            let frames: Vec<SessionMessage> = dq.lock().unwrap().drain(..).collect();
2223            for frame in frames {
2224                let status = dispatch_event_loop_frame(scope, frame, pending);
2225                if !matches!(status, EventLoopStatus::Completed) {
2226                    return status;
2227                }
2228            }
2229            if pending.is_empty()
2230                && !execution::pending_module_evaluation_needs_wait(scope)
2231                && !execution::pending_script_evaluation_needs_wait(scope)
2232                && pending_guest_timer_count(scope) == 0
2233                && pending_guest_immediate_count(scope) == 0
2234            {
2235                break;
2236            }
2237        }
2238
2239        // Flush microtasks before blocking. Run in a loop to drain the full
2240        // microtask queue -- each checkpoint may resolve Promises that schedule
2241        // new microtasks (e.g., async function await chains).
2242        for _ in 0..100 {
2243            scope.perform_microtask_checkpoint();
2244            pump_v8_message_loop(scope);
2245            // Check if new deferred work appeared from microtask processing
2246            if let Some(dq) = deferred {
2247                if !dq.lock().unwrap().is_empty() {
2248                    break; // New bridge work to process
2249                }
2250            }
2251        }
2252
2253        if pending_guest_immediate_count(scope) > 0 {
2254            match try_recv_session_command(scope, rx, abort_rx) {
2255                Ok(Some(cmd)) => {
2256                    let status = dispatch_session_command(scope, cmd, pending);
2257                    if !matches!(status, EventLoopStatus::Completed) {
2258                        return status;
2259                    }
2260                }
2261                Ok(None) => {
2262                    let status = drain_guest_immediates(scope);
2263                    if !matches!(status, EventLoopStatus::Completed) {
2264                        return status;
2265                    }
2266                }
2267                Err(status) => return status,
2268            }
2269            scope.perform_microtask_checkpoint();
2270            pump_v8_message_loop(scope);
2271        }
2272
2273        // Re-check exit conditions after microtask flush — the microtask may
2274        // have resolved all pending promises or registered new handles.
2275        if pending.is_empty()
2276            && !execution::pending_module_evaluation_needs_wait(scope)
2277            && !execution::pending_script_evaluation_needs_wait(scope)
2278            && pending_guest_timer_count(scope) == 0
2279            && pending_guest_immediate_count(scope) == 0
2280            && deferred
2281                .map(|dq| dq.lock().unwrap().is_empty())
2282                .unwrap_or(true)
2283        {
2284            break;
2285        }
2286
2287        // Receive next command with interleaved microtask processing.
2288        // Instead of blocking indefinitely, use a short timeout so we can
2289        // periodically flush microtasks (like Node.js's libuv + DrainTasks pattern).
2290        let cmd = loop {
2291            if pending_guest_immediate_count(scope) > 0 {
2292                match try_recv_session_command(scope, rx, abort_rx) {
2293                    Ok(Some(cmd)) => break cmd,
2294                    Ok(None) => {
2295                        let status = drain_guest_immediates(scope);
2296                        if !matches!(status, EventLoopStatus::Completed) {
2297                            return status;
2298                        }
2299                        scope.perform_microtask_checkpoint();
2300                        pump_v8_message_loop(scope);
2301                        continue;
2302                    }
2303                    Err(status) => return status,
2304                }
2305            }
2306            let recv_result = if let Some(abort) = abort_rx {
2307                crossbeam_channel::select! {
2308                    recv(rx) -> result => result.ok(),
2309                    recv(abort) -> _ => {
2310                        scope.terminate_execution();
2311                        return EventLoopStatus::Terminated;
2312                    },
2313                    default(std::time::Duration::from_millis(1)) => None,
2314                }
2315            } else {
2316                rx.recv_timeout(std::time::Duration::from_millis(1)).ok()
2317            };
2318            if let Some(cmd) = recv_result {
2319                break cmd;
2320            }
2321            // No command received — flush microtasks and check deferred queue
2322            scope.perform_microtask_checkpoint();
2323            pump_v8_message_loop(scope);
2324            if let Some(dq) = deferred {
2325                if !dq.lock().unwrap().is_empty() {
2326                    // New deferred work appeared — drain it in the outer loop
2327                    let frames: Vec<SessionMessage> = dq.lock().unwrap().drain(..).collect();
2328                    for frame in frames {
2329                        let status = dispatch_event_loop_frame(scope, frame, pending);
2330                        if !matches!(status, EventLoopStatus::Completed) {
2331                            return status;
2332                        }
2333                    }
2334                }
2335            }
2336            // Check if we should exit
2337            if pending.is_empty()
2338                && !execution::pending_module_evaluation_needs_wait(scope)
2339                && !execution::pending_script_evaluation_needs_wait(scope)
2340                && pending_guest_timer_count(scope) == 0
2341                && pending_guest_immediate_count(scope) == 0
2342                && deferred
2343                    .map(|dq| dq.lock().unwrap().is_empty())
2344                    .unwrap_or(true)
2345            {
2346                return EventLoopStatus::Completed;
2347            }
2348        };
2349
2350        let status = dispatch_session_command(scope, cmd, pending);
2351        if !matches!(status, EventLoopStatus::Completed) {
2352            return status;
2353        }
2354    }
2355    EventLoopStatus::Completed
2356}
2357
2358fn try_recv_session_command(
2359    scope: &mut v8::HandleScope,
2360    rx: &Receiver<SessionCommand>,
2361    abort_rx: Option<&crossbeam_channel::Receiver<()>>,
2362) -> Result<Option<SessionCommand>, EventLoopStatus> {
2363    if let Some(abort) = abort_rx {
2364        crossbeam_channel::select! {
2365            recv(abort) -> _ => {
2366                scope.terminate_execution();
2367                Err(EventLoopStatus::Terminated)
2368            },
2369            recv(rx) -> result => Ok(result.ok()),
2370            default => Ok(None),
2371        }
2372    } else {
2373        match rx.try_recv() {
2374            Ok(cmd) => Ok(Some(cmd)),
2375            Err(crossbeam_channel::TryRecvError::Empty) => Ok(None),
2376            Err(crossbeam_channel::TryRecvError::Disconnected) => Ok(None),
2377        }
2378    }
2379}
2380
2381fn dispatch_session_command(
2382    scope: &mut v8::HandleScope,
2383    cmd: SessionCommand,
2384    pending: &crate::bridge::PendingPromises,
2385) -> EventLoopStatus {
2386    match cmd {
2387        SessionCommand::Message(frame) => dispatch_event_loop_frame(scope, frame, pending),
2388        SessionCommand::SetModuleReader(reader) => {
2389            execution::install_session_guest_reader(Some(reader));
2390            EventLoopStatus::Completed
2391        }
2392        SessionCommand::Shutdown => EventLoopStatus::Terminated,
2393    }
2394}
2395
2396fn pending_guest_timer_count(scope: &mut v8::HandleScope) -> usize {
2397    let tc = &mut v8::TryCatch::new(scope);
2398    let context = tc.get_current_context();
2399    let global = context.global(tc);
2400    let key = match v8::String::new(tc, "_getPendingTimerCount") {
2401        Some(key) => key,
2402        None => return 0,
2403    };
2404    let Some(func_value) = global.get(tc, key.into()) else {
2405        return 0;
2406    };
2407    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
2408        return 0;
2409    };
2410    let Some(result) = func.call(tc, global.into(), &[]) else {
2411        return 0;
2412    };
2413
2414    result
2415        .integer_value(tc)
2416        .and_then(|count| usize::try_from(count).ok())
2417        .unwrap_or(0)
2418}
2419
2420fn pending_guest_immediate_count(scope: &mut v8::HandleScope) -> usize {
2421    let tc = &mut v8::TryCatch::new(scope);
2422    let context = tc.get_current_context();
2423    let global = context.global(tc);
2424    let key = match v8::String::new(tc, "_getPendingImmediateCount") {
2425        Some(key) => key,
2426        None => return 0,
2427    };
2428    let Some(func_value) = global.get(tc, key.into()) else {
2429        return 0;
2430    };
2431    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
2432        return 0;
2433    };
2434    let Some(result) = func.call(tc, global.into(), &[]) else {
2435        return 0;
2436    };
2437
2438    result
2439        .integer_value(tc)
2440        .and_then(|count| usize::try_from(count).ok())
2441        .unwrap_or(0)
2442}
2443
2444fn drain_guest_immediates(scope: &mut v8::HandleScope) -> EventLoopStatus {
2445    let tc = &mut v8::TryCatch::new(scope);
2446    let context = tc.get_current_context();
2447    let global = context.global(tc);
2448    let key = match v8::String::new(tc, "_drainImmediates") {
2449        Some(key) => key,
2450        None => return EventLoopStatus::Completed,
2451    };
2452    let Some(func_value) = global.get(tc, key.into()) else {
2453        return EventLoopStatus::Completed;
2454    };
2455    let Ok(func) = v8::Local::<v8::Function>::try_from(func_value) else {
2456        return EventLoopStatus::Completed;
2457    };
2458    let _ = func.call(tc, global.into(), &[]);
2459    tc.perform_microtask_checkpoint();
2460    if let Some(exception) = tc.exception() {
2461        let (code, err) = execution::exception_to_result(tc, exception);
2462        return EventLoopStatus::Failed(code, err);
2463    }
2464    if let Some(err) = execution::take_unhandled_promise_rejection(tc) {
2465        return EventLoopStatus::Failed(1, err);
2466    }
2467    EventLoopStatus::Completed
2468}
2469
2470fn pump_v8_message_loop(scope: &mut v8::HandleScope) {
2471    let platform = v8::V8::get_current_platform();
2472    while v8::Platform::pump_message_loop(&platform, scope, false) {
2473        scope.perform_microtask_checkpoint();
2474    }
2475}
2476
2477/// Dispatch a single session message within the event loop.
2478/// Returns the event-loop status after handling the frame.
2479#[derive(Debug)]
2480#[doc(hidden)]
2481pub enum EventLoopStatus {
2482    Completed,
2483    Terminated,
2484    Failed(i32, ExecutionError),
2485}
2486
2487fn dispatch_event_loop_frame(
2488    scope: &mut v8::HandleScope,
2489    frame: SessionMessage,
2490    pending: &crate::bridge::PendingPromises,
2491) -> EventLoopStatus {
2492    match frame {
2493        SessionMessage::BridgeResponse(BridgeResponse {
2494            call_id,
2495            status,
2496            payload,
2497        }) => {
2498            let (result, error) = if status == 1 {
2499                (None, Some(String::from_utf8_lossy(&payload).to_string()))
2500            } else if status == 2 || !payload.is_empty() {
2501                // status=0: V8-serialized, status=2: raw binary (Uint8Array)
2502                (Some(payload), None)
2503            } else {
2504                (None, None)
2505            };
2506            let _ = crate::bridge::resolve_pending_promise(
2507                scope, pending, call_id, status, result, error,
2508            );
2509            // Microtasks already flushed in resolve_pending_promise
2510            EventLoopStatus::Completed
2511        }
2512        SessionMessage::StreamEvent(StreamEvent {
2513            event_type,
2514            payload,
2515        }) => {
2516            let tc = &mut v8::TryCatch::new(scope);
2517            crate::stream::dispatch_stream_event(tc, &event_type, &payload);
2518            tc.perform_microtask_checkpoint();
2519            if let Some(exception) = tc.exception() {
2520                let (code, err) = execution::exception_to_result(tc, exception);
2521                return EventLoopStatus::Failed(code, err);
2522            }
2523            if let Some(err) = execution::take_unhandled_promise_rejection(tc) {
2524                return EventLoopStatus::Failed(1, err);
2525            }
2526            EventLoopStatus::Completed
2527        }
2528        SessionMessage::TerminateExecution => {
2529            scope.terminate_execution();
2530            EventLoopStatus::Terminated
2531        }
2532        _ => {
2533            // Ignore other messages during event loop
2534            EventLoopStatus::Completed
2535        }
2536    }
2537}
2538
2539/// ResponseReceiver that receives typed session messages directly from the session channel.
2540///
2541/// Only returns BridgeResponse frames from recv_response(). Non-BridgeResponse
2542/// messages (StreamEvent, TerminateExecution) consumed during sync bridge calls
2543/// are queued in the deferred queue for later processing by the event loop.
2544///
2545/// When `abort_rx` is set (timeout configured), uses `select!` to also monitor
2546/// the abort channel. If the timeout fires, the abort sender is dropped, which
2547/// unblocks the select and returns a timeout error.
2548pub(crate) struct ChannelResponseReceiver {
2549    rx: Receiver<SessionCommand>,
2550    abort_rx: Option<crossbeam_channel::Receiver<()>>,
2551    deferred: DeferredQueue,
2552}
2553
2554impl ChannelResponseReceiver {
2555    #[allow(dead_code)]
2556    pub(crate) fn new(rx: Receiver<SessionCommand>, deferred: DeferredQueue) -> Self {
2557        ChannelResponseReceiver {
2558            rx,
2559            abort_rx: None,
2560            deferred,
2561        }
2562    }
2563
2564    pub(crate) fn with_abort(
2565        rx: Receiver<SessionCommand>,
2566        abort_rx: crossbeam_channel::Receiver<()>,
2567        deferred: DeferredQueue,
2568    ) -> Self {
2569        ChannelResponseReceiver {
2570            rx,
2571            abort_rx: Some(abort_rx),
2572            deferred,
2573        }
2574    }
2575}
2576
2577impl crate::host_call::BridgeResponseReceiver for ChannelResponseReceiver {
2578    fn recv_response(&self, expected_call_id: u64) -> Result<BridgeResponse, String> {
2579        loop {
2580            // Wait for next command, with optional abort monitoring
2581            let cmd = if let Some(ref abort) = self.abort_rx {
2582                crossbeam_channel::select! {
2583                    recv(self.rx) -> result => match result {
2584                        Ok(cmd) => cmd,
2585                        Err(_) => return Err("channel closed".into()),
2586                    },
2587                    recv(abort) -> _ => {
2588                        return Err("execution aborted".into());
2589                    },
2590                }
2591            } else {
2592                match self.rx.recv() {
2593                    Ok(cmd) => cmd,
2594                    Err(_) => return Err("channel closed".into()),
2595                }
2596            };
2597
2598            match cmd {
2599                SessionCommand::Message(frame) => {
2600                    if let SessionMessage::BridgeResponse(response) = &frame {
2601                        let call_id = response.call_id;
2602                        if call_id == expected_call_id {
2603                            crate::host_call::record_sync_bridge_response_channel_received(call_id);
2604                            return Ok(response.clone());
2605                        }
2606                        push_deferred_sync_message(&self.deferred, frame)?;
2607                        continue;
2608                    }
2609                    // Queue non-BridgeResponse for later event loop processing
2610                    push_deferred_sync_message(&self.deferred, frame)?;
2611                }
2612                SessionCommand::SetModuleReader(reader) => {
2613                    execution::install_session_guest_reader(Some(reader));
2614                }
2615                SessionCommand::Shutdown => return Err("session shutdown".into()),
2616            }
2617        }
2618    }
2619}
2620
2621fn push_deferred_sync_message(
2622    deferred: &DeferredQueue,
2623    frame: SessionMessage,
2624) -> Result<(), String> {
2625    let mut queue = deferred.lock().unwrap();
2626    if queue.len() >= MAX_DEFERRED_SYNC_MESSAGES {
2627        return Err(format!(
2628            "sync bridge deferred message queue exceeded limit of {MAX_DEFERRED_SYNC_MESSAGES}"
2629        ));
2630    }
2631    queue.push_back(frame);
2632    Ok(())
2633}
2634
2635#[cfg(test)]
2636mod tests {
2637    use super::*;
2638    use std::collections::{HashMap, HashSet};
2639
2640    /// Helper to create a SessionManager for tests
2641    fn test_manager(max: usize) -> SessionManager {
2642        test_manager_with_events(max).0
2643    }
2644
2645    fn test_manager_with_events(max: usize) -> (SessionManager, Receiver<RuntimeEventEnvelope>) {
2646        let (tx, _rx) = crossbeam_channel::unbounded();
2647        let router: CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
2648        let snap_cache = Arc::new(SnapshotCache::new(4));
2649        let manager = SessionManager::new(max, tx, router, snap_cache);
2650        (manager, _rx)
2651    }
2652
2653    #[test]
2654    fn zero_cpu_time_limit_is_normalized_to_no_timeout() {
2655        assert_eq!(normalize_cpu_time_limit_ms(None), None);
2656        assert_eq!(normalize_cpu_time_limit_ms(Some(0)), None);
2657        assert_eq!(normalize_cpu_time_limit_ms(Some(1)), Some(1));
2658    }
2659
2660    fn expect_late_message_warning(
2661        rx: &Receiver<RuntimeEventEnvelope>,
2662        session_id: &str,
2663        error_code: &str,
2664        detail_fragment: &str,
2665    ) {
2666        let event = rx
2667            .recv_timeout(std::time::Duration::from_millis(200))
2668            .expect("late-message warning");
2669        match event.event {
2670            RuntimeEvent::Log {
2671                session_id: observed_session_id,
2672                channel,
2673                message,
2674            } => {
2675                assert_eq!(observed_session_id, session_id);
2676                assert_eq!(channel, 1, "late warnings should use stderr channel");
2677                assert!(
2678                    message.contains(error_code),
2679                    "warning should contain error code {error_code}, got {message}"
2680                );
2681                assert!(
2682                    message.contains(detail_fragment),
2683                    "warning should mention {detail_fragment}, got {message}"
2684                );
2685            }
2686            other => panic!("expected late-message warning log, got {other:?}"),
2687        }
2688    }
2689
2690    #[test]
2691    fn bridge_contract_function_partitions_cover_contract() {
2692        let contract = bridge_contract();
2693
2694        let expected_sync = contract
2695            .groups
2696            .iter()
2697            .filter(|group| {
2698                matches!(
2699                    group.convention,
2700                    BridgeCallConvention::Sync | BridgeCallConvention::SyncPromise
2701                )
2702            })
2703            .flat_map(|group| group.names.iter().map(String::as_str))
2704            .collect::<HashSet<_>>();
2705        let expected_async = contract
2706            .groups
2707            .iter()
2708            .filter(|group| group.convention == BridgeCallConvention::Async)
2709            .flat_map(|group| group.names.iter().map(String::as_str))
2710            .collect::<HashSet<_>>();
2711
2712        let sync_names = sync_bridge_fns();
2713        let async_names = async_bridge_fns();
2714        let registered_sync = sync_names.iter().copied().collect::<HashSet<_>>();
2715        let registered_async = async_names.iter().copied().collect::<HashSet<_>>();
2716
2717        assert_eq!(
2718            registered_sync, expected_sync,
2719            "sync bridge function partition drifted from crates/bridge/bridge-contract.json"
2720        );
2721        assert_eq!(
2722            registered_async, expected_async,
2723            "async bridge function partition drifted from crates/bridge/bridge-contract.json"
2724        );
2725        assert!(
2726            registered_sync.is_disjoint(&registered_async),
2727            "sync and async bridge function partitions must not overlap"
2728        );
2729    }
2730
2731    #[test]
2732    fn session_management() {
2733        // Consolidated test to avoid V8 inter-test SIGSEGV issues.
2734        // Covers: lifecycle and concurrency queuing.
2735
2736        // --- Part 1: Single session create/destroy ---
2737        {
2738            let mut mgr = test_manager(4);
2739
2740            mgr.create_session("session-aaa".into(), None, None, None)
2741                .expect("create session A");
2742            assert_eq!(mgr.session_count(), 1);
2743
2744            // Wait for thread to acquire slot and create isolate
2745            std::thread::sleep(std::time::Duration::from_millis(200));
2746
2747            // Destroy session A
2748            mgr.destroy_session("session-aaa")
2749                .expect("destroy session A");
2750            assert_eq!(mgr.session_count(), 0);
2751        }
2752
2753        // --- Part 2: Multiple sessions ---
2754        {
2755            let mut mgr = test_manager(4);
2756
2757            mgr.create_session("session-bbb".into(), None, None, None)
2758                .expect("create session B");
2759            mgr.create_session("session-ccc".into(), Some(16), None, None)
2760                .expect("create session C");
2761            assert_eq!(mgr.session_count(), 2);
2762
2763            std::thread::sleep(std::time::Duration::from_millis(200));
2764
2765            // Duplicate session ID is rejected
2766            let err = mgr.create_session("session-bbb".into(), None, None, None);
2767            assert!(err.is_err());
2768            assert!(err.unwrap_err().contains("already exists"));
2769
2770            // Sending to a missing session still fails.
2771            let err = mgr.send_to_session("missing", SessionMessage::TerminateExecution);
2772            assert!(err.is_err());
2773            assert!(err.unwrap_err().contains("does not exist"));
2774
2775            // Destroy non-existent session
2776            let err = mgr.destroy_session("no-such-session");
2777            assert!(err.is_err());
2778            assert!(err.unwrap_err().contains("does not exist"));
2779
2780            mgr.destroy_sessions(["session-bbb".into(), "session-ccc".into()]);
2781            assert_eq!(mgr.session_count(), 0);
2782        }
2783
2784        // --- Part 3: Max concurrency queuing ---
2785        {
2786            let mut mgr = test_manager(2);
2787
2788            mgr.create_session("s1".into(), None, None, None)
2789                .expect("create s1");
2790            mgr.create_session("s2".into(), None, None, None)
2791                .expect("create s2");
2792            mgr.create_session("s3".into(), None, None, None)
2793                .expect("create s3");
2794
2795            // Allow threads to acquire slots
2796            std::thread::sleep(std::time::Duration::from_millis(300));
2797
2798            // Only 2 slots active (s3 is queued)
2799            assert_eq!(mgr.active_slot_count(), 2);
2800            assert_eq!(mgr.session_count(), 3);
2801
2802            // Destroy s1 — releases slot, s3 acquires it
2803            mgr.destroy_session("s1").expect("destroy s1");
2804            std::thread::sleep(std::time::Duration::from_millis(300));
2805            assert_eq!(mgr.active_slot_count(), 2);
2806            assert_eq!(mgr.session_count(), 2);
2807
2808            // Destroy remaining
2809            mgr.destroy_sessions(["s2".into(), "s3".into()]);
2810            std::thread::sleep(std::time::Duration::from_millis(100));
2811            assert_eq!(mgr.session_count(), 0);
2812            assert_eq!(mgr.active_slot_count(), 0);
2813        }
2814    }
2815
2816    #[test]
2817    fn detach_session_clears_call_id_routes_for_session() {
2818        let mut mgr = test_manager(1);
2819        mgr.create_session_with_output_generation(
2820            "session-route".into(),
2821            None,
2822            None,
2823            None,
2824            Some(7),
2825            None,
2826        )
2827        .expect("create session");
2828        mgr.call_id_router()
2829            .lock()
2830            .expect("call_id router")
2831            .insert(42, "session-route".into());
2832
2833        assert!(
2834            mgr.detach_session_if_output_generation("session-route", 7)
2835                .expect("detach session"),
2836            "matching output generation should detach session"
2837        );
2838        assert!(
2839            mgr.call_id_router()
2840                .lock()
2841                .expect("call_id router")
2842                .get(&42)
2843                .is_none(),
2844            "detach should clear stale bridge call routes for the session"
2845        );
2846    }
2847
2848    #[test]
2849    fn begin_destroy_session_removes_entry_before_finish() {
2850        let mut mgr = test_manager(1);
2851        mgr.create_session("two-phase".into(), None, None, None)
2852            .expect("create session");
2853
2854        let first_shutdown = mgr
2855            .begin_destroy_session("two-phase")
2856            .expect("begin destroy session");
2857        assert_eq!(
2858            mgr.session_count(),
2859            0,
2860            "entry should be removed before the shutdown is finished"
2861        );
2862
2863        // A same-id create during the unfinished shutdown window must succeed
2864        // because the entry was removed up front.
2865        mgr.create_session("two-phase".into(), None, None, None)
2866            .expect("re-create session while first shutdown is unfinished");
2867
2868        let second_shutdown = mgr
2869            .begin_destroy_session("two-phase")
2870            .expect("begin destroy re-created session");
2871        first_shutdown.finish();
2872        second_shutdown.finish();
2873        assert_eq!(mgr.session_count(), 0);
2874    }
2875
2876    #[test]
2877    fn session_shutdown_finish_clears_late_call_routes() {
2878        let mut mgr = test_manager(1);
2879        mgr.create_session("late-route".into(), None, None, None)
2880            .expect("create session");
2881
2882        let shutdown = mgr
2883            .begin_destroy_session("late-route")
2884            .expect("begin destroy session");
2885        // Simulate a route the session thread registered between the pre-join
2886        // route clear and thread exit.
2887        mgr.call_id_router()
2888            .lock()
2889            .expect("call_id router")
2890            .insert(42, "late-route".into());
2891
2892        shutdown.finish();
2893        assert!(
2894            mgr.call_id_router()
2895                .lock()
2896                .expect("call_id router")
2897                .get(&42)
2898                .is_none(),
2899            "finish should clear call routes registered during shutdown"
2900        );
2901    }
2902
2903    #[test]
2904    fn channel_response_receiver_filters_bridge_response() {
2905        use crate::host_call::BridgeResponseReceiver;
2906
2907        // Sync bridge call interleaved with StreamEvent does not drop the StreamEvent
2908        let (tx, rx) = crossbeam_channel::bounded(10);
2909        let deferred = new_deferred_queue();
2910        let receiver = ChannelResponseReceiver::new(rx, Arc::clone(&deferred));
2911
2912        // Send: StreamEvent, TerminateExecution, then BridgeResponse
2913        tx.send(SessionCommand::Message(SessionMessage::StreamEvent(
2914            StreamEvent {
2915                event_type: "child_stdout".into(),
2916                payload: vec![0x01, 0x02],
2917            },
2918        )))
2919        .unwrap();
2920        tx.send(SessionCommand::Message(SessionMessage::TerminateExecution))
2921            .unwrap();
2922        tx.send(SessionCommand::Message(SessionMessage::BridgeResponse(
2923            BridgeResponse {
2924                call_id: 1,
2925                status: 0,
2926                payload: vec![0xAB],
2927            },
2928        )))
2929        .unwrap();
2930
2931        // recv_response should skip StreamEvent and TerminateExecution, return BridgeResponse
2932        let frame = receiver.recv_response(1).unwrap();
2933        assert!(
2934            frame.call_id == 1,
2935            "expected BridgeResponse with call_id=1, got {:?}",
2936            frame
2937        );
2938
2939        // Deferred queue should contain the StreamEvent and TerminateExecution
2940        let dq = deferred.lock().unwrap();
2941        assert_eq!(dq.len(), 2, "expected 2 deferred messages");
2942        assert!(
2943            matches!(&dq[0], SessionMessage::StreamEvent(StreamEvent { event_type, .. }) if event_type == "child_stdout"),
2944            "first deferred should be StreamEvent"
2945        );
2946        assert!(
2947            matches!(&dq[1], SessionMessage::TerminateExecution),
2948            "second deferred should be TerminateExecution"
2949        );
2950    }
2951
2952    #[test]
2953    fn channel_response_receiver_rejects_deferred_queue_overflow() {
2954        use crate::host_call::BridgeResponseReceiver;
2955
2956        let (tx, rx) = crossbeam_channel::bounded(MAX_DEFERRED_SYNC_MESSAGES + 1);
2957        let deferred = new_deferred_queue();
2958        let receiver = ChannelResponseReceiver::new(rx, Arc::clone(&deferred));
2959
2960        for index in 0..=MAX_DEFERRED_SYNC_MESSAGES {
2961            tx.send(SessionCommand::Message(SessionMessage::StreamEvent(
2962                StreamEvent {
2963                    event_type: format!("child_stdout_{index}"),
2964                    payload: Vec::new(),
2965                },
2966            )))
2967            .unwrap();
2968        }
2969
2970        let error = receiver
2971            .recv_response(1)
2972            .expect_err("deferred queue overflow should reject sync bridge wait");
2973        assert!(error.contains("deferred message queue exceeded limit"));
2974        assert_eq!(deferred.lock().unwrap().len(), MAX_DEFERRED_SYNC_MESSAGES);
2975    }
2976
2977    #[test]
2978    fn pre_slot_deferred_command_overflow_is_bounded_and_logged() {
2979        let (event_tx, event_rx) = crossbeam_channel::unbounded();
2980        let mut deferred_commands = VecDeque::new();
2981
2982        for _ in 0..MAX_DEFERRED_SESSION_COMMANDS {
2983            assert!(defer_session_command_before_slot(
2984                &mut deferred_commands,
2985                &event_tx,
2986                "queued-session",
2987                Some(3),
2988                SessionCommand::Message(SessionMessage::TerminateExecution),
2989            ));
2990        }
2991
2992        assert!(!defer_session_command_before_slot(
2993            &mut deferred_commands,
2994            &event_tx,
2995            "queued-session",
2996            Some(3),
2997            SessionCommand::Message(SessionMessage::TerminateExecution),
2998        ));
2999        assert_eq!(deferred_commands.len(), MAX_DEFERRED_SESSION_COMMANDS);
3000
3001        let warning = event_rx.recv().expect("overflow warning");
3002        assert_eq!(warning.output_generation, Some(3));
3003        match warning.event {
3004            RuntimeEvent::Log {
3005                session_id,
3006                channel,
3007                message,
3008            } => {
3009                assert_eq!(session_id, "queued-session");
3010                assert_eq!(channel, 1);
3011                assert!(message.contains(DEFERRED_COMMAND_LIMIT_ERROR_CODE));
3012            }
3013            other => panic!("expected overflow warning log, got {other:?}"),
3014        }
3015    }
3016
3017    #[test]
3018    fn late_terminate_execution_is_logged_instead_of_silently_dropped() {
3019        let (mut mgr, rx) = test_manager_with_events(1);
3020        mgr.create_session("late-terminate".into(), None, None, None)
3021            .expect("create session");
3022
3023        mgr.send_to_session("late-terminate", SessionMessage::TerminateExecution)
3024            .expect("send late terminate");
3025
3026        expect_late_message_warning(
3027            &rx,
3028            "late-terminate",
3029            LATE_TERMINATE_EXECUTION_ERROR_CODE,
3030            "TerminateExecution",
3031        );
3032
3033        mgr.destroy_session("late-terminate")
3034            .expect("destroy session");
3035    }
3036
3037    #[test]
3038    fn channel_response_receiver_abort_unblocks_waiting_sync_call() {
3039        use crate::host_call::BridgeResponseReceiver;
3040
3041        let (_tx, rx) = crossbeam_channel::bounded(1);
3042        let deferred = new_deferred_queue();
3043        let execution_abort = new_execution_abort();
3044        let (_active_abort, abort_rx) = ActiveExecutionAbort::arm(&execution_abort);
3045        let receiver = ChannelResponseReceiver::with_abort(rx, abort_rx, deferred);
3046
3047        let (result_tx, result_rx) = std::sync::mpsc::channel();
3048        let join_handle = std::thread::spawn(move || {
3049            let _ = result_tx.send(receiver.recv_response(1));
3050        });
3051
3052        std::thread::sleep(std::time::Duration::from_millis(25));
3053        signal_execution_abort(&execution_abort, ExecutionAbortReason::Terminated);
3054
3055        let result = result_rx
3056            .recv_timeout(std::time::Duration::from_secs(1))
3057            .expect("abort should unblock the waiting receiver");
3058        assert_eq!(
3059            result.expect_err("abort should not yield a bridge response"),
3060            "execution aborted"
3061        );
3062
3063        join_handle
3064            .join()
3065            .expect("receiver thread should exit cleanly");
3066    }
3067
3068    #[test]
3069    fn late_bridge_response_is_logged_instead_of_silently_dropped() {
3070        let (mut mgr, rx) = test_manager_with_events(1);
3071        mgr.create_session("late-bridge".into(), None, None, None)
3072            .expect("create session");
3073
3074        mgr.send_to_session(
3075            "late-bridge",
3076            SessionMessage::BridgeResponse(BridgeResponse {
3077                call_id: 41,
3078                status: 0,
3079                payload: vec![0xAA, 0xBB],
3080            }),
3081        )
3082        .expect("send late bridge response");
3083
3084        expect_late_message_warning(
3085            &rx,
3086            "late-bridge",
3087            LATE_BRIDGE_RESPONSE_ERROR_CODE,
3088            "BridgeResponse",
3089        );
3090
3091        mgr.destroy_session("late-bridge").expect("destroy session");
3092    }
3093
3094    /// Regression test for the pending-promise-resolver leak / V8 lifetime-contract
3095    /// violation: when `run_event_loop` exits early (Shutdown or timeout-abort) the
3096    /// `PendingPromises` registry can still hold `Global<PromiseResolver>` handles,
3097    /// and the session-thread teardown must reset them *before* dropping the isolate.
3098    ///
3099    /// This drives the real cleanup seam (`reset_pending_promises`) used on every
3100    /// isolate-drop path. It populates the registry with live resolver Globals (as a
3101    /// terminated execution would leave behind), runs the cleanup while the isolate
3102    /// is still alive, and asserts the registry is empty (every Global dropped).
3103    ///
3104    /// Fast + bounded (a handful of resolvers, then the safeguard fires) — it asserts
3105    /// the cleanup happens, it does not saturate `MAX_PENDING_PROMISES`.
3106    #[test]
3107    fn reset_pending_promises_drops_resolver_globals_before_isolate_teardown() {
3108        use crate::bridge::{register_async_bridge_fns, PendingPromises};
3109        use crate::host_call::BridgeCallContext;
3110        use crate::isolate;
3111        use std::process::Command;
3112
3113        // V8 isolates must be created in an isolated process: doing it inline in a
3114        // parallel `cargo test` thread races the process-global V8 platform and
3115        // segfaults. Re-exec this one test as a subprocess (matching the crate's
3116        // bridge_v8_hardening_* / vm_context_registry convention).
3117        const SUBPROCESS_ENV: &str = "AGENTOS_V8_RESET_PENDING_PROMISES_SUBPROCESS";
3118        if std::env::var_os(SUBPROCESS_ENV).is_none() {
3119            let output = Command::new(std::env::current_exe().expect("current test binary"))
3120                .arg("session::tests::reset_pending_promises_drops_resolver_globals_before_isolate_teardown")
3121                .arg("--exact")
3122                .arg("--nocapture")
3123                .env(SUBPROCESS_ENV, "1")
3124                .output()
3125                .expect("spawn reset-pending-promises subprocess");
3126            assert!(
3127                output.status.success(),
3128                "reset-pending-promises subprocess failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
3129                output.status.code(),
3130                String::from_utf8_lossy(&output.stdout),
3131                String::from_utf8_lossy(&output.stderr)
3132            );
3133            return;
3134        }
3135
3136        isolate::init_v8_platform();
3137
3138        let mut v8_isolate = isolate::create_isolate(None);
3139        let context = isolate::create_context(&mut v8_isolate);
3140        let scope = &mut v8::HandleScope::new(&mut v8_isolate);
3141        let context = v8::Local::new(scope, &context);
3142        let scope = &mut v8::ContextScope::new(scope, context);
3143
3144        let bridge_ctx = BridgeCallContext::new(
3145            Box::new(std::io::sink()),
3146            Box::new(std::io::empty()),
3147            String::from("reset-pending-test"),
3148        );
3149        let mut pending = PendingPromises::new();
3150
3151        // Each `_asyncFn(i)` call synchronously registers a pending promise
3152        // resolver Global in `pending` and returns an unresolved Promise —
3153        // exactly what remains registered when the event loop exits early on
3154        // Shutdown / timeout-abort.
3155        const REGISTERED: usize = 8;
3156        let _async_fns = register_async_bridge_fns(
3157            scope,
3158            &bridge_ctx as *const BridgeCallContext,
3159            &pending as *const PendingPromises,
3160            &["_asyncFn"],
3161        );
3162        let source = format!("for (let i = 0; i < {REGISTERED}; i++) {{ _asyncFn(i); }}");
3163        {
3164            let tc = &mut v8::TryCatch::new(scope);
3165            let code = v8::String::new(tc, &source).unwrap();
3166            let script = v8::Script::compile(tc, code, None).unwrap();
3167            assert!(
3168                script.run(tc).is_some(),
3169                "async bridge calls should register resolvers, not throw"
3170            );
3171            assert!(!tc.has_caught(), "async bridge calls should not throw");
3172        }
3173        assert_eq!(
3174            pending.len(),
3175            REGISTERED,
3176            "each _asyncFn call must register a pending resolver Global"
3177        );
3178
3179        // The cleanup invoked on every session-thread isolate-drop path. It must
3180        // empty the registry (resetting every Global<PromiseResolver>) while the
3181        // isolate is still alive.
3182        reset_pending_promises(&mut pending);
3183
3184        assert_eq!(
3185            pending.len(),
3186            0,
3187            "reset_pending_promises must drop all pending resolver Globals before isolate teardown"
3188        );
3189
3190        // Isolate is still alive here: the Globals were reset above, so dropping
3191        // the scope/isolate below honors the V8 lifetime contract.
3192    }
3193}