Skip to main content

agentos_v8_runtime/
host_call.rs

1// Sync-blocking bridge call: serialize, write to socket, block on read, deserialize
2
3use std::cell::RefCell;
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::io::{Read, Write};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use crate::ipc_binary::{self, BinaryFrame};
11use crate::runtime_protocol::{BridgeResponse, RuntimeEvent};
12use crate::session::RuntimeEventEnvelope;
13
14// ── Sync bridge-call round-trip latency (opt-in via AGENTOS_SYNCRPC_LAT=1) ──
15// Measures the guest-observed cost of one host_call round trip (send + block on
16// response). If this is ~ms per call, the embedded-V8 IPC floor is a big part of
17// the evaluate-phase VM tax (~50 fs sync RPCs during SDK init). Writes running
18// (count, total_us, max_us) to AGENTOS_SYNCRPC_LAT_FILE.
19static SYNCRPC_LAT: std::sync::OnceLock<std::sync::Mutex<(u64, u64, u64)>> =
20    std::sync::OnceLock::new();
21
22fn syncrpc_lat_enabled() -> bool {
23    std::env::var("AGENTOS_SYNCRPC_LAT").as_deref() == Ok("1")
24}
25
26fn record_syncrpc_lat(ns: u64) {
27    let m = SYNCRPC_LAT.get_or_init(|| std::sync::Mutex::new((0, 0, 0)));
28    let Ok(mut a) = m.lock() else {
29        return;
30    };
31    a.0 += 1;
32    a.1 = a.1.wrapping_add(ns / 1000);
33    a.2 = a.2.max(ns / 1000);
34    if a.0 % 25 == 0 {
35        if let Ok(path) = std::env::var("AGENTOS_SYNCRPC_LAT_FILE") {
36            let _ = std::fs::write(
37                &path,
38                format!(
39                    "calls={} total_us={} avg_us={} max_us={}\n",
40                    a.0,
41                    a.1,
42                    a.1 / a.0,
43                    a.2
44                ),
45            );
46        }
47    }
48}
49
50#[derive(Debug, Default, Clone)]
51struct SyncBridgeHostPhaseStats {
52    calls: u64,
53    total_us: u64,
54    max_us: u64,
55}
56
57static SYNC_BRIDGE_HOST_PHASES: std::sync::OnceLock<
58    std::sync::Mutex<BTreeMap<String, SyncBridgeHostPhaseStats>>,
59> = std::sync::OnceLock::new();
60static SYNC_BRIDGE_CALL_METHODS: std::sync::OnceLock<std::sync::Mutex<HashMap<u64, String>>> =
61    std::sync::OnceLock::new();
62static SYNC_BRIDGE_RESPONSE_CHANNEL_SENDS: std::sync::OnceLock<
63    std::sync::Mutex<HashMap<u64, (String, Instant)>>,
64> = std::sync::OnceLock::new();
65
66fn sync_bridge_host_phases_enabled() -> bool {
67    std::env::var("AGENTOS_SYNC_BRIDGE_HOST_PHASES").as_deref() == Ok("1")
68}
69
70pub(crate) fn record_sync_bridge_host_phase(method: &str, stage: &str, elapsed: Duration) {
71    if !sync_bridge_host_phases_enabled() {
72        return;
73    }
74    let stats = SYNC_BRIDGE_HOST_PHASES.get_or_init(|| std::sync::Mutex::new(BTreeMap::new()));
75    let Ok(mut stats) = stats.lock() else {
76        return;
77    };
78    let elapsed_us = elapsed.as_micros() as u64;
79    let key = format!("{method}:{stage}");
80    let entry = stats.entry(key).or_default();
81    entry.calls += 1;
82    entry.total_us = entry.total_us.wrapping_add(elapsed_us);
83    entry.max_us = entry.max_us.max(elapsed_us);
84
85    if let Ok(path) = std::env::var("AGENTOS_SYNC_BRIDGE_HOST_PHASES_FILE") {
86        let mut lines = String::new();
87        for (key, value) in stats.iter() {
88            let Some((method, stage)) = key.split_once(':') else {
89                continue;
90            };
91            let avg_us = value.total_us.checked_div(value.calls).unwrap_or(0);
92            lines.push_str(&format!(
93                "method={method} stage={stage} calls={} total_us={} avg_us={} max_us={}\n",
94                value.calls, value.total_us, avg_us, value.max_us
95            ));
96        }
97        let _ = std::fs::write(path, lines);
98    }
99}
100
101fn track_sync_bridge_call_method(call_id: u64, method: &str) {
102    if !sync_bridge_host_phases_enabled() {
103        return;
104    }
105    let methods = SYNC_BRIDGE_CALL_METHODS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
106    let Ok(mut methods) = methods.lock() else {
107        return;
108    };
109    if methods.len() > 4096 {
110        methods.clear();
111    }
112    methods.insert(call_id, method.to_owned());
113}
114
115fn cleanup_sync_bridge_call_tracking(call_id: u64) {
116    if let Some(methods) = SYNC_BRIDGE_CALL_METHODS.get() {
117        if let Ok(mut methods) = methods.lock() {
118            methods.remove(&call_id);
119        }
120    }
121    if let Some(starts) = SYNC_BRIDGE_RESPONSE_CHANNEL_SENDS.get() {
122        if let Ok(mut starts) = starts.lock() {
123            starts.remove(&call_id);
124        }
125    }
126}
127
128pub(crate) fn record_sync_bridge_response_channel_send_start(call_id: u64) {
129    if !sync_bridge_host_phases_enabled() {
130        return;
131    }
132    let method = SYNC_BRIDGE_CALL_METHODS
133        .get()
134        .and_then(|methods| methods.lock().ok()?.get(&call_id).cloned())
135        .unwrap_or_else(|| String::from("sync_rpc_response"));
136    let starts =
137        SYNC_BRIDGE_RESPONSE_CHANNEL_SENDS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
138    let Ok(mut starts) = starts.lock() else {
139        return;
140    };
141    if starts.len() > 4096 {
142        starts.clear();
143    }
144    starts.insert(call_id, (method, Instant::now()));
145}
146
147pub(crate) fn record_sync_bridge_response_channel_received(call_id: u64) {
148    if !sync_bridge_host_phases_enabled() {
149        return;
150    }
151    let Some(starts) = SYNC_BRIDGE_RESPONSE_CHANNEL_SENDS.get() else {
152        return;
153    };
154    let Ok(mut starts) = starts.lock() else {
155        return;
156    };
157    let Some((method, started)) = starts.remove(&call_id) else {
158        return;
159    };
160    record_sync_bridge_host_phase(&method, "host_response_channel_recv", started.elapsed());
161}
162
163/// Trait for sending serialized frames to the host without holding a shared mutex.
164/// Production code uses ChannelRuntimeEventSender (lock-free MPSC); tests use WriterRuntimeEventSender.
165pub trait RuntimeEventSender: Send {
166    fn send_event(&self, event: RuntimeEvent) -> Result<(), String>;
167}
168
169/// Sends frames via a crossbeam channel to a dedicated writer thread.
170/// Maintains a reusable frame buffer that grows to high-water mark,
171/// avoiding per-call allocation for frame construction.
172pub struct ChannelRuntimeEventSender {
173    pub tx: crossbeam_channel::Sender<RuntimeEventEnvelope>,
174    output_generation: Option<u64>,
175    /// Pre-allocated frame buffer reused across send_frame calls.
176    /// Grows to high-water mark; cleared (not deallocated) between calls.
177    #[allow(dead_code)]
178    frame_buf: RefCell<Vec<u8>>,
179}
180
181impl ChannelRuntimeEventSender {
182    pub fn new(
183        tx: crossbeam_channel::Sender<RuntimeEventEnvelope>,
184        output_generation: Option<u64>,
185    ) -> Self {
186        ChannelRuntimeEventSender {
187            tx,
188            output_generation,
189            frame_buf: RefCell::new(Vec::with_capacity(256)),
190        }
191    }
192}
193
194impl RuntimeEventSender for ChannelRuntimeEventSender {
195    fn send_event(&self, event: RuntimeEvent) -> Result<(), String> {
196        self.tx
197            .send(RuntimeEventEnvelope {
198                output_generation: self.output_generation,
199                event,
200            })
201            .map_err(|e| format!("channel send failed: {}", e))
202    }
203}
204
205/// Sends frames directly to a Write impl (used by tests).
206#[allow(dead_code)]
207pub struct WriterRuntimeEventSender {
208    writer: Mutex<Box<dyn Write + Send>>,
209}
210
211impl RuntimeEventSender for WriterRuntimeEventSender {
212    fn send_event(&self, event: RuntimeEvent) -> Result<(), String> {
213        let mut w = self.writer.lock().unwrap();
214        let frame: BinaryFrame = event.into();
215        ipc_binary::write_frame(&mut *w, &frame).map_err(|e| format!("write error: {}", e))
216    }
217}
218
219/// Trait for receiving a BridgeResponse directly without re-serialization.
220/// Production code uses a channel-based implementation; tests use a buffer-based one.
221pub trait BridgeResponseReceiver: Send {
222    fn recv_response(&self, expected_call_id: u64) -> Result<BridgeResponse, String>;
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct SyncBridgeCallResponse {
227    pub status: u8,
228    pub payload: Vec<u8>,
229}
230
231/// ResponseReceiver that reads frames from a byte buffer via ipc_binary::read_frame.
232/// Used by tests and any code that has a pre-serialized byte stream.
233#[allow(dead_code)]
234pub struct ReaderBridgeResponseReceiver {
235    reader: Mutex<Box<dyn Read + Send>>,
236}
237
238impl ReaderBridgeResponseReceiver {
239    #[allow(dead_code)]
240    pub fn new(reader: Box<dyn Read + Send>) -> Self {
241        ReaderBridgeResponseReceiver {
242            reader: Mutex::new(reader),
243        }
244    }
245}
246
247impl BridgeResponseReceiver for ReaderBridgeResponseReceiver {
248    fn recv_response(&self, expected_call_id: u64) -> Result<BridgeResponse, String> {
249        let mut reader = self.reader.lock().unwrap();
250        let frame = ipc_binary::read_frame(&mut *reader)
251            .map_err(|e| format!("failed to read BridgeResponse: {}", e))?;
252        match frame {
253            BinaryFrame::BridgeResponse {
254                call_id,
255                status,
256                payload,
257                ..
258            } => {
259                if call_id != expected_call_id {
260                    return Err(format!(
261                        "call_id mismatch: expected {}, got {}",
262                        expected_call_id, call_id
263                    ));
264                }
265                Ok(BridgeResponse {
266                    call_id,
267                    status,
268                    payload,
269                })
270            }
271            _ => Err("expected BridgeResponse, got different message type".into()),
272        }
273    }
274}
275
276/// Shared routing table: maps call_id → session_id for BridgeResponse routing.
277/// The connection handler uses this to determine which session a BridgeResponse
278/// belongs to (since BridgeResponse has call_id but no session_id).
279pub type CallIdRouter = Arc<Mutex<HashMap<u64, String>>>;
280
281/// Shared call_id counter type. Sessions sharing a CallIdRouter must use the same
282/// counter to prevent call_id collisions that cause BridgeResponses to be delivered
283/// to the wrong session.
284pub type SharedCallIdCounter = Arc<AtomicU64>;
285
286/// Context for sync-blocking bridge calls from a V8 session.
287///
288/// Holds the frame sender and response receiver, session ID, call_id counter,
289/// and pending-call tracking. Used by V8 FunctionTemplate callbacks to
290/// implement the sync-blocking bridge pattern.
291pub struct BridgeCallContext {
292    /// Sender for serialized frames to the host (channel-based in production)
293    sender: Box<dyn RuntimeEventSender>,
294    /// Receiver for BridgeResponse frames (no re-serialization needed)
295    response_rx: Mutex<Box<dyn BridgeResponseReceiver>>,
296    /// Session ID included in every BridgeCall
297    pub session_id: String,
298    /// Monotonically increasing call_id counter. Sessions sharing a CallIdRouter
299    /// must share the same counter (via Arc) to prevent call_id collisions.
300    next_call_id: Arc<AtomicU64>,
301    /// Set of in-flight call_ids (for duplicate rejection)
302    pending_calls: Mutex<HashSet<u64>>,
303    /// Opt-in diagnostic tracking for sync call_ids. The atomic call_id counter
304    /// plus recv_response(call_id) validation are the correctness path; this set
305    /// is only needed when inspecting in-flight calls.
306    track_pending_calls: bool,
307    /// Optional routing table for call_id → session_id mapping.
308    /// When set, call_ids are registered here so the connection handler
309    /// can route BridgeResponse messages to the correct session.
310    call_id_router: Option<CallIdRouter>,
311}
312
313/// No-op FrameSender for snapshot stub functions.
314/// Panics if called — stubs must never be invoked during snapshot creation.
315#[allow(dead_code)]
316struct StubRuntimeEventSender;
317
318impl RuntimeEventSender for StubRuntimeEventSender {
319    fn send_event(&self, _event: RuntimeEvent) -> Result<(), String> {
320        panic!(
321            "stub bridge function called during snapshot creation — bridge IIFE must not call bridge functions at setup time"
322        )
323    }
324}
325
326/// No-op ResponseReceiver for snapshot stub functions.
327/// Panics if called — stubs must never be invoked during snapshot creation.
328#[allow(dead_code)]
329struct StubBridgeResponseReceiver;
330
331impl BridgeResponseReceiver for StubBridgeResponseReceiver {
332    fn recv_response(&self, _expected_call_id: u64) -> Result<BridgeResponse, String> {
333        panic!(
334            "stub bridge function called during snapshot creation — bridge IIFE must not call bridge functions at setup time"
335        )
336    }
337}
338
339#[allow(dead_code)]
340impl BridgeCallContext {
341    /// Create a no-op BridgeCallContext for snapshot stub functions.
342    /// Panics if sync_call or async_send is called — stubs exist only for
343    /// the bridge IIFE to reference (not call) during snapshot creation.
344    pub fn stub() -> Self {
345        BridgeCallContext {
346            sender: Box::new(StubRuntimeEventSender),
347            response_rx: Mutex::new(Box::new(StubBridgeResponseReceiver)),
348            session_id: "stub".into(),
349            next_call_id: Arc::new(AtomicU64::new(1)),
350            pending_calls: Mutex::new(HashSet::new()),
351            track_pending_calls: should_track_pending_sync_calls(),
352            call_id_router: None,
353        }
354    }
355
356    /// Create a BridgeCallContext with a byte writer and reader (wraps in WriterFrameSender
357    /// and ReaderResponseReceiver). Convenient for tests that pre-serialize BridgeResponse bytes.
358    pub fn new(
359        writer: Box<dyn Write + Send>,
360        reader: Box<dyn Read + Send>,
361        session_id: String,
362    ) -> Self {
363        BridgeCallContext {
364            sender: Box::new(WriterRuntimeEventSender {
365                writer: Mutex::new(writer),
366            }),
367            response_rx: Mutex::new(Box::new(ReaderBridgeResponseReceiver::new(reader))),
368            session_id,
369            next_call_id: Arc::new(AtomicU64::new(1)),
370            pending_calls: Mutex::new(HashSet::new()),
371            track_pending_calls: should_track_pending_sync_calls(),
372            call_id_router: None,
373        }
374    }
375
376    /// Create a BridgeCallContext with a FrameSender, ResponseReceiver, call_id routing table,
377    /// and shared call_id counter. All sessions sharing the same CallIdRouter must share
378    /// the same counter to prevent call_id collisions in the routing table.
379    pub fn with_receiver(
380        sender: Box<dyn RuntimeEventSender>,
381        response_rx: Box<dyn BridgeResponseReceiver>,
382        session_id: String,
383        router: CallIdRouter,
384        shared_call_id: SharedCallIdCounter,
385    ) -> Self {
386        BridgeCallContext {
387            sender,
388            response_rx: Mutex::new(response_rx),
389            session_id,
390            next_call_id: shared_call_id,
391            pending_calls: Mutex::new(HashSet::new()),
392            track_pending_calls: should_track_pending_sync_calls(),
393            call_id_router: Some(router),
394        }
395    }
396
397    /// Perform a sync-blocking bridge call.
398    ///
399    /// Generates a unique call_id, sends a BridgeCall message over IPC,
400    /// blocks on read() for the BridgeResponse, and returns the result.
401    /// Error responses from the host are returned as Err.
402    pub fn sync_call_response(
403        &self,
404        method: &str,
405        args: Vec<u8>,
406    ) -> Result<Option<SyncBridgeCallResponse>, String> {
407        let call_id = self.next_call_id.fetch_add(1, Ordering::Relaxed);
408        track_sync_bridge_call_method(call_id, method);
409
410        // Optional diagnostic tracking. Correctness comes from the atomic
411        // counter and recv_response(call_id) identity validation.
412        if self.track_pending_calls {
413            let mut pending = self.pending_calls.lock().unwrap();
414            if !pending.insert(call_id) {
415                return Err(format!("duplicate call_id: {}", call_id));
416            }
417        }
418
419        // Register call_id → session_id for BridgeResponse routing
420        if let Some(ref router) = self.call_id_router {
421            let phase_start = Instant::now();
422            router
423                .lock()
424                .unwrap()
425                .insert(call_id, self.session_id.clone());
426            record_sync_bridge_host_phase(method, "host_register_route", phase_start.elapsed());
427        }
428
429        // Send BridgeCall to host
430        let bridge_call = RuntimeEvent::BridgeCall {
431            session_id: self.session_id.clone(),
432            call_id,
433            method: method.to_string(),
434            payload: args,
435        };
436
437        let __lat = syncrpc_lat_enabled().then(Instant::now);
438        let phase_start = Instant::now();
439        if let Err(e) = self.sender.send_event(bridge_call) {
440            self.remove_pending_call(call_id);
441            self.remove_call_route(call_id);
442            return Err(format!("failed to write BridgeCall: {}", e));
443        }
444        record_sync_bridge_host_phase(method, "host_send_event", phase_start.elapsed());
445
446        // Receive BridgeResponse directly (no re-serialization)
447        let response = {
448            let rx = self.response_rx.lock().unwrap();
449            let phase_start = Instant::now();
450            match rx.recv_response(call_id) {
451                Ok(frame) => {
452                    record_sync_bridge_host_phase(
453                        method,
454                        "host_recv_response",
455                        phase_start.elapsed(),
456                    );
457                    frame
458                }
459                Err(e) => {
460                    self.remove_pending_call(call_id);
461                    self.remove_call_route(call_id);
462                    return Err(e);
463                }
464            }
465        };
466        if let Some(t) = __lat {
467            record_syncrpc_lat(t.elapsed().as_nanos() as u64);
468        }
469
470        let phase_start = Instant::now();
471        self.remove_pending_call(call_id);
472        self.remove_call_route(call_id);
473        record_sync_bridge_host_phase(method, "host_cleanup", phase_start.elapsed());
474
475        // Validate and extract BridgeResponse
476        let phase_start = Instant::now();
477        if response.status == 1 {
478            let result = Err(String::from_utf8_lossy(&response.payload).to_string());
479            record_sync_bridge_host_phase(method, "host_extract_response", phase_start.elapsed());
480            result
481        } else if response.payload.is_empty() && response.status != 2 {
482            record_sync_bridge_host_phase(method, "host_extract_response", phase_start.elapsed());
483            Ok(None)
484        } else {
485            let result = Ok(Some(SyncBridgeCallResponse {
486                status: response.status,
487                payload: response.payload,
488            }));
489            record_sync_bridge_host_phase(method, "host_extract_response", phase_start.elapsed());
490            result
491        }
492    }
493
494    pub fn sync_call(&self, method: &str, args: Vec<u8>) -> Result<Option<Vec<u8>>, String> {
495        self.sync_call_response(method, args)
496            .map(|response| response.map(|response| response.payload))
497    }
498
499    /// Send a BridgeCall without blocking for a response.
500    /// Returns the call_id for later matching with BridgeResponse.
501    /// Used by async promise-returning bridge functions.
502    pub fn async_send(&self, method: &str, args: Vec<u8>) -> Result<u64, String> {
503        let call_id = self.next_call_id.fetch_add(1, Ordering::Relaxed);
504
505        // Register call_id → session_id for BridgeResponse routing
506        if let Some(ref router) = self.call_id_router {
507            router
508                .lock()
509                .unwrap()
510                .insert(call_id, self.session_id.clone());
511        }
512
513        let bridge_call = RuntimeEvent::BridgeCall {
514            session_id: self.session_id.clone(),
515            call_id,
516            method: method.to_string(),
517            payload: args,
518        };
519
520        if let Err(e) = self.sender.send_event(bridge_call) {
521            self.remove_call_route(call_id);
522            return Err(format!("failed to write BridgeCall: {}", e));
523        }
524
525        Ok(call_id)
526    }
527
528    fn remove_call_route(&self, call_id: u64) {
529        if let Some(ref router) = self.call_id_router {
530            router.lock().unwrap().remove(&call_id);
531        }
532    }
533
534    fn remove_pending_call(&self, call_id: u64) {
535        cleanup_sync_bridge_call_tracking(call_id);
536        if self.track_pending_calls {
537            self.pending_calls.lock().unwrap().remove(&call_id);
538        }
539    }
540
541    /// Check if a call_id is currently pending.
542    pub fn is_call_pending(&self, call_id: u64) -> bool {
543        if !self.track_pending_calls {
544            return false;
545        }
546        self.pending_calls.lock().unwrap().contains(&call_id)
547    }
548
549    /// Number of pending calls.
550    pub fn pending_count(&self) -> usize {
551        if !self.track_pending_calls {
552            return 0;
553        }
554        self.pending_calls.lock().unwrap().len()
555    }
556}
557
558fn should_track_pending_sync_calls() -> bool {
559    std::env::var("AGENTOS_TRACK_PENDING_SYNC_CALLS").as_deref() == Ok("1")
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use std::io::Cursor;
566    use std::sync::Arc;
567
568    /// Shared writer that captures output for test inspection
569    struct SharedWriter(Arc<Mutex<Vec<u8>>>);
570
571    impl Write for SharedWriter {
572        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
573            self.0.lock().unwrap().write(buf)
574        }
575        fn flush(&mut self) -> std::io::Result<()> {
576            self.0.lock().unwrap().flush()
577        }
578    }
579
580    /// Serialize a BridgeResponse into length-prefixed binary frame bytes
581    fn make_response_bytes(
582        call_id: u64,
583        result: Option<Vec<u8>>,
584        error: Option<String>,
585    ) -> Vec<u8> {
586        let mut buf = Vec::new();
587        let (status, payload) = if let Some(err) = error {
588            (1u8, err.into_bytes())
589        } else if let Some(res) = result {
590            (0u8, res)
591        } else {
592            (0u8, vec![])
593        };
594        ipc_binary::write_frame(
595            &mut buf,
596            &BinaryFrame::BridgeResponse {
597                session_id: String::new(),
598                call_id,
599                status,
600                payload,
601            },
602        )
603        .unwrap();
604        buf
605    }
606
607    #[test]
608    fn sync_call_success_with_result() {
609        let response_bytes = make_response_bytes(1, Some(vec![0x93, 0x01, 0x02, 0x03]), None);
610        let writer_buf = Arc::new(Mutex::new(Vec::new()));
611
612        let ctx = BridgeCallContext::new(
613            Box::new(SharedWriter(Arc::clone(&writer_buf))),
614            Box::new(Cursor::new(response_bytes)),
615            "test-session-abc".into(),
616        );
617
618        let result = ctx.sync_call("_fsReadFile", vec![0x91, 0xa3, 0x66, 0x6f, 0x6f]);
619        assert!(result.is_ok());
620        assert_eq!(result.unwrap(), Some(vec![0x93, 0x01, 0x02, 0x03]));
621
622        // Verify the BridgeCall was written correctly
623        let written = writer_buf.lock().unwrap();
624        let call = ipc_binary::read_frame(&mut Cursor::new(&*written)).unwrap();
625        match call {
626            BinaryFrame::BridgeCall {
627                call_id,
628                session_id,
629                method,
630                payload,
631                ..
632            } => {
633                assert_eq!(call_id, 1);
634                assert_eq!(session_id, "test-session-abc");
635                assert_eq!(method, "_fsReadFile");
636                assert_eq!(payload, vec![0x91, 0xa3, 0x66, 0x6f, 0x6f]);
637            }
638            _ => panic!("expected BridgeCall"),
639        }
640    }
641
642    #[test]
643    fn sync_call_success_null_result() {
644        let response_bytes = make_response_bytes(1, None, None);
645        let ctx = BridgeCallContext::new(
646            Box::new(Vec::new()),
647            Box::new(Cursor::new(response_bytes)),
648            "session-1".into(),
649        );
650
651        let result = ctx.sync_call("_log", vec![0xc0]).unwrap();
652        assert_eq!(result, None);
653    }
654
655    #[test]
656    fn sync_call_error_response() {
657        let response_bytes = make_response_bytes(1, None, Some("ENOENT: no such file".into()));
658        let ctx = BridgeCallContext::new(
659            Box::new(Vec::new()),
660            Box::new(Cursor::new(response_bytes)),
661            "session-1".into(),
662        );
663
664        let result = ctx.sync_call("_fsReadFile", vec![0xc0]);
665        assert!(result.is_err());
666        assert_eq!(result.unwrap_err(), "ENOENT: no such file");
667    }
668
669    #[test]
670    fn sync_call_call_id_increments() {
671        // Prepare two sequential responses
672        let mut response_bytes = make_response_bytes(1, Some(vec![0xa1, 0x61]), None);
673        response_bytes.extend_from_slice(&make_response_bytes(2, Some(vec![0xa1, 0x62]), None));
674
675        let ctx = BridgeCallContext::new(
676            Box::new(Vec::new()),
677            Box::new(Cursor::new(response_bytes)),
678            "session-1".into(),
679        );
680
681        let r1 = ctx.sync_call("_fn1", vec![]).unwrap();
682        let r2 = ctx.sync_call("_fn2", vec![]).unwrap();
683        assert_eq!(r1, Some(vec![0xa1, 0x61]));
684        assert_eq!(r2, Some(vec![0xa1, 0x62]));
685    }
686
687    #[test]
688    fn sync_call_pending_cleanup_on_read_error() {
689        // Empty reader = EOF error; call_id should be cleaned up
690        let ctx = BridgeCallContext::new(
691            Box::new(Vec::new()),
692            Box::new(Cursor::new(Vec::new())),
693            "session-1".into(),
694        );
695
696        assert_eq!(ctx.pending_count(), 0);
697        let _ = ctx.sync_call("_fn", vec![]);
698        assert_eq!(ctx.pending_count(), 0);
699    }
700
701    #[test]
702    fn sync_call_id_mismatch_rejected() {
703        // Response has call_id=99 but expected call_id=1
704        let response_bytes = make_response_bytes(99, Some(vec![0xc0]), None);
705        let ctx = BridgeCallContext::new(
706            Box::new(Vec::new()),
707            Box::new(Cursor::new(response_bytes)),
708            "session-1".into(),
709        );
710
711        let result = ctx.sync_call("_fn", vec![]);
712        assert!(result.is_err());
713        assert!(result.unwrap_err().contains("call_id mismatch"));
714    }
715
716    #[test]
717    fn sync_call_unexpected_message_type_rejected() {
718        // Response is not a BridgeResponse
719        let mut response_bytes = Vec::new();
720        ipc_binary::write_frame(
721            &mut response_bytes,
722            &BinaryFrame::TerminateExecution {
723                session_id: "session-1".into(),
724            },
725        )
726        .unwrap();
727
728        let ctx = BridgeCallContext::new(
729            Box::new(Vec::new()),
730            Box::new(Cursor::new(response_bytes)),
731            "session-1".into(),
732        );
733
734        let result = ctx.sync_call("_fn", vec![]);
735        assert!(result.is_err());
736        assert!(result.unwrap_err().contains("expected BridgeResponse"));
737    }
738
739    #[test]
740    fn async_send_writes_bridge_call() {
741        let writer_buf = Arc::new(Mutex::new(Vec::new()));
742        let ctx = BridgeCallContext::new(
743            Box::new(SharedWriter(Arc::clone(&writer_buf))),
744            Box::new(Cursor::new(Vec::new())),
745            "test-session-abc".into(),
746        );
747
748        let call_id = ctx
749            .async_send("_asyncFn", vec![0x91, 0xa3, 0x66, 0x6f, 0x6f])
750            .unwrap();
751        assert_eq!(call_id, 1);
752
753        // Verify the BridgeCall was written correctly
754        let written = writer_buf.lock().unwrap();
755        let call = ipc_binary::read_frame(&mut Cursor::new(&*written)).unwrap();
756        match call {
757            BinaryFrame::BridgeCall {
758                call_id,
759                session_id,
760                method,
761                payload,
762                ..
763            } => {
764                assert_eq!(call_id, 1);
765                assert_eq!(session_id, "test-session-abc");
766                assert_eq!(method, "_asyncFn");
767                assert_eq!(payload, vec![0x91, 0xa3, 0x66, 0x6f, 0x6f]);
768            }
769            _ => panic!("expected BridgeCall"),
770        }
771    }
772
773    #[test]
774    fn async_send_increments_call_id() {
775        let ctx = BridgeCallContext::new(
776            Box::new(Vec::new()),
777            Box::new(Cursor::new(Vec::new())),
778            "session-1".into(),
779        );
780
781        let id1 = ctx.async_send("_fn1", vec![]).unwrap();
782        let id2 = ctx.async_send("_fn2", vec![]).unwrap();
783        assert_eq!(id1, 1);
784        assert_eq!(id2, 2);
785    }
786
787    #[test]
788    fn async_send_shares_counter_with_sync() {
789        // Sync call uses call_id=1, async_send should get call_id=2
790        let response_bytes = make_response_bytes(1, Some(vec![0xc0]), None);
791        let ctx = BridgeCallContext::new(
792            Box::new(Vec::new()),
793            Box::new(Cursor::new(response_bytes)),
794            "session-1".into(),
795        );
796
797        let _ = ctx.sync_call("_sync", vec![]);
798        let id = ctx.async_send("_async", vec![]).unwrap();
799        assert_eq!(id, 2);
800    }
801
802    #[test]
803    fn channel_runtime_event_sender_delivers_frames() {
804        let (tx, rx) = crossbeam_channel::unbounded();
805        let sender = super::ChannelRuntimeEventSender::new(tx, None);
806
807        let event = RuntimeEvent::BridgeCall {
808            session_id: "sess-1".into(),
809            call_id: 42,
810            method: "_fsReadFile".into(),
811            payload: vec![0x01, 0x02],
812        };
813        sender.send_event(event.clone()).expect("send_event");
814
815        // Verify the received event matches without any BinaryFrame hop.
816        let received = rx.recv().expect("recv");
817        assert_eq!(received.output_generation, None);
818        assert_eq!(received.event, event);
819    }
820
821    #[test]
822    fn channel_runtime_event_sender_no_mutex_contention() {
823        // Multiple senders can send concurrently without blocking each other
824        let (tx, rx) = crossbeam_channel::unbounded();
825        let handles: Vec<_> = (0..4)
826            .map(|i| {
827                let sender = super::ChannelRuntimeEventSender::new(tx.clone(), None);
828                std::thread::spawn(move || {
829                    for j in 0..10 {
830                        let event = RuntimeEvent::BridgeCall {
831                            session_id: format!("sess-{}", i),
832                            call_id: (i * 100 + j) as u64,
833                            method: "_fn".into(),
834                            payload: vec![],
835                        };
836                        sender.send_event(event).expect("send_event");
837                    }
838                })
839            })
840            .collect();
841        drop(tx); // Drop original sender so rx closes when threads finish
842
843        for h in handles {
844            h.join().expect("thread join");
845        }
846
847        // All 40 frames should arrive and be decodable
848        let mut count = 0;
849        while rx.try_recv().is_ok() {
850            count += 1;
851        }
852        assert_eq!(count, 40);
853    }
854
855    #[test]
856    fn channel_runtime_event_sender_with_bridge_context() {
857        // Verify BridgeCallContext works with ChannelRuntimeEventSender end-to-end
858        let (tx, rx) = crossbeam_channel::unbounded();
859
860        // Pre-serialize a BridgeResponse for the reader
861        let response_bytes = make_response_bytes(1, Some(vec![0xAB, 0xCD]), None);
862        let router: super::CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
863
864        let ctx = BridgeCallContext::with_receiver(
865            Box::new(super::ChannelRuntimeEventSender::new(tx, None)),
866            Box::new(super::ReaderBridgeResponseReceiver::new(Box::new(
867                Cursor::new(response_bytes),
868            ))),
869            "test-session".into(),
870            router,
871            Arc::new(std::sync::atomic::AtomicU64::new(1)),
872        );
873
874        let result = ctx.sync_call("_fsReadFile", vec![0x01]).unwrap();
875        assert_eq!(result, Some(vec![0xAB, 0xCD]));
876
877        // Verify the BridgeCall went through the channel
878        let event = rx.recv().expect("recv bridge call");
879        match event.event {
880            RuntimeEvent::BridgeCall { method, .. } => assert_eq!(method, "_fsReadFile"),
881            _ => panic!("expected BridgeCall"),
882        }
883    }
884
885    #[test]
886    fn sync_call_success_clears_call_id_route() {
887        let (tx, _rx) = crossbeam_channel::unbounded();
888        let response_bytes = make_response_bytes(1, Some(vec![0xAB, 0xCD]), None);
889        let router: super::CallIdRouter = Arc::new(Mutex::new(HashMap::new()));
890
891        let ctx = BridgeCallContext::with_receiver(
892            Box::new(super::ChannelRuntimeEventSender::new(tx, None)),
893            Box::new(super::ReaderBridgeResponseReceiver::new(Box::new(
894                Cursor::new(response_bytes),
895            ))),
896            "test-session".into(),
897            Arc::clone(&router),
898            Arc::new(std::sync::atomic::AtomicU64::new(1)),
899        );
900
901        let result = ctx.sync_call("_fsReadFile", vec![0x01]).unwrap();
902        assert_eq!(result, Some(vec![0xAB, 0xCD]));
903        assert!(
904            router.lock().unwrap().is_empty(),
905            "sync bridge response completion should clear the call_id route"
906        );
907    }
908
909    #[test]
910    fn writer_runtime_event_sender_serializes_events() {
911        let (tx, rx) = crossbeam_channel::unbounded();
912        let sender = super::ChannelRuntimeEventSender::new(tx, None);
913
914        // Send multiple frames — buffer grows to high-water mark
915        for i in 0..5 {
916            let event = RuntimeEvent::BridgeCall {
917                session_id: "sess-1".into(),
918                call_id: i,
919                method: "_fn".into(),
920                payload: vec![0xAA; 100 * (i as usize + 1)],
921            };
922            sender.send_event(event).expect("send_event");
923        }
924
925        // Verify all events arrive with their payload intact.
926        for i in 0..5u64 {
927            let decoded = rx.recv().expect("recv");
928            match decoded.event {
929                RuntimeEvent::BridgeCall {
930                    call_id, payload, ..
931                } => {
932                    assert_eq!(call_id, i);
933                    assert_eq!(payload.len(), 100 * (i as usize + 1));
934                }
935                _ => panic!("expected BridgeCall"),
936            }
937        }
938
939        // Small follow-up events still go through the same sender.
940        let small = RuntimeEvent::Log {
941            session_id: "s".into(),
942            channel: 0,
943            message: "x".into(),
944        };
945        sender.send_event(small.clone()).expect("send_event");
946        let decoded = rx.recv().expect("recv");
947        assert_eq!(decoded.event, small);
948    }
949
950    #[test]
951    fn stub_context_panics_on_sync_call() {
952        let ctx = BridgeCallContext::stub();
953        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
954            let _ = ctx.sync_call("_fsReadFile", vec![]);
955        }));
956        assert!(result.is_err(), "stub sync_call should panic");
957    }
958
959    #[test]
960    fn stub_context_panics_on_async_send() {
961        let ctx = BridgeCallContext::stub();
962        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
963            let _ = ctx.async_send("_asyncFn", vec![]);
964        }));
965        assert!(result.is_err(), "stub async_send should panic");
966    }
967}