Skip to main content

agentos_v8_runtime/
bridge.rs

1// Host function injection via v8::FunctionTemplate
2
3use std::cell::{Cell, RefCell};
4use std::collections::{HashMap, HashSet};
5use std::ffi::c_void;
6use std::mem::MaybeUninit;
7use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8use std::sync::OnceLock;
9
10use agentos_bridge::bridge_contract;
11use serde::de;
12use v8::MapFnTo;
13use v8::ValueDeserializerHelper;
14use v8::ValueSerializerHelper;
15
16use crate::host_call::BridgeCallContext;
17
18// CBOR codec flag: when true, use CBOR (via ciborium) instead of V8
19// ValueSerializer/ValueDeserializer for IPC payloads. Activated by
20// AGENTOS_V8_CODEC=cbor for runtimes whose node:v8 module doesn't
21// produce real V8 serialization format (e.g. Bun).
22static USE_CBOR_CODEC: AtomicBool = AtomicBool::new(false);
23static EMBEDDED_CBOR_USERS: AtomicUsize = AtomicUsize::new(0);
24const MAX_CBOR_BRIDGE_DEPTH: usize = 64;
25const MAX_CBOR_BRIDGE_CONTAINER_ITEMS: usize = 100_000;
26const MAX_VM_CONTEXTS: usize = 1024;
27pub(crate) const MAX_PENDING_PROMISES: usize = 1024;
28const DEFAULT_BRIDGE_RESPONSE_MAX_BYTES: usize = 256 * 1024;
29const READ_RESPONSE_ENVELOPE_BYTES: usize = 4 * 1024;
30
31/// Initialize the codec from the AGENTOS_V8_CODEC environment variable.
32/// Call once at process startup before any sessions are created.
33pub fn init_codec() {
34    USE_CBOR_CODEC.store(configured_cbor_codec_enabled(), Ordering::Relaxed);
35}
36
37pub fn enable_cbor_codec() {
38    USE_CBOR_CODEC.store(true, Ordering::Relaxed);
39}
40
41pub fn acquire_embedded_cbor_codec() {
42    EMBEDDED_CBOR_USERS.fetch_add(1, Ordering::AcqRel);
43    USE_CBOR_CODEC.store(true, Ordering::Relaxed);
44}
45
46pub fn release_embedded_cbor_codec() {
47    let previous = EMBEDDED_CBOR_USERS.fetch_sub(1, Ordering::AcqRel);
48    if previous <= 1 {
49        USE_CBOR_CODEC.store(configured_cbor_codec_enabled(), Ordering::Relaxed);
50    }
51}
52
53/// Returns true if the CBOR codec is active.
54pub fn is_cbor_codec() -> bool {
55    USE_CBOR_CODEC.load(Ordering::Relaxed)
56}
57
58fn configured_cbor_codec_enabled() -> bool {
59    std::env::var("AGENTOS_V8_CODEC")
60        .map(|val| val == "cbor")
61        .unwrap_or(false)
62}
63
64/// External references for V8 snapshot serialization.
65/// Maps function pointer indices in the snapshot to current addresses.
66/// Must be identical at snapshot creation and restore time.
67pub fn external_refs() -> &'static v8::ExternalReferences {
68    static REFS: OnceLock<v8::ExternalReferences> = OnceLock::new();
69    REFS.get_or_init(|| {
70        v8::ExternalReferences::new(&[
71            v8::ExternalReference {
72                function: sync_bridge_callback.map_fn_to(),
73            },
74            v8::ExternalReference {
75                function: async_bridge_callback.map_fn_to(),
76            },
77        ])
78    })
79}
80
81// Minimal delegate for V8 ValueSerializer — throws DataCloneError as a V8 exception
82struct DefaultSerializerDelegate;
83
84impl v8::ValueSerializerImpl for DefaultSerializerDelegate {
85    fn throw_data_clone_error<'s>(
86        &self,
87        scope: &mut v8::HandleScope<'s>,
88        message: v8::Local<'s, v8::String>,
89    ) {
90        let exc = v8::Exception::error(scope, message);
91        scope.throw_exception(exc);
92    }
93}
94
95// Minimal delegate for V8 ValueDeserializer — default callbacks are sufficient
96struct DefaultDeserializerDelegate;
97
98impl v8::ValueDeserializerImpl for DefaultDeserializerDelegate {}
99
100/// Serialize a V8 value to bytes using V8's built-in ValueSerializer.
101/// Handles all V8 types natively: primitives, strings, arrays, objects,
102/// Uint8Array, Date, Map, Set, RegExp, Error, and circular references.
103/// When CBOR codec is active, uses ciborium instead.
104pub fn serialize_v8_value(
105    scope: &mut v8::HandleScope,
106    value: v8::Local<v8::Value>,
107) -> Result<Vec<u8>, String> {
108    if is_cbor_codec() {
109        return serialize_cbor_value(scope, value);
110    }
111    serialize_v8_wire_value(scope, value)
112}
113
114/// Serialize a V8 value to bytes using V8's native wire format regardless of
115/// the process-wide codec toggle.
116pub fn serialize_v8_wire_value(
117    scope: &mut v8::HandleScope,
118    value: v8::Local<v8::Value>,
119) -> Result<Vec<u8>, String> {
120    let context = scope.get_current_context();
121    let serializer = v8::ValueSerializer::new(scope, Box::new(DefaultSerializerDelegate));
122    serializer.write_header();
123    serializer
124        .write_value(context, value)
125        .ok_or_else(|| "V8 ValueSerializer: failed to serialize value".to_string())?;
126    Ok(serializer.release())
127}
128
129/// Deserialize bytes back to a V8 value using V8's built-in ValueDeserializer.
130/// The bytes must have been produced by serialize_v8_value() or node:v8.serialize().
131pub fn deserialize_v8_value<'s>(
132    scope: &mut v8::HandleScope<'s>,
133    data: &[u8],
134) -> Result<v8::Local<'s, v8::Value>, String> {
135    if is_cbor_codec() {
136        return deserialize_cbor_value(scope, data);
137    }
138    deserialize_v8_wire_value(scope, data)
139}
140
141/// Deserialize bytes from V8's native wire format regardless of the
142/// process-wide codec toggle.
143pub fn deserialize_v8_wire_value<'s>(
144    scope: &mut v8::HandleScope<'s>,
145    data: &[u8],
146) -> Result<v8::Local<'s, v8::Value>, String> {
147    let context = scope.get_current_context();
148    let deserializer =
149        v8::ValueDeserializer::new(scope, Box::new(DefaultDeserializerDelegate), data);
150    deserializer
151        .read_header(context)
152        .ok_or_else(|| "V8 ValueDeserializer: invalid header".to_string())?;
153    deserializer
154        .read_value(context)
155        .ok_or_else(|| "V8 ValueDeserializer: failed to deserialize value".to_string())
156}
157
158// ── CBOR codec ──
159
160/// Convert a V8 value to a ciborium::Value for CBOR serialization.
161fn v8_to_cbor(
162    scope: &mut v8::HandleScope,
163    value: v8::Local<v8::Value>,
164) -> Result<ciborium::Value, String> {
165    let mut object_stack = Vec::new();
166    v8_to_cbor_inner(scope, value, 0, &mut object_stack)
167}
168
169fn v8_to_cbor_inner(
170    scope: &mut v8::HandleScope,
171    value: v8::Local<v8::Value>,
172    depth: usize,
173    object_stack: &mut Vec<v8::Global<v8::Object>>,
174) -> Result<ciborium::Value, String> {
175    if depth > MAX_CBOR_BRIDGE_DEPTH {
176        return Err(format!(
177            "CBOR encode depth exceeds limit of {MAX_CBOR_BRIDGE_DEPTH}"
178        ));
179    }
180
181    if value.is_null_or_undefined() {
182        return Ok(ciborium::Value::Null);
183    }
184    if value.is_boolean() {
185        return Ok(ciborium::Value::Bool(value.boolean_value(scope)));
186    }
187    if value.is_int32() {
188        return Ok(ciborium::Value::Integer(
189            value.int32_value(scope).unwrap_or(0).into(),
190        ));
191    }
192    if value.is_number() {
193        return Ok(ciborium::Value::Float(
194            value.number_value(scope).unwrap_or(0.0),
195        ));
196    }
197    if value.is_string() {
198        let s = value.to_rust_string_lossy(scope);
199        return Ok(ciborium::Value::Text(s));
200    }
201    if value.is_array_buffer_view() {
202        let view = v8::Local::<v8::ArrayBufferView>::try_from(value).unwrap();
203        let len = view.byte_length();
204        let mut buf = vec![0u8; len];
205        view.copy_contents(&mut buf);
206        return Ok(ciborium::Value::Bytes(buf));
207    }
208    if value.is_array() {
209        let obj = value
210            .to_object(scope)
211            .ok_or_else(|| "CBOR encode failed to convert array to object".to_string())?;
212        enter_cbor_object(scope, object_stack, obj)?;
213        let arr = v8::Local::<v8::Array>::try_from(value).unwrap();
214        let len = arr.length();
215        let item_count = cbor_container_item_count("array", len as usize)?;
216        let mut items = Vec::with_capacity(item_count);
217        let result = (|| {
218            for i in 0..len {
219                if let Some(elem) = arr.get_index(scope, i) {
220                    items.push(v8_to_cbor_inner(scope, elem, depth + 1, object_stack)?);
221                } else {
222                    items.push(ciborium::Value::Null);
223                }
224            }
225            Ok(ciborium::Value::Array(items))
226        })();
227        object_stack.pop();
228        return result;
229    }
230    if value.is_object() {
231        let obj = value.to_object(scope).unwrap();
232        enter_cbor_object(scope, object_stack, obj)?;
233        let names = obj
234            .get_own_property_names(scope, v8::GetPropertyNamesArgs::default())
235            .unwrap_or_else(|| v8::Array::new(scope, 0));
236        let len = names.length();
237        let item_count = cbor_container_item_count("object", len as usize)?;
238        let mut entries = Vec::with_capacity(item_count);
239        let result = (|| {
240            for i in 0..len {
241                let key = names.get_index(scope, i).unwrap();
242                let key_str = key.to_rust_string_lossy(scope);
243                let val = obj
244                    .get(scope, key)
245                    .unwrap_or_else(|| v8::undefined(scope).into());
246                entries.push((
247                    ciborium::Value::Text(key_str),
248                    v8_to_cbor_inner(scope, val, depth + 1, object_stack)?,
249                ));
250            }
251            Ok(ciborium::Value::Map(entries))
252        })();
253        object_stack.pop();
254        return result;
255    }
256    Ok(ciborium::Value::Null)
257}
258
259fn enter_cbor_object(
260    scope: &mut v8::HandleScope,
261    object_stack: &mut Vec<v8::Global<v8::Object>>,
262    object: v8::Local<v8::Object>,
263) -> Result<(), String> {
264    for previous in object_stack.iter() {
265        let previous = v8::Local::new(scope, previous);
266        if previous.strict_equals(object.into()) {
267            return Err("CBOR encode rejected circular object graph".to_string());
268        }
269    }
270    object_stack.push(v8::Global::new(scope, object));
271    Ok(())
272}
273
274fn cbor_container_item_count(kind: &str, item_count: usize) -> Result<usize, String> {
275    if item_count > MAX_CBOR_BRIDGE_CONTAINER_ITEMS {
276        return Err(format!(
277            "CBOR {kind} item count {item_count} exceeds limit of {MAX_CBOR_BRIDGE_CONTAINER_ITEMS}"
278        ));
279    }
280    Ok(item_count)
281}
282
283struct LimitedCborValue(ciborium::Value);
284
285impl<'de> de::Deserialize<'de> for LimitedCborValue {
286    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
287    where
288        D: de::Deserializer<'de>,
289    {
290        deserializer.deserialize_any(LimitedCborVisitor).map(Self)
291    }
292}
293
294struct LimitedCborSeed;
295
296impl<'de> de::DeserializeSeed<'de> for LimitedCborSeed {
297    type Value = ciborium::Value;
298
299    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
300    where
301        D: de::Deserializer<'de>,
302    {
303        deserializer.deserialize_any(LimitedCborVisitor)
304    }
305}
306
307struct LimitedCborVisitor;
308
309impl<'de> de::Visitor<'de> for LimitedCborVisitor {
310    type Value = ciborium::Value;
311
312    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        formatter.write_str("a bounded CBOR bridge value")
314    }
315
316    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
317        Ok(ciborium::Value::Bool(value))
318    }
319
320    fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E> {
321        Ok(ciborium::Value::Float(value.into()))
322    }
323
324    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E> {
325        Ok(ciborium::Value::Float(value))
326    }
327
328    fn visit_i8<E>(self, value: i8) -> Result<Self::Value, E> {
329        Ok(value.into())
330    }
331
332    fn visit_i16<E>(self, value: i16) -> Result<Self::Value, E> {
333        Ok(value.into())
334    }
335
336    fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E> {
337        Ok(value.into())
338    }
339
340    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
341        Ok(value.into())
342    }
343
344    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E> {
345        Ok(value.into())
346    }
347
348    fn visit_u8<E>(self, value: u8) -> Result<Self::Value, E> {
349        Ok(value.into())
350    }
351
352    fn visit_u16<E>(self, value: u16) -> Result<Self::Value, E> {
353        Ok(value.into())
354    }
355
356    fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E> {
357        Ok(value.into())
358    }
359
360    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
361        Ok(value.into())
362    }
363
364    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E> {
365        Ok(value.into())
366    }
367
368    fn visit_char<E>(self, value: char) -> Result<Self::Value, E> {
369        Ok(value.into())
370    }
371
372    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
373    where
374        E: de::Error,
375    {
376        Ok(value.into())
377    }
378
379    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
380    where
381        E: de::Error,
382    {
383        Ok(value.into())
384    }
385
386    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
387        Ok(value.into())
388    }
389
390    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
391    where
392        E: de::Error,
393    {
394        Ok(value.into())
395    }
396
397    fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
398    where
399        E: de::Error,
400    {
401        Ok(value.into())
402    }
403
404    fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E> {
405        Ok(value.into())
406    }
407
408    fn visit_none<E>(self) -> Result<Self::Value, E> {
409        Ok(ciborium::Value::Null)
410    }
411
412    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
413    where
414        D: de::Deserializer<'de>,
415    {
416        deserializer.deserialize_any(self)
417    }
418
419    fn visit_unit<E>(self) -> Result<Self::Value, E> {
420        Ok(ciborium::Value::Null)
421    }
422
423    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
424    where
425        D: de::Deserializer<'de>,
426    {
427        deserializer.deserialize_any(self)
428    }
429
430    fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
431    where
432        A: de::SeqAccess<'de>,
433    {
434        if let Some(item_count) = access.size_hint() {
435            limited_cbor_item_count("array", item_count)?;
436        }
437
438        let mut items = Vec::new();
439        while let Some(item) = access.next_element_seed(LimitedCborSeed)? {
440            limited_cbor_item_count("array", items.len() + 1)?;
441            items.push(item);
442        }
443        Ok(ciborium::Value::Array(items))
444    }
445
446    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
447    where
448        A: de::MapAccess<'de>,
449    {
450        if let Some(item_count) = access.size_hint() {
451            limited_cbor_item_count("map", item_count)?;
452        }
453
454        let mut entries = Vec::new();
455        while let Some(key) = access.next_key_seed(LimitedCborSeed)? {
456            limited_cbor_item_count("map", entries.len() + 1)?;
457            let value = access.next_value_seed(LimitedCborSeed)?;
458            entries.push((key, value));
459        }
460        Ok(ciborium::Value::Map(entries))
461    }
462
463    fn visit_enum<A>(self, access: A) -> Result<Self::Value, A::Error>
464    where
465        A: de::EnumAccess<'de>,
466    {
467        use serde::de::VariantAccess;
468
469        struct TaggedValueVisitor;
470
471        impl<'de> de::Visitor<'de> for TaggedValueVisitor {
472            type Value = ciborium::Value;
473
474            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475                formatter.write_str("a tagged CBOR bridge value")
476            }
477
478            fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
479            where
480                A: de::SeqAccess<'de>,
481            {
482                let tag = access
483                    .next_element()?
484                    .ok_or_else(|| de::Error::custom("expected tag"))?;
485                let value = access
486                    .next_element_seed(LimitedCborSeed)?
487                    .ok_or_else(|| de::Error::custom("expected tagged value"))?;
488                Ok(ciborium::Value::Tag(tag, Box::new(value)))
489            }
490        }
491
492        let (name, data): (String, _) = access.variant()?;
493        if name != "@@TAGGED@@" {
494            return Err(de::Error::custom("expected CBOR tag"));
495        }
496        data.tuple_variant(2, TaggedValueVisitor)
497    }
498}
499
500fn limited_cbor_item_count<E: de::Error>(kind: &str, item_count: usize) -> Result<usize, E> {
501    cbor_container_item_count(kind, item_count).map_err(de::Error::custom)
502}
503
504/// Convert a ciborium::Value to a V8 value.
505fn cbor_to_v8<'s>(
506    scope: &mut v8::HandleScope<'s>,
507    value: &ciborium::Value,
508) -> Result<v8::Local<'s, v8::Value>, String> {
509    cbor_to_v8_inner(scope, value, 0)
510}
511
512fn cbor_to_v8_inner<'s>(
513    scope: &mut v8::HandleScope<'s>,
514    value: &ciborium::Value,
515    depth: usize,
516) -> Result<v8::Local<'s, v8::Value>, String> {
517    if depth > MAX_CBOR_BRIDGE_DEPTH {
518        return Err(format!(
519            "CBOR decode depth exceeds limit of {MAX_CBOR_BRIDGE_DEPTH}"
520        ));
521    }
522
523    match value {
524        ciborium::Value::Null => Ok(v8::null(scope).into()),
525        ciborium::Value::Bool(b) => Ok(v8::Boolean::new(scope, *b).into()),
526        ciborium::Value::Integer(n) => {
527            let n: i128 = (*n).into();
528            if n >= i32::MIN as i128 && n <= i32::MAX as i128 {
529                Ok(v8::Integer::new(scope, n as i32).into())
530            } else {
531                Ok(v8::Number::new(scope, n as f64).into())
532            }
533        }
534        ciborium::Value::Float(f) => Ok(v8::Number::new(scope, *f).into()),
535        ciborium::Value::Text(s) => Ok(v8::String::new(scope, s)
536            .ok_or_else(|| "CBOR decode failed to allocate string".to_string())?
537            .into()),
538        ciborium::Value::Bytes(b) => {
539            let len = b.len();
540            let ab = v8::ArrayBuffer::new(scope, len);
541            if len > 0 {
542                let bs = ab.get_backing_store();
543                unsafe {
544                    std::ptr::copy_nonoverlapping(
545                        b.as_ptr(),
546                        bs.data().unwrap().as_ptr() as *mut u8,
547                        len,
548                    );
549                }
550            }
551            Ok(v8::Uint8Array::new(scope, ab, 0, len)
552                .ok_or_else(|| "CBOR decode failed to allocate byte array".to_string())?
553                .into())
554        }
555        ciborium::Value::Array(items) => {
556            cbor_container_item_count("array", items.len())?;
557            let arr = v8::Array::new(scope, items.len() as i32);
558            for (i, item) in items.iter().enumerate() {
559                let val = cbor_to_v8_inner(scope, item, depth + 1)?;
560                arr.set_index(scope, i as u32, val);
561            }
562            Ok(arr.into())
563        }
564        ciborium::Value::Map(entries) => {
565            cbor_container_item_count("map", entries.len())?;
566            let obj = v8::Object::new(scope);
567            for (k, v) in entries {
568                let key = cbor_to_v8_inner(scope, k, depth + 1)?;
569                let val = cbor_to_v8_inner(scope, v, depth + 1)?;
570                obj.set(scope, key, val);
571            }
572            Ok(obj.into())
573        }
574        ciborium::Value::Tag(_, inner) => cbor_to_v8_inner(scope, inner, depth + 1),
575        _ => Ok(v8::undefined(scope).into()),
576    }
577}
578
579fn raw_bytes_to_uint8array<'s>(
580    scope: &mut v8::HandleScope<'s>,
581    bytes: &[u8],
582) -> Option<v8::Local<'s, v8::Value>> {
583    let len = bytes.len();
584    let ab = v8::ArrayBuffer::new(scope, len);
585    if len > 0 {
586        let bs = ab.get_backing_store();
587        unsafe {
588            std::ptr::copy_nonoverlapping(bytes.as_ptr(), bs.data()?.as_ptr() as *mut u8, len);
589        }
590    }
591    v8::Uint8Array::new(scope, ab, 0, len).map(Into::into)
592}
593
594fn bridge_response_payload_to_v8<'s>(
595    scope: &mut v8::HandleScope<'s>,
596    status: u8,
597    payload: &[u8],
598) -> Option<v8::Local<'s, v8::Value>> {
599    if status == 2 {
600        return raw_bytes_to_uint8array(scope, payload);
601    }
602
603    let v8_val = {
604        let tc = &mut v8::TryCatch::new(scope);
605        deserialize_v8_value(tc, payload).ok()
606    };
607    if v8_val.is_some() {
608        return v8_val;
609    }
610
611    // Preserve the historical compatibility fallback for malformed/non-native
612    // status=0 responses, but never let status=2 raw bytes take this path.
613    raw_bytes_to_uint8array(scope, payload)
614}
615
616/// Serialize a V8 value to CBOR bytes.
617pub fn serialize_cbor_value(
618    scope: &mut v8::HandleScope,
619    value: v8::Local<v8::Value>,
620) -> Result<Vec<u8>, String> {
621    let cbor_val = v8_to_cbor(scope, value)?;
622    let mut buf = Vec::new();
623    ciborium::into_writer(&cbor_val, &mut buf).map_err(|e| format!("CBOR encode failed: {}", e))?;
624    Ok(buf)
625}
626
627/// Deserialize CBOR bytes to a V8 value.
628pub fn deserialize_cbor_value<'s>(
629    scope: &mut v8::HandleScope<'s>,
630    data: &[u8],
631) -> Result<v8::Local<'s, v8::Value>, String> {
632    let LimitedCborValue(cbor_val) =
633        ciborium::de::from_reader_with_recursion_limit(data, MAX_CBOR_BRIDGE_DEPTH)
634            .map_err(|e| format!("CBOR decode failed: {}", e))?;
635    cbor_to_v8(scope, &cbor_val)
636}
637
638/// Data attached to each sync bridge function via v8::External.
639/// BridgeFnStore keeps these heap allocations alive for the session.
640struct SyncBridgeFnData {
641    ctx: *const BridgeCallContext,
642    method: String,
643}
644
645/// Opaque store that keeps bridge function data alive.
646/// Must be held for the lifetime of the V8 context.
647pub struct BridgeFnStore {
648    // Box ensures stable pointer address for v8::External data when Vec grows
649    #[allow(clippy::vec_box)]
650    _data: Vec<Box<SyncBridgeFnData>>,
651}
652
653/// Data attached to each async bridge function via v8::External.
654struct AsyncBridgeFnData {
655    ctx: *const BridgeCallContext,
656    pending: *const PendingPromises,
657    method: String,
658}
659
660/// Opaque store that keeps async bridge function data alive.
661/// Must be held for the lifetime of the V8 context.
662pub struct AsyncBridgeFnStore {
663    // Box ensures stable pointer address for v8::External data when Vec grows
664    #[allow(clippy::vec_box)]
665    _data: Vec<Box<AsyncBridgeFnData>>,
666}
667
668/// Stores pending promise resolvers keyed by call_id.
669/// Single-threaded: only accessed from the session thread.
670pub struct PendingPromises {
671    map: RefCell<HashMap<u64, v8::Global<v8::PromiseResolver>>>,
672    reserved: Cell<usize>,
673}
674
675impl PendingPromises {
676    pub fn new() -> Self {
677        PendingPromises {
678            map: RefCell::new(HashMap::new()),
679            reserved: Cell::new(0),
680        }
681    }
682
683    fn capacity_error(&self) -> Option<String> {
684        let len = self.map.borrow().len().saturating_add(self.reserved.get());
685        if len >= MAX_PENDING_PROMISES {
686            return Some(format!(
687                "async bridge pending promise registry exceeded limit of {MAX_PENDING_PROMISES} promises"
688            ));
689        }
690        None
691    }
692
693    fn reserve(&self) -> Result<PendingPromiseReservation<'_>, String> {
694        if let Some(error) = self.capacity_error() {
695            return Err(error);
696        }
697        self.reserved.set(self.reserved.get().saturating_add(1));
698        Ok(PendingPromiseReservation {
699            pending: self,
700            active: true,
701        })
702    }
703
704    fn release_reservation(&self) {
705        self.reserved.set(self.reserved.get().saturating_sub(1));
706    }
707
708    fn insert_reserved(
709        &self,
710        call_id: u64,
711        resolver: v8::Global<v8::PromiseResolver>,
712        mut reservation: PendingPromiseReservation<'_>,
713    ) {
714        self.map.borrow_mut().insert(call_id, resolver);
715        reservation.active = false;
716        self.release_reservation();
717    }
718
719    /// Remove and return the resolver for a given call_id.
720    pub fn remove(&self, call_id: u64) -> Option<v8::Global<v8::PromiseResolver>> {
721        self.map.borrow_mut().remove(&call_id)
722    }
723
724    /// Number of pending promises.
725    pub fn len(&self) -> usize {
726        self.map.borrow().len()
727    }
728
729    /// Whether there are no pending promises.
730    pub fn is_empty(&self) -> bool {
731        self.map.borrow().is_empty()
732    }
733}
734
735impl Default for PendingPromises {
736    fn default() -> Self {
737        Self::new()
738    }
739}
740
741struct PendingPromiseReservation<'a> {
742    pending: &'a PendingPromises,
743    active: bool,
744}
745
746impl Drop for PendingPromiseReservation<'_> {
747    fn drop(&mut self) {
748        if self.active {
749            self.pending.release_reservation();
750        }
751    }
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755struct ThreadResourceUsageSnapshot {
756    user_cpu_us: u64,
757    system_cpu_us: u64,
758    max_rss_kib: i64,
759    shared_memory_size: i64,
760    unshared_data_size: i64,
761    unshared_stack_size: i64,
762    minor_page_faults: i64,
763    major_page_faults: i64,
764    swapped_out: i64,
765    fs_read: i64,
766    fs_write: i64,
767    ipc_sent: i64,
768    ipc_received: i64,
769    signals_count: i64,
770    voluntary_context_switches: i64,
771    involuntary_context_switches: i64,
772}
773
774fn non_negative_c_long(value: libc::c_long) -> i64 {
775    let normalized = i128::from(value).max(0);
776    normalized.min(i128::from(i64::MAX)) as i64
777}
778
779// Used only by the non-macOS `getrusage(RUSAGE_THREAD)` path; macOS reads CPU
780// time from Mach `time_value_t` instead.
781#[cfg(not(target_os = "macos"))]
782fn timeval_to_micros(value: libc::timeval) -> u64 {
783    let seconds = i128::from(value.tv_sec).max(0);
784    let micros = i128::from(value.tv_usec).max(0);
785    (seconds
786        .saturating_mul(1_000_000)
787        .saturating_add(micros)
788        .min(i128::from(u64::MAX))) as u64
789}
790
791#[cfg(not(target_os = "macos"))]
792fn current_thread_resource_usage() -> Result<ThreadResourceUsageSnapshot, String> {
793    let mut usage = MaybeUninit::<libc::rusage>::uninit();
794    let result = unsafe { libc::getrusage(libc::RUSAGE_THREAD, usage.as_mut_ptr()) };
795    if result != 0 {
796        return Err(format!(
797            "getrusage(RUSAGE_THREAD) failed: {}",
798            std::io::Error::last_os_error()
799        ));
800    }
801    let usage = unsafe { usage.assume_init() };
802    Ok(ThreadResourceUsageSnapshot {
803        user_cpu_us: timeval_to_micros(usage.ru_utime),
804        system_cpu_us: timeval_to_micros(usage.ru_stime),
805        max_rss_kib: non_negative_c_long(usage.ru_maxrss),
806        shared_memory_size: non_negative_c_long(usage.ru_ixrss),
807        unshared_data_size: non_negative_c_long(usage.ru_idrss),
808        unshared_stack_size: non_negative_c_long(usage.ru_isrss),
809        minor_page_faults: non_negative_c_long(usage.ru_minflt),
810        major_page_faults: non_negative_c_long(usage.ru_majflt),
811        swapped_out: non_negative_c_long(usage.ru_nswap),
812        fs_read: non_negative_c_long(usage.ru_inblock),
813        fs_write: non_negative_c_long(usage.ru_oublock),
814        ipc_sent: non_negative_c_long(usage.ru_msgsnd),
815        ipc_received: non_negative_c_long(usage.ru_msgrcv),
816        signals_count: non_negative_c_long(usage.ru_nsignals),
817        voluntary_context_switches: non_negative_c_long(usage.ru_nvcsw),
818        involuntary_context_switches: non_negative_c_long(usage.ru_nivcsw),
819    })
820}
821
822// macOS has no `RUSAGE_THREAD`, so per-thread CPU time comes from the Mach
823// `thread_info(THREAD_BASIC_INFO)` call. The remaining rusage fields have no
824// per-thread source on Apple platforms, so they are filled best-effort from the
825// process-wide `getrusage(RUSAGE_SELF)` (mirroring libuv's macOS behaviour).
826#[cfg(target_os = "macos")]
827fn macos_thread_cpu_micros() -> Result<(u64, u64), String> {
828    // SAFETY: `pthread_mach_thread_np` yields the calling thread's Mach port;
829    // `thread_info` fully initialises `info` on KERN_SUCCESS.
830    unsafe {
831        let port = libc::pthread_mach_thread_np(libc::pthread_self());
832        if port == 0 {
833            return Err("pthread_mach_thread_np returned MACH_PORT_NULL".to_string());
834        }
835        let mut info = MaybeUninit::<libc::thread_basic_info>::zeroed();
836        let mut count = (std::mem::size_of::<libc::thread_basic_info>()
837            / std::mem::size_of::<libc::integer_t>())
838            as libc::mach_msg_type_number_t;
839        let rc = libc::thread_info(
840            port,
841            libc::THREAD_BASIC_INFO as libc::thread_flavor_t,
842            info.as_mut_ptr() as libc::thread_info_t,
843            &mut count,
844        );
845        if rc != libc::KERN_SUCCESS {
846            return Err(format!("thread_info(THREAD_BASIC_INFO) failed: {rc}"));
847        }
848        let info = info.assume_init();
849        let to_micros = |t: libc::time_value_t| -> u64 {
850            let secs = i128::from(t.seconds).max(0);
851            let micros = i128::from(t.microseconds).max(0);
852            (secs
853                .saturating_mul(1_000_000)
854                .saturating_add(micros)
855                .min(i128::from(u64::MAX))) as u64
856        };
857        Ok((to_micros(info.user_time), to_micros(info.system_time)))
858    }
859}
860
861#[cfg(target_os = "macos")]
862fn current_thread_resource_usage() -> Result<ThreadResourceUsageSnapshot, String> {
863    let (user_cpu_us, system_cpu_us) = macos_thread_cpu_micros()?;
864
865    let mut usage = MaybeUninit::<libc::rusage>::uninit();
866    let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
867    if result != 0 {
868        return Err(format!(
869            "getrusage(RUSAGE_SELF) failed: {}",
870            std::io::Error::last_os_error()
871        ));
872    }
873    let usage = unsafe { usage.assume_init() };
874    Ok(ThreadResourceUsageSnapshot {
875        // Per-thread CPU time (accurate, from Mach thread_info).
876        user_cpu_us,
877        system_cpu_us,
878        // macOS reports ru_maxrss in bytes; normalise to KiB to match Linux.
879        max_rss_kib: non_negative_c_long(usage.ru_maxrss) / 1024,
880        // Process-wide best-effort: no per-thread source on macOS.
881        shared_memory_size: non_negative_c_long(usage.ru_ixrss),
882        unshared_data_size: non_negative_c_long(usage.ru_idrss),
883        unshared_stack_size: non_negative_c_long(usage.ru_isrss),
884        minor_page_faults: non_negative_c_long(usage.ru_minflt),
885        major_page_faults: non_negative_c_long(usage.ru_majflt),
886        swapped_out: non_negative_c_long(usage.ru_nswap),
887        fs_read: non_negative_c_long(usage.ru_inblock),
888        fs_write: non_negative_c_long(usage.ru_oublock),
889        ipc_sent: non_negative_c_long(usage.ru_msgsnd),
890        ipc_received: non_negative_c_long(usage.ru_msgrcv),
891        signals_count: non_negative_c_long(usage.ru_nsignals),
892        voluntary_context_switches: non_negative_c_long(usage.ru_nvcsw),
893        involuntary_context_switches: non_negative_c_long(usage.ru_nivcsw),
894    })
895}
896
897// Guest crypto is served by pure-Rust crates (RustCrypto), not OpenSSL, so there
898// is no live OpenSSL library to query. We still surface a `process.versions.openssl`
899// string for guest compatibility, pinned to the OpenSSL release vendored by the
900// sidecar (openssl-sys 300.6.0+3.6.2). The browser executor reports the same
901// constant so both runtimes present an identical identity.
902pub const EMULATED_OPENSSL_VERSION: &str = "3.6.3";
903
904fn set_object_string_property<'s>(
905    scope: &mut v8::HandleScope<'s>,
906    object: v8::Local<'s, v8::Object>,
907    key: &str,
908    value: &str,
909) {
910    let key = v8::String::new(scope, key).expect("V8 string key");
911    let value = v8::String::new(scope, value).expect("V8 string value");
912    let _ = object.set(scope, key.into(), value.into());
913}
914
915fn set_object_number_property<'s>(
916    scope: &mut v8::HandleScope<'s>,
917    object: v8::Local<'s, v8::Object>,
918    key: &str,
919    value: f64,
920) {
921    let key = v8::String::new(scope, key).expect("V8 string key");
922    let value = v8::Number::new(scope, value);
923    let _ = object.set(scope, key.into(), value.into());
924}
925
926fn number_property_or_zero<'s>(
927    scope: &mut v8::HandleScope<'s>,
928    object: v8::Local<'s, v8::Object>,
929    key: &str,
930) -> u64 {
931    let key = v8::String::new(scope, key).expect("V8 string key");
932    object
933        .get(scope, key.into())
934        .and_then(|value| value.integer_value(scope))
935        .and_then(|value| u64::try_from(value).ok())
936        .unwrap_or_default()
937}
938
939fn process_memory_usage_value<'s>(scope: &mut v8::HandleScope<'s>) -> v8::Local<'s, v8::Value> {
940    let mut stats = v8::HeapStatistics::default();
941    scope.get_heap_statistics(&mut stats);
942
943    let object = v8::Object::new(scope);
944    set_object_number_property(scope, object, "rss", stats.total_physical_size() as f64);
945    set_object_number_property(scope, object, "heapTotal", stats.total_heap_size() as f64);
946    set_object_number_property(scope, object, "heapUsed", stats.used_heap_size() as f64);
947    set_object_number_property(scope, object, "external", stats.external_memory() as f64);
948    set_object_number_property(
949        scope,
950        object,
951        "arrayBuffers",
952        stats.external_memory() as f64,
953    );
954    object.into()
955}
956
957fn process_cpu_usage_value<'s>(
958    scope: &mut v8::HandleScope<'s>,
959    args: &v8::FunctionCallbackArguments,
960) -> Result<v8::Local<'s, v8::Value>, String> {
961    let usage = current_thread_resource_usage()?;
962    let current_user = usage.user_cpu_us;
963    let current_system = usage.system_cpu_us;
964
965    let (user, system) = if args.length() > 0 {
966        let prev = args.get(0);
967        if prev.is_null_or_undefined() {
968            (current_user, current_system)
969        } else if let Some(prev) = prev.to_object(scope) {
970            let previous_user = number_property_or_zero(scope, prev, "user");
971            let previous_system = number_property_or_zero(scope, prev, "system");
972            (
973                current_user.saturating_sub(previous_user),
974                current_system.saturating_sub(previous_system),
975            )
976        } else {
977            (current_user, current_system)
978        }
979    } else {
980        (current_user, current_system)
981    };
982
983    let object = v8::Object::new(scope);
984    set_object_number_property(scope, object, "user", user as f64);
985    set_object_number_property(scope, object, "system", system as f64);
986    Ok(object.into())
987}
988
989fn process_resource_usage_value<'s>(
990    scope: &mut v8::HandleScope<'s>,
991) -> Result<v8::Local<'s, v8::Value>, String> {
992    let usage = current_thread_resource_usage()?;
993    let object = v8::Object::new(scope);
994    set_object_number_property(scope, object, "userCPUTime", usage.user_cpu_us as f64);
995    set_object_number_property(scope, object, "systemCPUTime", usage.system_cpu_us as f64);
996    set_object_number_property(scope, object, "maxRSS", usage.max_rss_kib as f64);
997    set_object_number_property(
998        scope,
999        object,
1000        "sharedMemorySize",
1001        usage.shared_memory_size as f64,
1002    );
1003    set_object_number_property(
1004        scope,
1005        object,
1006        "unsharedDataSize",
1007        usage.unshared_data_size as f64,
1008    );
1009    set_object_number_property(
1010        scope,
1011        object,
1012        "unsharedStackSize",
1013        usage.unshared_stack_size as f64,
1014    );
1015    set_object_number_property(
1016        scope,
1017        object,
1018        "minorPageFault",
1019        usage.minor_page_faults as f64,
1020    );
1021    set_object_number_property(
1022        scope,
1023        object,
1024        "majorPageFault",
1025        usage.major_page_faults as f64,
1026    );
1027    set_object_number_property(scope, object, "swappedOut", usage.swapped_out as f64);
1028    set_object_number_property(scope, object, "fsRead", usage.fs_read as f64);
1029    set_object_number_property(scope, object, "fsWrite", usage.fs_write as f64);
1030    set_object_number_property(scope, object, "ipcSent", usage.ipc_sent as f64);
1031    set_object_number_property(scope, object, "ipcReceived", usage.ipc_received as f64);
1032    set_object_number_property(scope, object, "signalsCount", usage.signals_count as f64);
1033    set_object_number_property(
1034        scope,
1035        object,
1036        "voluntaryContextSwitches",
1037        usage.voluntary_context_switches as f64,
1038    );
1039    set_object_number_property(
1040        scope,
1041        object,
1042        "involuntaryContextSwitches",
1043        usage.involuntary_context_switches as f64,
1044    );
1045    Ok(object.into())
1046}
1047
1048fn process_versions_value<'s>(scope: &mut v8::HandleScope<'s>) -> v8::Local<'s, v8::Value> {
1049    let object = v8::Object::new(scope);
1050    set_object_string_property(scope, object, "v8", v8::V8::get_version());
1051    set_object_string_property(scope, object, "openssl", EMULATED_OPENSSL_VERSION);
1052    object.into()
1053}
1054
1055#[derive(Clone)]
1056struct VmContextState {
1057    context: v8::Global<v8::Context>,
1058    baseline_keys: HashSet<String>,
1059    mirrored_keys: HashSet<String>,
1060}
1061
1062#[derive(Clone, Debug)]
1063struct VmRunOptions {
1064    filename: String,
1065    line_offset: i32,
1066    column_offset: i32,
1067    timeout_ms: Option<u32>,
1068}
1069
1070impl Default for VmRunOptions {
1071    fn default() -> Self {
1072        Self {
1073            filename: String::from("evalmachine.<anonymous>"),
1074            line_offset: 0,
1075            column_offset: 0,
1076            timeout_ms: None,
1077        }
1078    }
1079}
1080
1081thread_local! {
1082    static VM_CONTEXTS: RefCell<HashMap<u32, VmContextState>> = RefCell::new(HashMap::new());
1083    static NEXT_VM_CONTEXT_ID: Cell<u32> = const { Cell::new(1) };
1084}
1085
1086fn vm_context_capacity_error(current_contexts: usize) -> Option<String> {
1087    if current_contexts >= MAX_VM_CONTEXTS {
1088        return Some(format!(
1089            "node:vm context registry exceeded limit of {MAX_VM_CONTEXTS} contexts"
1090        ));
1091    }
1092    None
1093}
1094
1095fn reserve_vm_context_slot<'s>(
1096    scope: &mut v8::HandleScope<'s>,
1097    context: v8::Local<'s, v8::Context>,
1098) -> Result<u32, String> {
1099    VM_CONTEXTS.with(|contexts| {
1100        let mut contexts = contexts.borrow_mut();
1101        if let Some(error) = vm_context_capacity_error(contexts.len()) {
1102            return Err(error);
1103        }
1104
1105        let context_id = next_vm_context_id();
1106        contexts.insert(
1107            context_id,
1108            VmContextState {
1109                context: v8::Global::new(scope, context),
1110                baseline_keys: HashSet::new(),
1111                mirrored_keys: HashSet::new(),
1112            },
1113        );
1114        Ok(context_id)
1115    })
1116}
1117
1118fn update_vm_context_slot(
1119    context_id: u32,
1120    baseline_keys: HashSet<String>,
1121    mirrored_keys: HashSet<String>,
1122) {
1123    VM_CONTEXTS.with(|contexts| {
1124        if let Some(state) = contexts.borrow_mut().get_mut(&context_id) {
1125            state.baseline_keys = baseline_keys;
1126            state.mirrored_keys = mirrored_keys;
1127        }
1128    });
1129}
1130
1131fn remove_vm_context_slot(context_id: u32) {
1132    VM_CONTEXTS.with(|contexts| {
1133        contexts.borrow_mut().remove(&context_id);
1134    });
1135}
1136
1137/// Evict every `node:vm` context slot held by the current isolate thread and
1138/// reset the id counter.
1139///
1140/// `VM_CONTEXTS` is a thread-local that lives for the lifetime of the (reused)
1141/// isolate thread, not a single execution. `reserve_vm_context_slot` adds a slot
1142/// for every `vm.createContext()`, but the success path
1143/// (`update_vm_context_slot`) never removes it — only the sandbox-mirroring error
1144/// path does. Without a teardown sweep, slots reserved by one execution survive
1145/// into every later execution on the same isolate, so the registry grows
1146/// monotonically until it hits the hard cap `MAX_VM_CONTEXTS` and all subsequent
1147/// `createContext()` calls fail (freed only on thread exit). This sweep releases
1148/// those slots at a session boundary so the registry returns to empty between
1149/// executions. See `VmContextRegistryGuard`.
1150fn reset_vm_context_registry() {
1151    VM_CONTEXTS.with(|contexts| contexts.borrow_mut().clear());
1152    NEXT_VM_CONTEXT_ID.with(|next_id| next_id.set(1));
1153}
1154
1155/// RAII owner of the thread-local `node:vm` context registry for one session.
1156///
1157/// Mirrors the per-session ownership of [`PendingPromises`] (created fresh per
1158/// session, dropped at teardown): a session holds one guard, and dropping it
1159/// evicts every context slot the session reserved. Because `Drop` runs on *every*
1160/// termination path of the frame that holds the guard — normal return, `?` error,
1161/// early return, and panic unwinding — the slots are reclaimed unconditionally,
1162/// not only on the happy path. This prevents a reused isolate thread from
1163/// accumulating `vm.createContext()` slots across executions toward
1164/// `MAX_VM_CONTEXTS`.
1165#[must_use = "hold the guard for the session lifetime; dropping it evicts the vm context registry"]
1166pub struct VmContextRegistryGuard {
1167    _private: (),
1168}
1169
1170impl VmContextRegistryGuard {
1171    /// Begin a session's ownership of the vm context registry, sweeping any slots
1172    /// a prior session left on this (reused) isolate thread so the new session
1173    /// starts from an empty registry.
1174    pub fn new() -> Self {
1175        reset_vm_context_registry();
1176        VmContextRegistryGuard { _private: () }
1177    }
1178}
1179
1180impl Default for VmContextRegistryGuard {
1181    fn default() -> Self {
1182        Self::new()
1183    }
1184}
1185
1186impl Drop for VmContextRegistryGuard {
1187    fn drop(&mut self) {
1188        reset_vm_context_registry();
1189    }
1190}
1191
1192#[cfg(test)]
1193fn clear_vm_context_registry_for_test() {
1194    reset_vm_context_registry();
1195}
1196
1197#[cfg(test)]
1198fn fill_vm_context_registry_for_test<'s>(
1199    scope: &mut v8::HandleScope<'s>,
1200    context: v8::Local<'s, v8::Context>,
1201    count: usize,
1202) {
1203    clear_vm_context_registry_for_test();
1204    for _ in 0..count {
1205        reserve_vm_context_slot(scope, context).expect("fill vm context test registry");
1206    }
1207}
1208
1209#[cfg(test)]
1210fn vm_context_registry_len_for_test() -> usize {
1211    VM_CONTEXTS.with(|contexts| contexts.borrow().len())
1212}
1213
1214fn next_vm_context_id() -> u32 {
1215    NEXT_VM_CONTEXT_ID.with(|next_id| {
1216        let id = next_id.get();
1217        let next = id.checked_add(1).unwrap_or(1);
1218        next_id.set(next.max(1));
1219        id
1220    })
1221}
1222
1223fn vm_collect_object_keys<'s>(
1224    scope: &mut v8::HandleScope<'s>,
1225    object: v8::Local<'s, v8::Object>,
1226) -> HashSet<String> {
1227    let names = object
1228        .get_own_property_names(scope, v8::GetPropertyNamesArgs::default())
1229        .unwrap_or_else(|| v8::Array::new(scope, 0));
1230    let mut keys = HashSet::new();
1231    for index in 0..names.length() {
1232        let Some(name) = names.get_index(scope, index) else {
1233            continue;
1234        };
1235        if name.is_string() {
1236            keys.insert(name.to_rust_string_lossy(scope));
1237        }
1238    }
1239    keys
1240}
1241
1242fn vm_set_property<'s>(
1243    scope: &mut v8::HandleScope<'s>,
1244    object: v8::Local<'s, v8::Object>,
1245    key: &str,
1246    value: v8::Local<'s, v8::Value>,
1247) {
1248    let Some(key_value) = v8::String::new(scope, key) else {
1249        return;
1250    };
1251    let _ = object.set(scope, key_value.into(), value);
1252}
1253
1254fn vm_delete_property<'s>(
1255    scope: &mut v8::HandleScope<'s>,
1256    object: v8::Local<'s, v8::Object>,
1257    key: &str,
1258) {
1259    let Some(key_value) = v8::String::new(scope, key) else {
1260        return;
1261    };
1262    let _ = object.delete(scope, key_value.into());
1263}
1264
1265fn vm_copy_sandbox_into_context<'s>(
1266    scope: &mut v8::HandleScope<'s>,
1267    sandbox: v8::Local<'s, v8::Object>,
1268    context_global: v8::Local<'s, v8::Object>,
1269    previous_mirrored_keys: &HashSet<String>,
1270) -> HashSet<String> {
1271    let current_keys = vm_collect_object_keys(scope, sandbox);
1272    for key in current_keys.iter() {
1273        let Some(key_value) = v8::String::new(scope, key) else {
1274            continue;
1275        };
1276        let value = sandbox
1277            .get(scope, key_value.into())
1278            .unwrap_or_else(|| v8::undefined(scope).into());
1279        vm_set_property(scope, context_global, key, value);
1280    }
1281    for key in previous_mirrored_keys {
1282        if !current_keys.contains(key) {
1283            vm_delete_property(scope, context_global, key);
1284        }
1285    }
1286    current_keys
1287}
1288
1289fn vm_copy_context_into_sandbox<'s>(
1290    scope: &mut v8::HandleScope<'s>,
1291    context_global: v8::Local<'s, v8::Object>,
1292    sandbox: v8::Local<'s, v8::Object>,
1293    baseline_keys: &HashSet<String>,
1294    previous_mirrored_keys: &HashSet<String>,
1295) -> HashSet<String> {
1296    let current_keys = vm_collect_object_keys(scope, context_global)
1297        .into_iter()
1298        .filter(|key| !baseline_keys.contains(key))
1299        .collect::<HashSet<_>>();
1300    for key in current_keys.iter() {
1301        let Some(key_value) = v8::String::new(scope, key) else {
1302            continue;
1303        };
1304        let value = context_global
1305            .get(scope, key_value.into())
1306            .unwrap_or_else(|| v8::undefined(scope).into());
1307        vm_set_property(scope, sandbox, key, value);
1308    }
1309    for key in previous_mirrored_keys {
1310        if !current_keys.contains(key) {
1311            vm_delete_property(scope, sandbox, key);
1312        }
1313    }
1314    current_keys
1315}
1316
1317fn vm_options_from_value<'s>(
1318    scope: &mut v8::HandleScope<'s>,
1319    value: v8::Local<'s, v8::Value>,
1320) -> VmRunOptions {
1321    if value.is_null_or_undefined() {
1322        return VmRunOptions::default();
1323    }
1324    if value.is_string() {
1325        return VmRunOptions {
1326            filename: value.to_rust_string_lossy(scope),
1327            ..VmRunOptions::default()
1328        };
1329    }
1330    let Some(options) = value.to_object(scope) else {
1331        return VmRunOptions::default();
1332    };
1333    let mut result = VmRunOptions::default();
1334    let read_string = |scope: &mut v8::HandleScope<'s>, key: &str| {
1335        let key_value = v8::String::new(scope, key).expect("V8 string key");
1336        options
1337            .get(scope, key_value.into())
1338            .filter(|value| value.is_string())
1339            .map(|value| value.to_rust_string_lossy(scope))
1340    };
1341    let read_i32 = |scope: &mut v8::HandleScope<'s>, key: &str| {
1342        let key_value = v8::String::new(scope, key).expect("V8 string key");
1343        options
1344            .get(scope, key_value.into())
1345            .and_then(|value| value.int32_value(scope))
1346    };
1347    let read_u32 = |scope: &mut v8::HandleScope<'s>, key: &str| {
1348        let key_value = v8::String::new(scope, key).expect("V8 string key");
1349        options
1350            .get(scope, key_value.into())
1351            .and_then(|value| value.integer_value(scope))
1352            .and_then(|value| u32::try_from(value).ok())
1353    };
1354
1355    if let Some(filename) = read_string(scope, "filename") {
1356        result.filename = filename;
1357    }
1358    if let Some(line_offset) = read_i32(scope, "lineOffset") {
1359        result.line_offset = line_offset;
1360    }
1361    if let Some(column_offset) = read_i32(scope, "columnOffset") {
1362        result.column_offset = column_offset;
1363    }
1364    result.timeout_ms = read_u32(scope, "timeout").filter(|timeout_ms| *timeout_ms > 0);
1365    result
1366}
1367
1368fn vm_throw_error<'s>(
1369    scope: &mut v8::HandleScope<'s>,
1370    message: &str,
1371    code: Option<&str>,
1372    type_error: bool,
1373) -> v8::Local<'s, v8::Value> {
1374    let message_value = v8::String::new(scope, message).expect("V8 error message");
1375    let exception = if type_error {
1376        v8::Exception::type_error(scope, message_value)
1377    } else {
1378        v8::Exception::error(scope, message_value)
1379    };
1380    if let Some(code) = code {
1381        if let Some(exception_object) = exception.to_object(scope) {
1382            let code_key = v8::String::new(scope, "code").expect("V8 code key");
1383            let code_value = v8::String::new(scope, code).expect("V8 code value");
1384            let _ = exception_object.set(scope, code_key.into(), code_value.into());
1385        }
1386    }
1387    scope.throw_exception(exception);
1388    exception
1389}
1390
1391fn vm_throw_execution_error<'s>(
1392    scope: &mut v8::HandleScope<'s>,
1393    error: &crate::ipc::ExecutionError,
1394) -> v8::Local<'s, v8::Value> {
1395    let message_value = v8::String::new(scope, &error.message).expect("V8 error message");
1396    let exception = match error.error_type.as_str() {
1397        "TypeError" => v8::Exception::type_error(scope, message_value),
1398        _ => v8::Exception::error(scope, message_value),
1399    };
1400    if let Some(exception_object) = exception.to_object(scope) {
1401        if let Some(code) = error.code.as_deref() {
1402            let code_key = v8::String::new(scope, "code").expect("V8 code key");
1403            let code_value = v8::String::new(scope, code).expect("V8 code value");
1404            let _ = exception_object.set(scope, code_key.into(), code_value.into());
1405        }
1406        if !error.stack.is_empty() {
1407            let stack_key = v8::String::new(scope, "stack").expect("V8 stack key");
1408            let stack_value = v8::String::new(scope, &error.stack).expect("V8 stack value");
1409            let _ = exception_object.set(scope, stack_key.into(), stack_value.into());
1410        }
1411    }
1412    scope.throw_exception(exception);
1413    exception
1414}
1415
1416fn vm_apply_script_origin_to_error(
1417    mut error: crate::ipc::ExecutionError,
1418    options: &VmRunOptions,
1419) -> crate::ipc::ExecutionError {
1420    let display_line = options.line_offset.saturating_add(1).max(1);
1421    let display_column = options.column_offset.saturating_add(1).max(1);
1422    let marker = format!("{}:{}", options.filename, display_line);
1423    if !error.stack.contains(&marker) {
1424        error.stack = format!(
1425            "{}: {}\n    at {}:{}:{}",
1426            error.error_type, error.message, options.filename, display_line, display_column
1427        );
1428    }
1429    error
1430}
1431
1432fn vm_run_script_in_context<'s>(
1433    scope: &mut v8::HandleScope<'s>,
1434    isolate_handle: v8::IsolateHandle,
1435    context: v8::Local<'s, v8::Context>,
1436    code: &str,
1437    options: &VmRunOptions,
1438    runtime: Option<&agentos_runtime::RuntimeContext>,
1439    task_owner: Option<agentos_runtime::TaskOwner>,
1440) -> Result<v8::Local<'s, v8::Value>, String> {
1441    let mut timeout_guard = match options.timeout_ms {
1442        Some(timeout_ms) => {
1443            let runtime = runtime.ok_or_else(|| {
1444                format!(
1445                    "{}: node:vm timeout requires the session runtime context",
1446                    crate::timeout::TIMEOUT_GUARD_START_ERROR_CODE
1447                )
1448            })?;
1449            let (abort_tx, _abort_rx) = crossbeam_channel::bounded::<()>(0);
1450            Some(crate::timeout::TimeoutGuard::new(
1451                runtime,
1452                task_owner,
1453                timeout_ms,
1454                isolate_handle.clone(),
1455                abort_tx,
1456            )?)
1457        }
1458        None => None,
1459    };
1460
1461    let mut result = None;
1462    let mut exception = None;
1463    {
1464        let context_scope = &mut v8::ContextScope::new(scope, context);
1465        let tc = &mut v8::TryCatch::new(context_scope);
1466        let source = v8::String::new(tc, code)
1467            .ok_or_else(|| String::from("vm source string too large for V8"))?;
1468        let filename = v8::String::new(tc, &options.filename)
1469            .ok_or_else(|| String::from("vm filename too large for V8"))?;
1470        let origin = v8::ScriptOrigin::new(
1471            tc,
1472            filename.into(),
1473            options.line_offset.saturating_sub(1),
1474            options.column_offset,
1475            false,
1476            -1,
1477            None,
1478            false,
1479            false,
1480            false,
1481            None,
1482        );
1483        match v8::Script::compile(tc, source, Some(&origin)) {
1484            Some(script) => match script.run(tc) {
1485                Some(value) => {
1486                    tc.perform_microtask_checkpoint();
1487                    if let Some(thrown) = tc.exception() {
1488                        exception = Some(vm_apply_script_origin_to_error(
1489                            crate::execution::extract_error_info(tc, thrown),
1490                            options,
1491                        ));
1492                    } else {
1493                        result = Some(v8::Global::new(tc, value));
1494                    }
1495                }
1496                None => {
1497                    let failure_message = v8::String::new(tc, "vm script execution failed")
1498                        .expect("vm failure message");
1499                    let thrown = tc
1500                        .exception()
1501                        .unwrap_or_else(|| v8::Exception::error(tc, failure_message));
1502                    exception = Some(vm_apply_script_origin_to_error(
1503                        crate::execution::extract_error_info(tc, thrown),
1504                        options,
1505                    ));
1506                }
1507            },
1508            None => {
1509                let failure_message = v8::String::new(tc, "vm script compilation failed")
1510                    .expect("vm failure message");
1511                let thrown = tc
1512                    .exception()
1513                    .unwrap_or_else(|| v8::Exception::error(tc, failure_message));
1514                exception = Some(vm_apply_script_origin_to_error(
1515                    crate::execution::extract_error_info(tc, thrown),
1516                    options,
1517                ));
1518            }
1519        }
1520    }
1521
1522    let timed_out = if let Some(ref mut guard) = timeout_guard {
1523        guard.cancel();
1524        guard.timed_out()
1525    } else {
1526        false
1527    };
1528
1529    if timed_out {
1530        isolate_handle.cancel_terminate_execution();
1531        return Ok(vm_throw_error(
1532            scope,
1533            &format!(
1534                "Script execution timed out after {}ms",
1535                options.timeout_ms.unwrap_or_default()
1536            ),
1537            Some("ERR_SCRIPT_EXECUTION_TIMEOUT"),
1538            false,
1539        ));
1540    }
1541
1542    if let Some(exception) = exception {
1543        return Ok(vm_throw_execution_error(scope, &exception));
1544    }
1545
1546    Ok(result
1547        .map(|result| v8::Local::new(scope, &result))
1548        .unwrap_or_else(|| v8::undefined(scope).into()))
1549}
1550
1551fn vm_create_context_value<'s>(
1552    scope: &mut v8::HandleScope<'s>,
1553    args: &mut v8::FunctionCallbackArguments<'s>,
1554) -> Result<v8::Local<'s, v8::Value>, String> {
1555    let sandbox_value = args.get(0);
1556    if !(sandbox_value.is_object() || sandbox_value.is_function()) {
1557        return Ok(vm_throw_error(
1558            scope,
1559            "The \"object\" argument must be of type object.",
1560            None,
1561            true,
1562        ));
1563    }
1564    let sandbox = sandbox_value
1565        .to_object(scope)
1566        .ok_or_else(|| String::from("vm.createContext expected an object sandbox"))?;
1567    let context = v8::Context::new(scope, Default::default());
1568    let context_id = match reserve_vm_context_slot(scope, context) {
1569        Ok(context_id) => context_id,
1570        Err(message) => {
1571            return Ok(vm_throw_error(
1572                scope,
1573                &message,
1574                Some("ERR_AGENTOS_VM_CONTEXT_LIMIT"),
1575                false,
1576            ));
1577        }
1578    };
1579    {
1580        let context_scope = &mut v8::ContextScope::new(scope, context);
1581        let global = context.global(context_scope);
1582        for key in [
1583            "Buffer",
1584            "require",
1585            "process",
1586            "module",
1587            "exports",
1588            "__dirname",
1589            "__filename",
1590        ] {
1591            vm_delete_property(context_scope, global, key);
1592            let undefined = v8::undefined(context_scope).into();
1593            vm_set_property(context_scope, global, key, undefined);
1594        }
1595    }
1596    let baseline_keys = {
1597        let context_scope = &mut v8::ContextScope::new(scope, context);
1598        let global = context.global(context_scope);
1599        vm_collect_object_keys(context_scope, global)
1600    };
1601    let mirrored_keys_result = {
1602        let tc = &mut v8::TryCatch::new(scope);
1603        let mirrored_keys = {
1604            let context_scope = &mut v8::ContextScope::new(tc, context);
1605            let global = context.global(context_scope);
1606            vm_copy_sandbox_into_context(context_scope, sandbox, global, &HashSet::new())
1607        };
1608        if tc.has_caught() {
1609            Err(tc
1610                .exception()
1611                .map(|exception| v8::Global::new(tc, exception)))
1612        } else {
1613            Ok(mirrored_keys)
1614        }
1615    };
1616    let mirrored_keys = match mirrored_keys_result {
1617        Ok(mirrored_keys) => mirrored_keys,
1618        Err(exception) => {
1619            remove_vm_context_slot(context_id);
1620            if let Some(exception) = exception {
1621                let exception = v8::Local::new(scope, &exception);
1622                scope.throw_exception(exception);
1623                return Ok(exception);
1624            }
1625            return Ok(vm_throw_error(
1626                scope,
1627                "vm.createContext failed while mirroring sandbox properties",
1628                None,
1629                false,
1630            ));
1631        }
1632    };
1633
1634    update_vm_context_slot(context_id, baseline_keys, mirrored_keys);
1635    Ok(v8::Integer::new_from_unsigned(scope, context_id).into())
1636}
1637
1638fn vm_run_in_context_value<'s>(
1639    scope: &mut v8::HandleScope<'s>,
1640    args: &mut v8::FunctionCallbackArguments<'s>,
1641    bridge_ctx: &BridgeCallContext,
1642) -> Result<v8::Local<'s, v8::Value>, String> {
1643    let context_id = args
1644        .get(0)
1645        .uint32_value(scope)
1646        .ok_or_else(|| String::from("vm.runInContext missing context id"))?;
1647    let code = args.get(1).to_rust_string_lossy(scope);
1648    let options_value = args.get(2);
1649    let options = vm_options_from_value(scope, options_value);
1650    let sandbox = args
1651        .get(3)
1652        .to_object(scope)
1653        .ok_or_else(|| String::from("vm.runInContext missing sandbox object"))?;
1654    let isolate_handle = unsafe { args.get_isolate() }.thread_safe_handle();
1655
1656    let Some((context_global, baseline_keys, mirrored_keys)) = VM_CONTEXTS.with(|contexts| {
1657        contexts.borrow().get(&context_id).map(|state| {
1658            (
1659                state.context.clone(),
1660                state.baseline_keys.clone(),
1661                state.mirrored_keys.clone(),
1662            )
1663        })
1664    }) else {
1665        return Ok(vm_throw_error(
1666            scope,
1667            "The \"contextifiedObject\" argument must be a vm context.",
1668            Some("ERR_INVALID_ARG_TYPE"),
1669            true,
1670        ));
1671    };
1672
1673    let context = v8::Local::new(scope, &context_global);
1674    {
1675        let context_scope = &mut v8::ContextScope::new(scope, context);
1676        let global = context.global(context_scope);
1677        vm_copy_sandbox_into_context(context_scope, sandbox, global, &mirrored_keys);
1678    }
1679    let result = vm_run_script_in_context(
1680        scope,
1681        isolate_handle,
1682        context,
1683        &code,
1684        &options,
1685        bridge_ctx.runtime_context(),
1686        bridge_ctx.timer_task_owner(),
1687    )?;
1688    let updated_keys = {
1689        let context_scope = &mut v8::ContextScope::new(scope, context);
1690        let global = context.global(context_scope);
1691        vm_copy_context_into_sandbox(
1692            context_scope,
1693            global,
1694            sandbox,
1695            &baseline_keys,
1696            &mirrored_keys,
1697        )
1698    };
1699    VM_CONTEXTS.with(|contexts| {
1700        if let Some(state) = contexts.borrow_mut().get_mut(&context_id) {
1701            state.mirrored_keys = updated_keys;
1702        }
1703    });
1704    Ok(result)
1705}
1706
1707fn vm_run_in_this_context_value<'s>(
1708    scope: &mut v8::HandleScope<'s>,
1709    args: &mut v8::FunctionCallbackArguments<'s>,
1710    bridge_ctx: &BridgeCallContext,
1711) -> Result<v8::Local<'s, v8::Value>, String> {
1712    let code = args.get(0).to_rust_string_lossy(scope);
1713    let options_value = args.get(1);
1714    let options = vm_options_from_value(scope, options_value);
1715    let context = scope.get_current_context();
1716    let isolate_handle = unsafe { args.get_isolate() }.thread_safe_handle();
1717    vm_run_script_in_context(
1718        scope,
1719        isolate_handle,
1720        context,
1721        &code,
1722        &options,
1723        bridge_ctx.runtime_context(),
1724        bridge_ctx.timer_task_owner(),
1725    )
1726}
1727
1728fn handle_local_bridge_call<'s>(
1729    scope: &mut v8::HandleScope<'s>,
1730    method: &str,
1731    args: &mut v8::FunctionCallbackArguments<'s>,
1732    bridge_ctx: &BridgeCallContext,
1733) -> Result<Option<v8::Local<'s, v8::Value>>, String> {
1734    match method {
1735        "process.memoryUsage" => Ok(Some(process_memory_usage_value(scope))),
1736        "process.cpuUsage" => process_cpu_usage_value(scope, args).map(Some),
1737        "process.resourceUsage" => process_resource_usage_value(scope).map(Some),
1738        "process.versions" => Ok(Some(process_versions_value(scope))),
1739        "_vmCreateContext" => vm_create_context_value(scope, args).map(Some),
1740        "_vmRunInContext" => vm_run_in_context_value(scope, args, bridge_ctx).map(Some),
1741        "_vmRunInThisContext" => vm_run_in_this_context_value(scope, args, bridge_ctx).map(Some),
1742        _ => Ok(None),
1743    }
1744}
1745
1746fn read_response_length_argument(
1747    scope: &mut v8::HandleScope,
1748    method: &str,
1749    args: &v8::FunctionCallbackArguments,
1750) -> Option<usize> {
1751    let index = match method {
1752        "fs.readSync" | "_fsReadRaw" => 1,
1753        "_fsReadFileRangeRaw" => 2,
1754        "_pythonStdinRead" | "_kernelStdinReadRaw" | "_kernelStdinRead" => 0,
1755        _ => return None,
1756    };
1757    let value = args.get(index).integer_value(scope)?;
1758    usize::try_from(value).ok()
1759}
1760
1761pub(crate) fn declared_bridge_response_bytes(
1762    method: &str,
1763    requested_read_bytes: Option<usize>,
1764) -> usize {
1765    if let Some(requested_read_bytes) = requested_read_bytes {
1766        return requested_read_bytes.saturating_add(READ_RESPONSE_ENVELOPE_BYTES);
1767    }
1768    bridge_contract()
1769        .response_max_bytes
1770        .get(method)
1771        .copied()
1772        .unwrap_or(DEFAULT_BRIDGE_RESPONSE_MAX_BYTES)
1773}
1774
1775fn bridge_response_declaration(
1776    scope: &mut v8::HandleScope,
1777    method: &str,
1778    args: &v8::FunctionCallbackArguments,
1779) -> usize {
1780    declared_bridge_response_bytes(method, read_response_length_argument(scope, method, args))
1781}
1782
1783/// Register sync-blocking bridge functions on the V8 global object.
1784///
1785/// Each registered function, when called from V8:
1786/// 1. Serializes arguments as a V8 Array via ValueSerializer
1787/// 2. Sends a BridgeCall over IPC via BridgeCallContext
1788/// 3. Blocks on read() for the BridgeResponse
1789/// 4. Returns the V8-deserialized result or throws a V8 exception
1790///
1791/// The BridgeCallContext pointer must remain valid for the lifetime of the V8 context.
1792/// The returned BridgeFnStore must also be kept alive.
1793pub fn register_sync_bridge_fns(
1794    scope: &mut v8::HandleScope,
1795    ctx: *const BridgeCallContext,
1796    methods: &[&str],
1797) -> BridgeFnStore {
1798    let context = scope.get_current_context();
1799    let global = context.global(scope);
1800    let mut data = Vec::with_capacity(methods.len());
1801
1802    for &method_name in methods {
1803        let boxed = Box::new(SyncBridgeFnData {
1804            ctx,
1805            method: method_name.to_string(),
1806        });
1807        // Pointer to heap allocation — stable while Box exists in data vec
1808        let ptr = &*boxed as *const SyncBridgeFnData as *mut c_void;
1809        data.push(boxed);
1810
1811        let external = v8::External::new(scope, ptr);
1812        let template = v8::FunctionTemplate::builder(sync_bridge_callback)
1813            .data(external.into())
1814            .build(scope);
1815        let func = template.get_function(scope).unwrap();
1816        attach_bridge_function_aliases(scope, func, &["applySync", "applySyncPromise"]);
1817
1818        let key = v8::String::new(scope, method_name).unwrap();
1819        global.set(scope, key.into(), func.into());
1820    }
1821
1822    BridgeFnStore { _data: data }
1823}
1824
1825/// V8 FunctionTemplate callback for sync-blocking bridge calls.
1826fn sync_bridge_callback<'s>(
1827    scope: &mut v8::HandleScope<'s>,
1828    args: v8::FunctionCallbackArguments<'s>,
1829    mut rv: v8::ReturnValue,
1830) {
1831    let mut args = args;
1832    // Extract SyncBridgeFnData from External
1833    let external = match v8::Local::<v8::External>::try_from(args.data()) {
1834        Ok(ext) => ext,
1835        Err(_) => {
1836            let msg =
1837                v8::String::new(scope, "internal error: missing bridge function data").unwrap();
1838            let exc = v8::Exception::error(scope, msg);
1839            scope.throw_exception(exc);
1840            return;
1841        }
1842    };
1843    // SAFETY: pointer is valid while BridgeFnStore is alive (same session lifetime)
1844    let data = unsafe { &*(external.value() as *const SyncBridgeFnData) };
1845    let ctx = unsafe { &*data.ctx };
1846
1847    {
1848        let tc = &mut v8::TryCatch::new(scope);
1849        match handle_local_bridge_call(tc, &data.method, &mut args, ctx) {
1850            Ok(Some(value)) => {
1851                if tc.has_caught() {
1852                    let _ = tc.rethrow();
1853                    return;
1854                }
1855                rv.set(value);
1856                return;
1857            }
1858            Ok(None) => {}
1859            Err(err) => {
1860                if tc.has_caught() {
1861                    let _ = tc.rethrow();
1862                    return;
1863                }
1864                let msg = v8::String::new(tc, &format!("bridge runtime error: {err}")).unwrap();
1865                let exc = v8::Exception::error(tc, msg);
1866                tc.throw_exception(exc);
1867                return;
1868            }
1869        }
1870    }
1871
1872    // Serialize V8 arguments using the Vec released by V8's serializer directly.
1873    let encoded_args = match serialize_v8_args(scope, &args) {
1874        Ok(encoded_args) => encoded_args,
1875        Err(err) => {
1876            let msg =
1877                v8::String::new(scope, &format!("bridge serialization error: {}", err)).unwrap();
1878            let exc = v8::Exception::error(scope, msg);
1879            scope.throw_exception(exc);
1880            return;
1881        }
1882    };
1883
1884    // Perform sync-blocking bridge call
1885    let max_response_bytes = bridge_response_declaration(scope, &data.method, &args);
1886    match ctx.sync_call_response_with_max_response_bytes(
1887        &data.method,
1888        encoded_args,
1889        max_response_bytes,
1890    ) {
1891        Ok(Some(response)) => {
1892            let v8_val = bridge_response_payload_to_v8(scope, response.status, &response.payload);
1893            if let Some(val) = v8_val {
1894                rv.set(val);
1895            } else {
1896                let msg = v8::String::new(scope, "bridge response conversion failed").unwrap();
1897                let exc = v8::Exception::error(scope, msg);
1898                scope.throw_exception(exc);
1899            }
1900        }
1901        Ok(None) => {
1902            rv.set_undefined();
1903        }
1904        Err(err_msg) => {
1905            let msg = v8::String::new(scope, &err_msg).unwrap();
1906            let exc = v8::Exception::error(scope, msg);
1907            if let Some(code) = bridge_error_code(&err_msg) {
1908                let exc_object = exc.to_object(scope).unwrap();
1909                let code_key = v8::String::new(scope, "code").unwrap();
1910                let code_value = v8::String::new(scope, code).unwrap();
1911                let _ = exc_object.set(scope, code_key.into(), code_value.into());
1912            }
1913            scope.throw_exception(exc);
1914        }
1915    }
1916}
1917
1918/// Register async promise-returning bridge functions on the V8 global object.
1919///
1920/// Each registered function, when called from V8:
1921/// 1. Creates a v8::PromiseResolver
1922/// 2. Stores the resolver + call_id in PendingPromises
1923/// 3. Sends a BridgeCall over IPC (non-blocking write)
1924/// 4. Returns the promise to V8
1925///
1926/// The BridgeCallContext and PendingPromises pointers must remain valid
1927/// for the lifetime of the V8 context.
1928pub fn register_async_bridge_fns(
1929    scope: &mut v8::HandleScope,
1930    ctx: *const BridgeCallContext,
1931    pending: *const PendingPromises,
1932    methods: &[&str],
1933) -> AsyncBridgeFnStore {
1934    let context = scope.get_current_context();
1935    let global = context.global(scope);
1936    let mut data = Vec::with_capacity(methods.len());
1937
1938    for &method_name in methods {
1939        let boxed = Box::new(AsyncBridgeFnData {
1940            ctx,
1941            pending,
1942            method: method_name.to_string(),
1943        });
1944        // Pointer to heap allocation — stable while Box exists in data vec
1945        let ptr = &*boxed as *const AsyncBridgeFnData as *mut c_void;
1946        data.push(boxed);
1947
1948        let external = v8::External::new(scope, ptr);
1949        let template = v8::FunctionTemplate::builder(async_bridge_callback)
1950            .data(external.into())
1951            .build(scope);
1952        let func = template.get_function(scope).unwrap();
1953        attach_bridge_function_aliases(scope, func, &["apply"]);
1954
1955        let key = v8::String::new(scope, method_name).unwrap();
1956        global.set(scope, key.into(), func.into());
1957    }
1958
1959    AsyncBridgeFnStore { _data: data }
1960}
1961
1962fn attach_bridge_function_aliases<'s>(
1963    scope: &mut v8::HandleScope<'s>,
1964    func: v8::Local<'s, v8::Function>,
1965    aliases: &[&str],
1966) {
1967    let func_object = func.to_object(scope).unwrap();
1968    for alias in aliases {
1969        let key = v8::String::new(scope, alias).unwrap();
1970        let Some(wrapper) = build_bridge_apply_wrapper(scope, func) else {
1971            continue;
1972        };
1973        let _ = func_object.set(scope, key.into(), wrapper.into());
1974    }
1975}
1976
1977fn build_bridge_apply_wrapper<'s>(
1978    scope: &mut v8::HandleScope<'s>,
1979    func: v8::Local<'s, v8::Function>,
1980) -> Option<v8::Local<'s, v8::Function>> {
1981    let source = v8::String::new(
1982        scope,
1983        "(function (fn) { return function (_thisArg, args) { return fn(...(Array.isArray(args) ? args : [])); }; })",
1984    )?;
1985    let script = v8::Script::compile(scope, source, None)?;
1986    let factory = script.run(scope)?;
1987    let factory = v8::Local::<v8::Function>::try_from(factory).ok()?;
1988    let argv = [func.into()];
1989    let receiver = v8::undefined(scope).into();
1990    factory
1991        .call(scope, receiver, &argv)
1992        .and_then(|value| v8::Local::<v8::Function>::try_from(value).ok())
1993}
1994
1995fn reject_promise_with_error(
1996    scope: &mut v8::HandleScope,
1997    resolver: v8::Local<v8::PromiseResolver>,
1998    message: &str,
1999    code: Option<&str>,
2000) {
2001    let msg = v8::String::new(scope, message).unwrap();
2002    let exc = v8::Exception::error(scope, msg);
2003    if let Some(code) = code {
2004        let exc_object = exc.to_object(scope).unwrap();
2005        let code_key = v8::String::new(scope, "code").unwrap();
2006        let code_value = v8::String::new(scope, code).unwrap();
2007        let _ = exc_object.set(scope, code_key.into(), code_value.into());
2008    }
2009    resolver.reject(scope, exc);
2010}
2011
2012/// V8 FunctionTemplate callback for async promise-returning bridge calls.
2013fn async_bridge_callback(
2014    scope: &mut v8::HandleScope,
2015    args: v8::FunctionCallbackArguments,
2016    mut rv: v8::ReturnValue,
2017) {
2018    // Extract AsyncBridgeFnData from External
2019    let external = match v8::Local::<v8::External>::try_from(args.data()) {
2020        Ok(ext) => ext,
2021        Err(_) => {
2022            let msg = v8::String::new(scope, "internal error: missing async bridge function data")
2023                .unwrap();
2024            let exc = v8::Exception::error(scope, msg);
2025            scope.throw_exception(exc);
2026            return;
2027        }
2028    };
2029    // SAFETY: pointer is valid while AsyncBridgeFnStore is alive (same session lifetime)
2030    let data = unsafe { &*(external.value() as *const AsyncBridgeFnData) };
2031    let ctx = unsafe { &*data.ctx };
2032    let pending = unsafe { &*data.pending };
2033
2034    // Create PromiseResolver
2035    let resolver = match v8::PromiseResolver::new(scope) {
2036        Some(r) => r,
2037        None => {
2038            let msg = v8::String::new(scope, "failed to create PromiseResolver").unwrap();
2039            let exc = v8::Exception::error(scope, msg);
2040            scope.throw_exception(exc);
2041            return;
2042        }
2043    };
2044
2045    // Get the promise to return to V8
2046    let promise = resolver.get_promise(scope);
2047
2048    let reservation = match pending.reserve() {
2049        Ok(reservation) => reservation,
2050        Err(err_msg) => {
2051            reject_promise_with_error(
2052                scope,
2053                resolver,
2054                &err_msg,
2055                Some("ERR_AGENTOS_BRIDGE_PENDING_PROMISE_LIMIT"),
2056            );
2057            rv.set(promise.into());
2058            return;
2059        }
2060    };
2061
2062    // Serialize V8 arguments using the Vec released by V8's serializer directly.
2063    let encoded_args = match serialize_v8_args(scope, &args) {
2064        Ok(encoded_args) => encoded_args,
2065        Err(err) => {
2066            let msg =
2067                v8::String::new(scope, &format!("bridge serialization error: {}", err)).unwrap();
2068            let exc = v8::Exception::error(scope, msg);
2069            scope.throw_exception(exc);
2070            return;
2071        }
2072    };
2073
2074    // Register the response target and V8 resolver before the request can
2075    // become host-visible. A fast response therefore cannot outrun resolver
2076    // installation once response ingress is concurrent with the VM thread.
2077    let max_response_bytes = bridge_response_declaration(scope, &data.method, &args);
2078    match ctx.prepare_async_call_with_max_response_bytes(
2079        &data.method,
2080        encoded_args,
2081        max_response_bytes,
2082    ) {
2083        Ok(prepared) => {
2084            let call_id = prepared.call_id;
2085            let global_resolver = v8::Global::new(scope, resolver);
2086            pending.insert_reserved(call_id, global_resolver, reservation);
2087            if let Err(err_msg) = ctx.dispatch_async_call(prepared) {
2088                pending.remove(call_id);
2089                reject_promise_with_error(scope, resolver, &err_msg, None);
2090            }
2091        }
2092        Err(err_msg) => {
2093            // Reject the promise immediately if send fails
2094            reject_promise_with_error(scope, resolver, &err_msg, None);
2095        }
2096    }
2097
2098    // Return the promise
2099    rv.set(promise.into());
2100}
2101
2102/// Replace stub bridge functions on a snapshot-restored context with real
2103/// session-local bridge functions. Overwrites the 38 stub globals with
2104/// functions backed by session-local BridgeCallContext.
2105///
2106/// Returns (BridgeFnStore, AsyncBridgeFnStore) that must be kept alive
2107/// for the lifetime of the V8 context.
2108pub fn replace_bridge_fns(
2109    scope: &mut v8::HandleScope,
2110    ctx: *const BridgeCallContext,
2111    pending: *const PendingPromises,
2112    sync_fns: &[&str],
2113    async_fns: &[&str],
2114) -> (BridgeFnStore, AsyncBridgeFnStore) {
2115    // Per-session bridge installation runs once, before any user code executes in
2116    // this context, so the only `node:vm` context slots present are leftovers from
2117    // a prior session that reused this isolate thread. Sweep them here so a new
2118    // session never inherits another session's contexts and the registry can never
2119    // accumulate across executions toward `MAX_VM_CONTEXTS`. The session should
2120    // also hold a `VmContextRegistryGuard` to evict its own slots at teardown.
2121    reset_vm_context_registry();
2122    let sync_store = register_sync_bridge_fns(scope, ctx, sync_fns);
2123    let async_store = register_async_bridge_fns(scope, ctx, pending, async_fns);
2124    (sync_store, async_store)
2125}
2126
2127/// Register stub bridge functions on the V8 global for snapshot creation.
2128///
2129/// Uses the same sync_bridge_callback / async_bridge_callback as real
2130/// functions (required for ExternalReferences in snapshot serialization)
2131/// but WITHOUT v8::External data. If a stub is accidentally called during
2132/// snapshot creation, the callback gracefully throws a V8 exception
2133/// (args.data() is not External -> "missing bridge function data" error).
2134///
2135/// After snapshot restore, these stubs are replaced with real functions
2136/// that have proper External data pointing to a session-local BridgeCallContext.
2137pub fn register_stub_bridge_fns(
2138    scope: &mut v8::HandleScope,
2139    sync_fns: &[&str],
2140    async_fns: &[&str],
2141) {
2142    let context = scope.get_current_context();
2143    let global = context.global(scope);
2144
2145    // Register sync bridge functions as stubs (no External data)
2146    for &method_name in sync_fns {
2147        let template = v8::FunctionTemplate::builder(sync_bridge_callback).build(scope);
2148        let func = template.get_function(scope).unwrap();
2149        let key = v8::String::new(scope, method_name).unwrap();
2150        global.set(scope, key.into(), func.into());
2151    }
2152
2153    // Register async bridge functions as stubs (no External data)
2154    for &method_name in async_fns {
2155        let template = v8::FunctionTemplate::builder(async_bridge_callback).build(scope);
2156        let func = template.get_function(scope).unwrap();
2157        let key = v8::String::new(scope, method_name).unwrap();
2158        global.set(scope, key.into(), func.into());
2159    }
2160}
2161
2162/// Serialize V8 function arguments as an array.
2163fn serialize_v8_args(
2164    scope: &mut v8::HandleScope,
2165    args: &v8::FunctionCallbackArguments,
2166) -> Result<Vec<u8>, String> {
2167    let count = args.length();
2168    let array = v8::Array::new(scope, count);
2169    for i in 0..count {
2170        array.set_index(scope, i as u32, args.get(i));
2171    }
2172    serialize_v8_value(scope, array.into())
2173}
2174
2175/// Resolve or reject a pending async bridge promise by call_id.
2176///
2177/// Called when a BridgeResponse arrives during the session event loop.
2178/// Flushes microtasks after resolution to process .then() handlers.
2179pub fn resolve_pending_promise(
2180    scope: &mut v8::HandleScope,
2181    pending: &PendingPromises,
2182    call_id: u64,
2183    status: u8,
2184    result: Option<Vec<u8>>,
2185    error: Option<String>,
2186) -> Result<(), String> {
2187    let resolver_global = pending
2188        .remove(call_id)
2189        .ok_or_else(|| format!("no pending promise for call_id {}", call_id))?;
2190    let resolver = v8::Local::new(scope, &resolver_global);
2191
2192    if let Some(err_msg) = error {
2193        let msg = v8::String::new(scope, &err_msg).unwrap();
2194        let exc = v8::Exception::error(scope, msg);
2195        if let Some(code) = bridge_error_code(&err_msg) {
2196            let exc_object = exc.to_object(scope).unwrap();
2197            let code_key = v8::String::new(scope, "code").unwrap();
2198            let code_value = v8::String::new(scope, code).unwrap();
2199            let _ = exc_object.set(scope, code_key.into(), code_value.into());
2200        }
2201        resolver.reject(scope, exc);
2202    } else if let Some(result_bytes) = result {
2203        let v8_val = bridge_response_payload_to_v8(scope, status, &result_bytes);
2204        if let Some(val) = v8_val {
2205            resolver.resolve(scope, val);
2206        } else {
2207            let msg = v8::String::new(scope, "bridge response conversion failed").unwrap();
2208            let exc = v8::Exception::error(scope, msg);
2209            resolver.reject(scope, exc);
2210        }
2211    } else {
2212        let undef = v8::undefined(scope);
2213        resolver.resolve(scope, undef.into());
2214    }
2215
2216    // Flush microtasks after resolution
2217    scope.perform_microtask_checkpoint();
2218
2219    Ok(())
2220}
2221
2222fn bridge_error_code(message: &str) -> Option<&str> {
2223    const TRUSTED_PREFIXES: &[&str] = &[
2224        "ERR_AGENTOS_NODE_SYNC_RPC",
2225        "ERR_AGENTOS_PYTHON_VFS_RPC",
2226        "ERR_AGENTOS_BRIDGE",
2227    ];
2228
2229    let mut segments = message.split(':').map(str::trim);
2230    let first = segments.next()?;
2231    if is_errno_segment(first) {
2232        return Some(first);
2233    }
2234
2235    if TRUSTED_PREFIXES.contains(&first) {
2236        let second = segments.next()?;
2237        if is_errno_segment(second) {
2238            return Some(second);
2239        }
2240    }
2241
2242    None
2243}
2244
2245fn is_errno_segment(segment: &str) -> bool {
2246    segment.len() >= 2
2247        && segment.starts_with('E')
2248        && !segment.starts_with("ERR_")
2249        && segment[1..]
2250            .bytes()
2251            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
2252}
2253
2254#[cfg(test)]
2255mod tests {
2256    use super::{
2257        bridge_error_code, clear_vm_context_registry_for_test, declared_bridge_response_bytes,
2258        deserialize_cbor_value, fill_vm_context_registry_for_test, register_async_bridge_fns,
2259        register_sync_bridge_fns, reserve_vm_context_slot, reset_vm_context_registry,
2260        serialize_cbor_value, vm_context_capacity_error, vm_context_registry_len_for_test,
2261        PendingPromises, VmContextRegistryGuard, MAX_CBOR_BRIDGE_CONTAINER_ITEMS,
2262        MAX_CBOR_BRIDGE_DEPTH, MAX_PENDING_PROMISES, MAX_VM_CONTEXTS,
2263    };
2264    use crate::host_call::BridgeCallContext;
2265    use crate::ipc_binary::{self, BinaryFrame};
2266    use crate::isolate;
2267    use std::io::{Cursor, Write};
2268    use std::process::Command;
2269    use std::sync::{Arc, Mutex};
2270
2271    struct SharedWriter(Arc<Mutex<Vec<u8>>>);
2272
2273    impl Write for SharedWriter {
2274        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2275            self.0.lock().unwrap().write(buf)
2276        }
2277
2278        fn flush(&mut self) -> std::io::Result<()> {
2279            self.0.lock().unwrap().flush()
2280        }
2281    }
2282
2283    fn bridge_call_count(bytes: &[u8]) -> usize {
2284        let mut cursor = Cursor::new(bytes);
2285        let mut count = 0;
2286        while let Ok(frame) = ipc_binary::read_frame(&mut cursor) {
2287            if matches!(frame, BinaryFrame::BridgeCall { .. }) {
2288                count += 1;
2289            }
2290        }
2291        count
2292    }
2293
2294    #[test]
2295    fn bridge_response_declarations_use_contract_reads_and_bounded_default() {
2296        assert_eq!(declared_bridge_response_bytes("_log", None), 4096);
2297        assert_eq!(
2298            declared_bridge_response_bytes("_loadFileSync", None),
2299            16 * 1024 * 1024
2300        );
2301        assert_eq!(
2302            declared_bridge_response_bytes("_fsReadRaw", Some(32 * 1024)),
2303            36 * 1024
2304        );
2305        assert_eq!(
2306            declared_bridge_response_bytes("_unclassifiedBridge", None),
2307            256 * 1024
2308        );
2309    }
2310
2311    #[test]
2312    fn bridge_error_code_rejects_guest_controlled_errno_segments() {
2313        assert_eq!(bridge_error_code("user said 'EACCES: denied'"), None);
2314        assert_eq!(
2315            bridge_error_code("prefix: user said 'EPERM': more text"),
2316            None
2317        );
2318        assert_eq!(bridge_error_code("ERR_AGENTOS_FAKE: EACCES: denied"), None);
2319    }
2320
2321    #[test]
2322    fn bridge_error_code_accepts_trusted_agentos_prefixes() {
2323        assert_eq!(
2324            bridge_error_code("ERR_AGENTOS_NODE_SYNC_RPC: EACCES: permission denied on /foo"),
2325            Some("EACCES")
2326        );
2327        assert_eq!(
2328            bridge_error_code("ERR_AGENTOS_PYTHON_VFS_RPC: ENOENT: missing file"),
2329            Some("ENOENT")
2330        );
2331        assert_eq!(bridge_error_code("EEXIST: already exists"), Some("EEXIST"));
2332    }
2333
2334    #[test]
2335    fn bridge_v8_hardening_rejects_cbor_abuse_and_vm_context_reentry_overflow() {
2336        const SUBPROCESS_ENV: &str = "AGENTOS_V8_BRIDGE_HARDENING_SUBPROCESS";
2337        if std::env::var_os(SUBPROCESS_ENV).is_none() {
2338            let output = Command::new(std::env::current_exe().expect("current test binary"))
2339                .arg("bridge::tests::bridge_v8_hardening_rejects_cbor_abuse_and_vm_context_reentry_overflow")
2340                .arg("--exact")
2341                .arg("--nocapture")
2342                .env(SUBPROCESS_ENV, "1")
2343                .output()
2344                .expect("spawn bridge hardening subprocess");
2345            assert!(
2346                output.status.success(),
2347                "bridge hardening subprocess failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
2348                output.status.code(),
2349                String::from_utf8_lossy(&output.stdout),
2350                String::from_utf8_lossy(&output.stderr)
2351            );
2352            return;
2353        }
2354
2355        isolate::init_v8_platform();
2356
2357        let mut isolate = isolate::create_isolate(None);
2358        let context = isolate::create_context(&mut isolate);
2359        let scope = &mut v8::HandleScope::new(&mut isolate);
2360        let context = v8::Local::new(scope, &context);
2361        let scope = &mut v8::ContextScope::new(scope, context);
2362
2363        let object = v8::Object::new(scope);
2364        let self_key = v8::String::new(scope, "self").unwrap();
2365        assert!(object.set(scope, self_key.into(), object.into()).is_some());
2366
2367        let error = serialize_cbor_value(scope, object.into()).expect_err("cycle rejected");
2368        assert!(
2369            error.contains("circular object graph"),
2370            "unexpected error: {error}"
2371        );
2372
2373        let source = v8::String::new(
2374            scope,
2375            &format!(
2376                "const sparse = []; sparse.length = {}; sparse",
2377                MAX_CBOR_BRIDGE_CONTAINER_ITEMS + 1
2378            ),
2379        )
2380        .unwrap();
2381        let script = v8::Script::compile(scope, source, None).unwrap();
2382        let sparse = script.run(scope).unwrap();
2383        let error = serialize_cbor_value(scope, sparse).expect_err("sparse array rejected");
2384        assert!(
2385            error.contains(&format!(
2386                "item count {} exceeds limit",
2387                MAX_CBOR_BRIDGE_CONTAINER_ITEMS + 1
2388            )),
2389            "unexpected error: {error}"
2390        );
2391
2392        let mut value = ciborium::Value::Null;
2393        for _ in 0..=MAX_CBOR_BRIDGE_DEPTH {
2394            value = ciborium::Value::Array(vec![value]);
2395        }
2396        let mut encoded = Vec::new();
2397        ciborium::into_writer(&value, &mut encoded).unwrap();
2398        let error = deserialize_cbor_value(scope, &encoded).expect_err("depth rejected");
2399        assert!(
2400            error.contains("CBOR decode failed"),
2401            "unexpected error: {error}"
2402        );
2403
2404        let oversized_len = (MAX_CBOR_BRIDGE_CONTAINER_ITEMS + 1) as u32;
2405        let oversized_array_header = [
2406            0x9a,
2407            (oversized_len >> 24) as u8,
2408            (oversized_len >> 16) as u8,
2409            (oversized_len >> 8) as u8,
2410            oversized_len as u8,
2411        ];
2412        let error = deserialize_cbor_value(scope, &oversized_array_header)
2413            .expect_err("oversized array rejected before element allocation");
2414        assert!(
2415            error.contains(&format!(
2416                "item count {} exceeds limit",
2417                MAX_CBOR_BRIDGE_CONTAINER_ITEMS + 1
2418            )),
2419            "unexpected error: {error}"
2420        );
2421
2422        fill_vm_context_registry_for_test(scope, context, MAX_VM_CONTEXTS - 1);
2423        let bridge_ctx = BridgeCallContext::new(
2424            Box::new(Vec::new()),
2425            Box::new(Cursor::new(Vec::new())),
2426            String::from("test-session"),
2427        );
2428        let _bridge_fns = register_sync_bridge_fns(
2429            scope,
2430            &bridge_ctx as *const BridgeCallContext,
2431            &["_vmCreateContext"],
2432        );
2433
2434        let source = r#"
2435            let innerCode;
2436            const sandbox = {};
2437            Object.defineProperty(sandbox, "x", {
2438                get() {
2439                    try {
2440                        _vmCreateContext({});
2441                    } catch (error) {
2442                        innerCode = error && error.code;
2443                    }
2444                    return 1;
2445                },
2446                enumerable: true,
2447            });
2448
2449            const outerId = _vmCreateContext(sandbox);
2450            let limitCode;
2451            try {
2452                _vmCreateContext({});
2453            } catch (error) {
2454                limitCode = error && error.code;
2455            }
2456
2457            JSON.stringify({
2458                innerCode,
2459                limitCode,
2460                outerIsInteger: Number.isInteger(outerId),
2461            })
2462        "#;
2463        {
2464            let tc = &mut v8::TryCatch::new(scope);
2465            let source = v8::String::new(tc, source).unwrap();
2466            let script = v8::Script::compile(tc, source, None).unwrap();
2467            let result = script.run(tc);
2468            assert!(
2469                !tc.has_caught(),
2470                "unexpected exception while testing vm cap"
2471            );
2472            let details = result
2473                .expect("vm context cap script result")
2474                .to_rust_string_lossy(tc);
2475            assert_eq!(
2476                details,
2477                r#"{"innerCode":"ERR_AGENTOS_VM_CONTEXT_LIMIT","limitCode":"ERR_AGENTOS_VM_CONTEXT_LIMIT","outerIsInteger":true}"#,
2478                "vm context cap script should observe limit errors"
2479            );
2480        }
2481        assert_eq!(vm_context_registry_len_for_test(), MAX_VM_CONTEXTS);
2482        clear_vm_context_registry_for_test();
2483
2484        let source = r#"
2485            (() => {
2486                let thrownMessage;
2487                const sandbox = {};
2488                Object.defineProperty(sandbox, "x", {
2489                    get() {
2490                        throw new Error("sandbox getter failed");
2491                    },
2492                    enumerable: true,
2493                });
2494                try {
2495                    _vmCreateContext(sandbox);
2496                } catch (error) {
2497                    thrownMessage = error && error.message;
2498                }
2499
2500                const nextId = _vmCreateContext({});
2501                return JSON.stringify({
2502                    thrownMessage,
2503                    nextIsInteger: Number.isInteger(nextId),
2504                });
2505            })()
2506        "#;
2507        {
2508            let tc = &mut v8::TryCatch::new(scope);
2509            let source = v8::String::new(tc, source).unwrap();
2510            let script = v8::Script::compile(tc, source, None).unwrap();
2511            let result = script.run(tc);
2512            if tc.has_caught() {
2513                let exception = tc
2514                    .exception()
2515                    .map(|exception| exception.to_rust_string_lossy(tc))
2516                    .unwrap_or_else(|| String::from("<missing exception>"));
2517                panic!("unexpected exception while testing vm rollback: {exception}");
2518            }
2519            let details = result
2520                .expect("vm context rollback script result")
2521                .to_rust_string_lossy(tc);
2522            assert_eq!(
2523                details, r#"{"thrownMessage":"sandbox getter failed","nextIsInteger":true}"#,
2524                "vm context rollback script should preserve the getter exception and keep registry usable"
2525            );
2526        }
2527        assert_eq!(vm_context_registry_len_for_test(), 1);
2528        clear_vm_context_registry_for_test();
2529
2530        let async_writer = Arc::new(Mutex::new(Vec::new()));
2531        let async_bridge_ctx = BridgeCallContext::new(
2532            Box::new(SharedWriter(Arc::clone(&async_writer))),
2533            Box::new(Cursor::new(Vec::new())),
2534            String::from("test-session"),
2535        );
2536        let async_pending = PendingPromises::new();
2537        let _async_bridge_fns = register_async_bridge_fns(
2538            scope,
2539            &async_bridge_ctx as *const BridgeCallContext,
2540            &async_pending as *const PendingPromises,
2541            &["_asyncFn"],
2542        );
2543        let source = format!(
2544            r#"
2545            for (let i = 0; i < {fill_count}; i++) {{
2546                _asyncFn(i);
2547            }}
2548            globalThis.__overflowPromise = _asyncFn("overflow");
2549            "#,
2550            fill_count = MAX_PENDING_PROMISES,
2551        );
2552        {
2553            let tc = &mut v8::TryCatch::new(scope);
2554            let source = v8::String::new(tc, &source).unwrap();
2555            let script = v8::Script::compile(tc, source, None).unwrap();
2556            assert!(script.run(tc).is_some());
2557            assert!(!tc.has_caught(), "async overflow should reject, not throw");
2558        }
2559        assert_eq!(async_pending.len(), MAX_PENDING_PROMISES);
2560        assert_eq!(
2561            bridge_call_count(&async_writer.lock().unwrap()),
2562            MAX_PENDING_PROMISES
2563        );
2564        {
2565            let key = v8::String::new(scope, "__overflowPromise").unwrap();
2566            let value = context.global(scope).get(scope, key.into()).unwrap();
2567            let promise = v8::Local::<v8::Promise>::try_from(value).unwrap();
2568            assert_eq!(promise.state(), v8::PromiseState::Rejected);
2569            let rejection = promise.result(scope);
2570            let rejection = v8::Local::<v8::Object>::try_from(rejection).unwrap();
2571            let code_key = v8::String::new(scope, "code").unwrap();
2572            let code = rejection.get(scope, code_key.into()).unwrap();
2573            assert_eq!(
2574                code.to_rust_string_lossy(scope),
2575                "ERR_AGENTOS_BRIDGE_PENDING_PROMISE_LIMIT"
2576            );
2577        }
2578
2579        let reentrant_writer = Arc::new(Mutex::new(Vec::new()));
2580        let reentrant_bridge_ctx = BridgeCallContext::new(
2581            Box::new(SharedWriter(Arc::clone(&reentrant_writer))),
2582            Box::new(Cursor::new(Vec::new())),
2583            String::from("test-session"),
2584        );
2585        let reentrant_pending = PendingPromises::new();
2586        let _reentrant_async_bridge_fns = register_async_bridge_fns(
2587            scope,
2588            &reentrant_bridge_ctx as *const BridgeCallContext,
2589            &reentrant_pending as *const PendingPromises,
2590            &["_asyncFn"],
2591        );
2592        let source = format!(
2593            r#"
2594            for (let i = 0; i < {fill_count}; i++) {{
2595                _asyncFn(i);
2596            }}
2597            let innerPromise;
2598            const reentrantArg = {{}};
2599            Object.defineProperty(reentrantArg, "x", {{
2600                get() {{
2601                    innerPromise = _asyncFn("inner");
2602                    return 1;
2603                }},
2604                enumerable: true,
2605            }});
2606            globalThis.__reentrantOuterPromise = _asyncFn(reentrantArg);
2607            globalThis.__reentrantInnerPromise = innerPromise;
2608            "#,
2609            fill_count = MAX_PENDING_PROMISES - 1,
2610        );
2611        {
2612            let tc = &mut v8::TryCatch::new(scope);
2613            let source = v8::String::new(tc, &source).unwrap();
2614            let script = v8::Script::compile(tc, source, None).unwrap();
2615            assert!(script.run(tc).is_some());
2616            assert!(!tc.has_caught(), "async reentry should reject, not throw");
2617        }
2618        assert_eq!(reentrant_pending.len(), MAX_PENDING_PROMISES);
2619        assert_eq!(
2620            bridge_call_count(&reentrant_writer.lock().unwrap()),
2621            MAX_PENDING_PROMISES
2622        );
2623        {
2624            let key = v8::String::new(scope, "__reentrantInnerPromise").unwrap();
2625            let value = context.global(scope).get(scope, key.into()).unwrap();
2626            let promise = v8::Local::<v8::Promise>::try_from(value).unwrap();
2627            assert_eq!(promise.state(), v8::PromiseState::Rejected);
2628            let rejection = promise.result(scope);
2629            let rejection = v8::Local::<v8::Object>::try_from(rejection).unwrap();
2630            let code_key = v8::String::new(scope, "code").unwrap();
2631            let code = rejection.get(scope, code_key.into()).unwrap();
2632            assert_eq!(
2633                code.to_rust_string_lossy(scope),
2634                "ERR_AGENTOS_BRIDGE_PENDING_PROMISE_LIMIT"
2635            );
2636        }
2637
2638        let buffer_reentry_writer = Arc::new(Mutex::new(Vec::new()));
2639        let buffer_reentry_bridge_ctx = BridgeCallContext::new(
2640            Box::new(SharedWriter(Arc::clone(&buffer_reentry_writer))),
2641            Box::new(Cursor::new(Vec::new())),
2642            String::from("test-session"),
2643        );
2644        let buffer_reentry_pending = PendingPromises::new();
2645        let _buffer_reentry_async_bridge_fns = register_async_bridge_fns(
2646            scope,
2647            &buffer_reentry_bridge_ctx as *const BridgeCallContext,
2648            &buffer_reentry_pending as *const PendingPromises,
2649            &["_asyncFn"],
2650        );
2651        let source = r#"
2652            let bufferInnerPromise;
2653            const bufferReentrantArg = {};
2654            Object.defineProperty(bufferReentrantArg, "x", {
2655                get() {
2656                    bufferInnerPromise = _asyncFn("inner");
2657                    return 1;
2658                },
2659                enumerable: true,
2660            });
2661            globalThis.__bufferOuterPromise = _asyncFn(bufferReentrantArg);
2662            globalThis.__bufferInnerPromise = bufferInnerPromise;
2663        "#;
2664        {
2665            let tc = &mut v8::TryCatch::new(scope);
2666            let source = v8::String::new(tc, source).unwrap();
2667            let script = v8::Script::compile(tc, source, None).unwrap();
2668            assert!(script.run(tc).is_some());
2669            assert!(
2670                !tc.has_caught(),
2671                "async serialization reentry should not panic or throw"
2672            );
2673        }
2674        assert_eq!(buffer_reentry_pending.len(), 2);
2675        assert_eq!(bridge_call_count(&buffer_reentry_writer.lock().unwrap()), 2);
2676    }
2677
2678    #[test]
2679    fn vm_context_capacity_error_trips_at_registry_limit() {
2680        assert!(vm_context_capacity_error(MAX_VM_CONTEXTS - 1).is_none());
2681
2682        let error = vm_context_capacity_error(MAX_VM_CONTEXTS).expect("limit error");
2683        assert!(
2684            error.contains(&format!("limit of {MAX_VM_CONTEXTS} contexts")),
2685            "unexpected error: {error}"
2686        );
2687    }
2688
2689    // Regression test for the `VM_CONTEXTS` leak: `reserve_vm_context_slot` adds a
2690    // slot per `vm.createContext()`, but the success path never removes it. On a
2691    // reused isolate thread that made the registry grow without bound across
2692    // executions until it hit `MAX_VM_CONTEXTS` and every later `createContext()`
2693    // failed. With the fix, a session's `VmContextRegistryGuard` evicts the slots
2694    // at teardown, so the registry returns to empty between executions and the cap
2695    // is never reached. This asserts the safeguard FIRING (slots reclaimed), it
2696    // does not saturate any resource, so it stays in the default suite.
2697    //
2698    // Runs in a subprocess to match the V8-initializing test convention in this
2699    // module (one isolate per process, no cross-test V8 interference).
2700    #[test]
2701    fn vm_context_registry_evicts_slots_on_finalize() {
2702        const SUBPROCESS_ENV: &str = "AGENTOS_V8_VM_CONTEXT_FINALIZE_SUBPROCESS";
2703        if std::env::var_os(SUBPROCESS_ENV).is_none() {
2704            let output = Command::new(std::env::current_exe().expect("current test binary"))
2705                .arg("bridge::tests::vm_context_registry_evicts_slots_on_finalize")
2706                .arg("--exact")
2707                .arg("--nocapture")
2708                .env(SUBPROCESS_ENV, "1")
2709                .output()
2710                .expect("spawn vm context finalize subprocess");
2711            assert!(
2712                output.status.success(),
2713                "vm context finalize subprocess failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
2714                output.status.code(),
2715                String::from_utf8_lossy(&output.stdout),
2716                String::from_utf8_lossy(&output.stderr)
2717            );
2718            return;
2719        }
2720
2721        isolate::init_v8_platform();
2722        let mut isolate = isolate::create_isolate(None);
2723        let context = isolate::create_context(&mut isolate);
2724        let scope = &mut v8::HandleScope::new(&mut isolate);
2725        let context = v8::Local::new(scope, &context);
2726        let scope = &mut v8::ContextScope::new(scope, context);
2727
2728        reset_vm_context_registry();
2729        assert_eq!(
2730            vm_context_registry_len_for_test(),
2731            0,
2732            "registry starts empty"
2733        );
2734
2735        // Simulate many executions on the same reused isolate. Each execution
2736        // reserves several contexts and then finalizes (its session guard drops).
2737        // Run far past the hard cap: a leaked slot per createContext would exhaust
2738        // MAX_VM_CONTEXTS within the first ~341 executions and the `.expect` on the
2739        // next reservation would panic.
2740        const CONTEXTS_PER_EXECUTION: usize = 3;
2741        let executions = MAX_VM_CONTEXTS * 4;
2742        for execution in 0..executions {
2743            {
2744                let _guard = VmContextRegistryGuard::new();
2745                for _ in 0..CONTEXTS_PER_EXECUTION {
2746                    reserve_vm_context_slot(scope, context)
2747                        .expect("reserve vm context slot below cap; a leak would hit the cap");
2748                }
2749                assert_eq!(
2750                    vm_context_registry_len_for_test(),
2751                    CONTEXTS_PER_EXECUTION,
2752                    "slots reserved during execution {execution} must be live"
2753                );
2754            }
2755            // Guard dropped above -> finalize. Asserting here, before the next
2756            // execution's guard is constructed, proves the Drop sweep (not the
2757            // start-of-session sweep) reclaimed the slots.
2758            assert_eq!(
2759                vm_context_registry_len_for_test(),
2760                0,
2761                "registry must be empty after execution {execution} finalizes"
2762            );
2763        }
2764
2765        // After 4x the cap worth of reservations, a fresh createContext still
2766        // succeeds because nothing leaked across executions.
2767        let _guard = VmContextRegistryGuard::new();
2768        assert!(
2769            reserve_vm_context_slot(scope, context).is_ok(),
2770            "createContext must keep succeeding; leaked slots would have hit MAX_VM_CONTEXTS"
2771        );
2772    }
2773}