FFI Handle Lifecycle — Debug Notes
The Bug (#271)
Creating and disposing 2+ MontyRuntime instances in the same process,
then creating a 3rd and calling an HTTP host function, causes a SEGFAULT.
Root Cause: NativeFinalizer Race
Subsystems involved
-
Rust
MontyHandle(native/src/handle.rs) — opaque handle holding interpreter state. Created bymonty_create, freed bymonty_free. -
Rust
LIVE_HANDLES(native/src/lib.rs:124) — globalHashSet<usize>tracking live handle pointers. Prevents double-free. -
Dart
FfiCoreBindings(dart_monty_core/lib/src/ffi/ffi_core_bindings.dart) — adapts FFI bindings to the core interface. Owns_handle(int) and_guard(_HandleGuard). -
Dart
_HandleGuard(line 18) —Finalizabletoken attached to aNativeFinalizer. When GC'd without explicit detach, the finalizer callsmonty_freeon the handle address. -
Dart
_handleFinalizer(line 28) —NativeFinalizerbacked bymonty_free. Attached per handle, detached on explicit free. -
Dart
BaseMontyPlatform(lib/src/platform/base_monty_platform.dart) — calls_bindings.dispose()on platform disposal. -
Dart
MontyRuntime(lib/src/runtime/runtime.dart) — createsMontyRepl→MontyFfi→FfiCoreBindings. Dispose chain:session.dispose()→bridge.dispose()→repl.dispose()→platform.dispose()→bindings.dispose()→_freeHandle().
The race condition
Session 1:
monty_create() → handle_A at address 0x1000
_storeHandle(0x1000) → guard_1 attached to finalizer
_freeHandle(0x1000):
detach(guard_1) → OK
monty_free(0x1000) → freed, removed from LIVE_HANDLES
dispose() complete
Session 2:
monty_create() → handle_B at address 0x2000
[same lifecycle as Session 1]
monty_free(0x2000) → freed
** GC runs: guard_1 and guard_2 collected. Finalizer may fire. **
** LIVE_HANDLES check prevents double-free — but what about: **
Session 3:
monty_create() → handle_C at address 0x1000 (REUSED!)
_storeHandle(0x1000) → guard_3 attached
** GC finalizer for guard_1 fires (delayed): **
** monty_free(0x1000) → LIVE_HANDLES says it's live! **
** Frees handle_C (Session 3's active handle!) **
** → Session 3's next monty_resume hits freed memory → SEGFAULT **
Why address reuse matters
Rust's allocator (jemalloc or system) reuses freed memory addresses.
After monty_free(0x1000) deallocates handle_A, a subsequent
monty_create() may allocate handle_C at the same address 0x1000.
The LIVE_HANDLES set sees 0x1000 as a valid live handle (it was
re-inserted by Session 3's monty_create). The stale finalizer's
monty_free(0x1000) passes the LIVE_HANDLES check and frees Session 3's
handle while it's still in use.
Fix options
Option A: Detach finalizer using a unique token (not the guard itself)
// Instead of:
_handleFinalizer.attach(guard, pointer); // token is guard
_handleFinalizer.detach(guard); // might not work if GC'd
// Use:
final token = Object();
_handleFinalizer.attach(guard, pointer, detach: token);
_handleFinalizer.detach(token); // works even if guard is GC'd
Option B: Use a generation counter in LIVE_HANDLES
Instead of HashSet<usize>, use HashMap<usize, u64> with a monotonic
generation. Each monty_create increments the generation. The finalizer
must match both address AND generation.
Option C: Never rely on NativeFinalizer for handle cleanup
Remove the _HandleGuard / NativeFinalizer entirely. Rely solely on
explicit dispose() calls. Leaked handles are a memory issue, not a
crash issue.
Recommended: Option A
Minimal change, keeps the GC safety net, prevents the race.