2020//! Resources (file handles, connections, etc.) are stored in a `ResourceRegistry`.
2121//! External ops can store resources and return their ID to the VM. Later ops
2222//! can retrieve resources by ID. The VM only sees integer IDs.
23+ //!
24+ //! # Garbage Collection Coordination
25+ //!
26+ //! The engine coordinates GC using an epoch-based system:
27+ //!
28+ //! 1. **Epoch tracking**: Each `call_function` registers with the current epoch
29+ //! 2. **GC trigger**: `collect_garbage()` increments epoch, causing old-epoch VMs to park
30+ //! 3. **Safe collection**: Once all VMs park, GC collects roots from:
31+ //! - Handle table (objects returned to external code)
32+ //! - Parked VM stacks (via VM pointer registry)
33+ //! 4. **Stack update**: GC updates parked VM stacks with forwarding pointers
34+ //! 5. **TLAB invalidation**: Parked VMs get TLABs invalidated before resuming
35+ //! 6. **Resume**: `gc_complete.notify_waiters()` releases parked VMs
36+ //!
37+ //! ## Safety Invariants
38+ //!
39+ //! - VMs register pointers before parking, unregister after waking
40+ //! - GC only accesses VM stacks while holding `parked_vms` lock
41+ //! - Handles always resolve through table (no cached indices)
42+ //! - New calls wait for in-progress GC before processing handle args
2343
2444use std:: {
2545 collections:: HashMap ,
2646 sync:: {
27- Arc ,
28- atomic:: { AtomicU64 , AtomicUsize , Ordering } ,
47+ Arc , Mutex ,
48+ atomic:: { AtomicBool , AtomicU64 , AtomicUsize , Ordering } ,
2949 } ,
3050} ;
3151
3252use baml_snapshot:: BamlSnapshot ;
33- pub use bex_external_types:: { ExternalValue , Snapshot } ;
53+ pub use bex_external_types:: { EpochGuard , ExternalValue , Snapshot } ;
3454use bex_heap:: BexHeap ;
3555// Re-export GcStats for users of the engine
3656pub use bex_heap:: GcStats ;
@@ -54,20 +74,45 @@ struct FutureResult {
5474 result : Result < ResolvedValue , EngineError > ,
5575}
5676
77+ /// Wrapper for VM pointer that implements Send.
78+ ///
79+ /// # Safety
80+ ///
81+ /// This is safe because:
82+ /// - The pointer is only used while holding the `parked_vms` lock
83+ /// - We only dereference when all VMs are parked at safepoints
84+ /// - The VM lives on the async task's stack and won't move/drop while parked
85+ struct VmPtr ( * const BexVm ) ;
86+
87+ // SAFETY: We control all access through the mutex and only use while VMs are parked
88+ #[ allow( unsafe_code) ]
89+ unsafe impl Send for VmPtr { }
90+
5791/// State for a single epoch slot.
5892/// Used to track VMs that started in a particular epoch.
5993struct EpochState {
6094 /// Number of VMs started in this epoch that haven't completed.
6195 active : AtomicUsize ,
6296 /// Number of VMs parked waiting for GC.
6397 parked : AtomicUsize ,
98+ /// Pointers to parked VMs for root collection during GC.
99+ ///
100+ /// # Safety
101+ ///
102+ /// These raw pointers are valid because:
103+ /// - VM is borrowed from `call_function`'s stack frame
104+ /// - `.await` on `gc_complete` suspends but doesn't drop the VM
105+ /// - GC only reads/writes while all VMs are parked
106+ /// - VM unregisters before resuming execution
107+ parked_vms : Mutex < Vec < VmPtr > > ,
64108}
65109
66110impl EpochState {
67111 fn new ( ) -> Self {
68112 Self {
69113 active : AtomicUsize :: new ( 0 ) ,
70114 parked : AtomicUsize :: new ( 0 ) ,
115+ parked_vms : Mutex :: new ( Vec :: new ( ) ) ,
71116 }
72117 }
73118}
@@ -184,6 +229,9 @@ pub struct BexEngine {
184229 epoch_drained : Notify ,
185230 /// Notified when GC completes and parked VMs can resume.
186231 gc_complete : Notify ,
232+ /// Flag indicating GC is currently in progress.
233+ /// Used to prevent handle resolution races.
234+ gc_in_progress : AtomicBool ,
187235}
188236
189237impl BexEngine {
@@ -217,6 +265,7 @@ impl BexEngine {
217265 epoch_states : [ EpochState :: new ( ) , EpochState :: new ( ) ] ,
218266 epoch_drained : Notify :: new ( ) ,
219267 gc_complete : Notify :: new ( ) ,
268+ gc_in_progress : AtomicBool :: new ( false ) ,
220269 } )
221270 }
222271
@@ -279,15 +328,26 @@ impl BexEngine {
279328 }
280329
281330 /// Convert a handle to a snapshot.
331+ ///
332+ /// This is safe for external code to call (no `EpochGuard` needed) because
333+ /// we hold the handle table read lock for the entire operation, preventing
334+ /// GC from moving objects while we're snapshotting.
282335 fn snapshot_handle (
283336 & self ,
284337 handle : & bex_external_types:: Handle ,
285338 ) -> Result < Snapshot , EngineError > {
286- let idx = self
287- . heap ( )
288- . resolve_handle ( handle)
289- . expect ( "Handle is a GC root - object should never be collected" ) ;
290- self . snapshot_object ( idx)
339+ // Hold the handles read lock for the entire snapshot operation.
340+ // This prevents GC from running update_handles (which needs write lock),
341+ // ensuring all ObjectIndex values remain valid during recursive snapshotting.
342+ //
343+ // The GcProtectedHeap guard ensures resolve_handle can only be called
344+ // while the lock is held - you can't accidentally use it unsafely.
345+ self . heap . with_gc_protection ( |protected| {
346+ let idx = protected
347+ . resolve_handle ( handle. slab_key ( ) )
348+ . expect ( "Handle is a GC root - object should never be collected" ) ;
349+ self . snapshot_object ( idx)
350+ } )
291351 }
292352
293353 /// Convert an object at the given index to a snapshot.
@@ -421,6 +481,9 @@ impl BexEngine {
421481 ///
422482 /// Statistics about the collection (live count, collected count, etc.)
423483 pub async fn collect_garbage ( & self ) -> bex_heap:: GcStats {
484+ // Signal GC starting - new calls will wait
485+ self . gc_in_progress . store ( true , Ordering :: Release ) ;
486+
424487 // Increment epoch - new calls get the new epoch
425488 let gc_epoch = self . current_epoch . fetch_add ( 1 , Ordering :: SeqCst ) ;
426489 let slot = ( gc_epoch % 2 ) as usize ;
@@ -444,23 +507,62 @@ impl BexEngine {
444507 }
445508
446509 // Collect roots from handles (objects returned to external code)
447- // These must be preserved during GC.
448- let handle_roots = self . heap . collect_handle_roots ( ) ;
510+ let mut all_roots = self . heap . collect_handle_roots ( ) ;
449511
450- tracing:: debug!( "GC: {} handle roots collected" , handle_roots. len( ) ) ;
512+ // Acquire parked_vms lock - hold it through GC to update stacks
513+ let parked_vms = self . epoch_states [ slot] . parked_vms . lock ( ) . unwrap ( ) ;
451514
452- // Note: For a complete implementation, we would also collect roots from parked VMs.
453- // For now, we only use handle roots. Parked VMs would need their stacks updated
454- // with remapped indices after GC.
515+ // SAFETY: All VMs are parked (verified above), so we have exclusive read access
516+ // to their stacks. The parked_vms vec contains valid pointers because VMs
517+ // register before parking and unregister only after gc_complete is notified.
518+ #[ allow( unsafe_code) ]
519+ for vm_ptr in parked_vms. iter ( ) {
520+ let vm = unsafe { & * vm_ptr. 0 } ;
521+ all_roots. extend ( Self :: collect_vm_roots ( vm) ) ;
522+ }
523+
524+ tracing:: debug!(
525+ "GC: {} total roots from {} handles and {} parked VMs" ,
526+ all_roots. len( ) ,
527+ self . heap. stats( ) . active_handles,
528+ parked_vms. len( )
529+ ) ;
530+
531+ // Run GC with forwarding map
532+ #[ allow( unsafe_code) ]
533+ let ( stats, _remapped_roots, forwarding) =
534+ unsafe { self . heap . collect_garbage_with_forwarding ( & all_roots) } ;
455535
456- // Run GC with handle roots
536+ // Update all parked VM stacks with forwarding pointers and invalidate TLABs
537+ // SAFETY: VMs are still parked (gc_complete not yet notified), we have
538+ // exclusive access via the parked_vms lock we're still holding
457539 #[ allow( unsafe_code) ]
458- let ( stats, _remapped_roots) = unsafe { self . heap . collect_garbage ( & handle_roots) } ;
540+ for vm_ptr in parked_vms. iter ( ) {
541+ let vm = unsafe { & mut * vm_ptr. 0 . cast_mut ( ) } ;
542+
543+ // Update stack values
544+ for value in & mut vm. stack . 0 {
545+ if let Value :: Object ( idx) = value {
546+ if let Some ( & new_idx) = forwarding. get ( idx) {
547+ * idx = new_idx;
548+ }
549+ }
550+ }
551+
552+ // Invalidate TLAB so next allocation gets chunk from new space
553+ vm. tlab . invalidate ( ) ;
554+ }
555+
556+ // Release lock before notifying waiters
557+ drop ( parked_vms) ;
459558
460559 // Reset epoch state for reuse
461560 self . epoch_states [ slot] . active . store ( 0 , Ordering :: Release ) ;
462561 self . epoch_states [ slot] . parked . store ( 0 , Ordering :: Release ) ;
463562
563+ // Signal GC complete before releasing parked VMs
564+ self . gc_in_progress . store ( false , Ordering :: Release ) ;
565+
464566 // Release parked VMs
465567 self . gc_complete . notify_waiters ( ) ;
466568
@@ -509,6 +611,12 @@ impl BexEngine {
509611 function_name : & str ,
510612 args : & [ ExternalValue ] ,
511613 ) -> Result < ExternalValue , EngineError > {
614+ // Wait for any in-progress GC to complete.
615+ // This ensures Handles in args have stable indices.
616+ while self . gc_in_progress . load ( Ordering :: Acquire ) {
617+ self . gc_complete . notified ( ) . await ;
618+ }
619+
512620 // Look up the function to verify it exists
513621 let function_index = self . lookup_function ( function_name) ?;
514622
@@ -519,6 +627,10 @@ impl BexEngine {
519627 . active
520628 . fetch_add ( 1 , Ordering :: AcqRel ) ;
521629
630+ // SAFETY: We just registered with the epoch above
631+ #[ allow( unsafe_code) ]
632+ let guard = unsafe { EpochGuard :: new ( ) } ;
633+
522634 // Create VM with shared heap (each VM gets its own TLAB)
523635 let mut vm = BexVm :: new (
524636 Arc :: clone ( & self . heap ) ,
@@ -529,7 +641,7 @@ impl BexEngine {
529641 // Convert ExternalValue args to Value, allocating Snapshots on the heap
530642 let vm_args: Vec < Value > = args
531643 . iter ( )
532- . map ( |arg| Self :: externalize_to_value ( & mut vm, arg) )
644+ . map ( |arg| Self :: externalize_to_value ( & mut vm, arg, & guard ) )
533645 . collect ( ) ;
534646
535647 // Set entry point with converted args
@@ -556,11 +668,24 @@ impl BexEngine {
556668
557669 /// Convert an `ExternalValue` to a VM `Value`.
558670 ///
671+ /// Requires `EpochGuard` because resolving handles returns an `ObjectIndex`
672+ /// that must remain valid while we use it.
673+ ///
559674 /// - `Object(Handle)` extracts the `ObjectIndex`
560675 /// - `Snapshot(...)` recursively allocates on the heap
561- fn externalize_to_value ( vm : & mut BexVm , external : & ExternalValue ) -> Value {
676+ fn externalize_to_value (
677+ vm : & mut BexVm ,
678+ external : & ExternalValue ,
679+ guard : & EpochGuard < ' _ > ,
680+ ) -> Value {
562681 match external {
563- ExternalValue :: Object ( handle) => Value :: Object ( handle. object_index ( ) ) ,
682+ ExternalValue :: Object ( handle) => {
683+ // Resolve through table to get current index after any GC
684+ let idx = handle
685+ . object_index ( guard)
686+ . expect ( "Handle should be valid - object was returned to external code" ) ;
687+ Value :: Object ( idx)
688+ }
564689 ExternalValue :: Snapshot ( snapshot) => Self :: allocate_snapshot ( vm, snapshot) ,
565690 }
566691 }
@@ -628,20 +753,32 @@ impl BexEngine {
628753 }
629754
630755 /// Run GC if conditions are met (called at safepoints).
631- fn maybe_run_gc ( & self , vm : & BexVm ) {
756+ fn maybe_run_gc ( & self , vm : & mut BexVm ) {
632757 if self . heap . should_gc ( ) {
633758 let roots = Self :: collect_vm_roots ( vm) ;
634759 #[ allow( unsafe_code) ]
635760 unsafe {
636- let ( stats, _remapped_roots) = self . heap . collect_garbage ( & roots) ;
761+ let ( stats, _remapped_roots, forwarding) =
762+ self . heap . collect_garbage_with_forwarding ( & roots) ;
763+
764+ // Update VM stack with forwarding pointers
765+ for value in & mut vm. stack . 0 {
766+ if let Value :: Object ( idx) = value {
767+ if let Some ( & new_idx) = forwarding. get ( idx) {
768+ * idx = new_idx;
769+ }
770+ }
771+ }
772+
773+ // Invalidate TLAB so next allocation gets chunk from new space
774+ vm. tlab . invalidate ( ) ;
775+
637776 self . heap . reset_gc_counter ( ) ;
638777 tracing:: debug!(
639- "GC completed: {} live, {} collected, {} handles invalidated " ,
778+ "GC completed: {} live, {} collected" ,
640779 stats. live_count,
641- stats. collected_count,
642- stats. handles_invalidated
780+ stats. collected_count
643781 ) ;
644- // TODO: Phase 5/6 - Update VM stack with remapped roots
645782 }
646783 }
647784 }
@@ -724,6 +861,14 @@ impl BexEngine {
724861 // GC has been requested - we need to park
725862 let slot = ( my_epoch % 2 ) as usize ;
726863
864+ // Register VM pointer before parking
865+ // SAFETY: VM lives on our async task's stack and won't be dropped
866+ // until after we unregister (after gc_complete.notified().await returns)
867+ {
868+ let mut parked_vms = self . epoch_states [ slot] . parked_vms . lock ( ) . unwrap ( ) ;
869+ parked_vms. push ( VmPtr ( std:: ptr:: from_ref ( vm) ) ) ;
870+ }
871+
727872 // Increment parked count and notify GC
728873 self . epoch_states [ slot]
729874 . parked
@@ -734,6 +879,13 @@ impl BexEngine {
734879 // Note: GC will update our VM's stack with new object indices
735880 self . gc_complete . notified ( ) . await ;
736881
882+ // Unregister VM pointer after waking
883+ {
884+ let mut parked_vms = self . epoch_states [ slot] . parked_vms . lock ( ) . unwrap ( ) ;
885+ let vm_ptr = std:: ptr:: from_ref ( vm) ;
886+ parked_vms. retain ( |p| p. 0 != vm_ptr) ;
887+ }
888+
737889 // Decrement parked count
738890 self . epoch_states [ slot]
739891 . parked
0 commit comments