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