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