Skip to content

Commit 6e051d6

Browse files
authored
feat(bex): complete garbage collector implementation (#3003)
## Summary Complete the BEX garbage collector implementation by closing 5 identified gaps in the engine-level integration: - **Gap 1**: Parked VM roots now collected via VM pointer registry - **Gap 2**: VM stacks updated with forwarding pointers after GC - **Gap 3**: TLABs invalidated after GC space swap - **Gap 6**: Handle race condition fixed with EpochGuard ## Key Changes ### VM Pointer Registry (Phase 1-2) - Add `parked_vms: Mutex<Vec<VmPtr>>` to `EpochState` for tracking VMs at safepoints - VMs register pointers before parking, unregister after GC completes - GC collects roots from all parked VM stacks ### Stack Updates & TLAB Invalidation (Phase 3) - Add `collect_garbage_with_forwarding()` that returns the forwarding map - Update parked VM stacks with new object indices after GC moves objects - Invalidate TLABs so VMs allocate from the new space ### Handle Race Condition Fixes (Phase 4) - Remove cached `ObjectIndex` from `Handle` - always resolve through table - Add `gc_in_progress` flag to synchronize new calls with GC - New `call_function` invocations wait for in-progress GC before resolving handles ### EpochGuard & Accessor API (Phase 6) - Add `EpochGuard` token type for compile-time epoch protection enforcement - `Handle::object_index()` now requires `&EpochGuard<'_>` parameter - Add heap accessor API (`read_string`, `read_array`, `with_object`) for safe external access ### Documentation & Tests (Phase 5) - Add GC coordination documentation to module docs - Add e2e tests: `test_gc_updates_forwarding_pointers`, `test_multiple_handles_survive_gc` ## Test plan - [x] `cargo build -p bex_engine -p bex_heap -p bex_external_types` compiles - [x] `cargo test -p bex_engine` - all tests pass (including 5 GC tests) - [x] `cargo test -p bex_heap` - 28 tests pass - [x] `cargo test -p bex_external_types` - all tests pass - [x] `cargo clippy` passes - [x] Compile-time enforcement: `handle.object_index()` without guard fails to compile 🤖 Generated with [Claude Code](https://claude.ai/code)
1 parent 4b2e6b1 commit 6e051d6

10 files changed

Lines changed: 650 additions & 67 deletions

File tree

baml_language/crates/bex_engine/src/lib.rs

Lines changed: 177 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,37 @@
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
2444
use 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

3252
use baml_snapshot::BamlSnapshot;
33-
pub use bex_external_types::{ExternalValue, Snapshot};
53+
pub use bex_external_types::{EpochGuard, ExternalValue, Snapshot};
3454
use bex_heap::BexHeap;
3555
// Re-export GcStats for users of the engine
3656
pub 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.
5993
struct 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

66110
impl 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

189237
impl 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

Comments
 (0)