Skip to content

Fiddle::Closure segfaults after GC compaction: raw VALUE handed to libffi as closure user-data #211

Description

@jeremy

Summary

Fiddle::Closure hands libffi the raw VALUE of the Closure object as the trampoline's user-data, and casts it back on every invocation. closure_data_type declares .dmark = 0 and no .dcompact, so nothing keeps that object in place. If GC compaction relocates the Closure between initialize and a later call through the trampoline, libffi passes back a dead address and the process segfaults.

Reproduction

require "fiddle"

class Adder < Fiddle::Closure
  def call(a, b) = a + b
end

# Parked off-stack on purpose: a local variable is conservatively pinned by the
# machine-stack scan, which masks the bug.
$holder = [Adder.new(Fiddle::TYPE_INT, [Fiddle::TYPE_INT, Fiddle::TYPE_INT])]

GC.verify_compaction_references(expand_heap: true, toward: :empty)

closure = $holder[0]
func = Fiddle::Function.new(closure.to_i, [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
p func.call(40, 2)

Actual — 3/3 runs:

[BUG] Segmentation fault at 0x0000000000000004
...(with_gvl_callback+0x4c) closure.c:81
c:0003 p:---- s:0014 e:000013 l:y b:0001 CFUNC  :call

Expected, and what you get with the GC.verify_compaction_references line removed (I ran that as a control): 42.

Fiddle::Closure::BlockCaller and Fiddle::Importer#bind fail identically — e.g. passing a BlockCaller to libc qsort after compaction segfaults at the same address.

Cause

ext/fiddle/closure.c (line numbers from master):

// :60   nothing marks, pins, or relocates the stored copy
.dmark = 0,

// :320  the Closure's own VALUE becomes libffi's user-data
result = ffi_prep_closure_loc(pcl, cif, callback,
                              (void *)(data->self), cl->code);

// :79   cast straight back on every invocation
VALUE self      = (VALUE)x->ctx;
VALUE rbargs    = rb_iv_get(self, "@args");

TypedData_Make_Struct leaves the Closure movable. Being reachable keeps it alive but not in place; after relocation x->ctx refers to whatever now occupies the address, and rb_iv_get on it is a wild read.

Worth noting that fiddle already knows about this hazard class: Fiddle::Pinned exists precisely because Fiddle.dlwrap / Fiddle::Pointer#to_value store an unpinned VALUE as an integer, and wrapping the object in Fiddle::Pinned is the documented mitigation. Closure uses the same unsafe primitive internally, where a user has no opportunity to apply that mitigation.

Ordinary GC.compact does not reliably reproduce it — the object has to actually be relocated, which verify_compaction_references forces and which GC.auto_compact = true makes possible in the wild.

Proposed fix

Hand libffi the malloc'd fiddle_closure *, which never moves, keep the Closure's VALUE in that struct, and mark/relocate it.

 typedef struct {
+    VALUE self;
     void * code;
     ffi_closure *pcl;
     ffi_cif cif;
     int argc;
     ffi_type **argv;
 } fiddle_closure;

+static void
+closure_mark(void *ptr)
+{
+    fiddle_closure *cl = ptr;
+    rb_gc_mark_movable(cl->self);
+}
+
+static void
+closure_compact(void *ptr)
+{
+    fiddle_closure *cl = ptr;
+    cl->self = rb_gc_location(cl->self);
+}
+
 const rb_data_type_t closure_data_type = {
     .wrap_struct_name = "fiddle/closure",
     .function = {
-        .dmark = 0,
+        .dmark = closure_mark,
         .dfree = dealloc,
-        .dsize = closure_memsize
+        .dsize = closure_memsize,
+        .dcompact = closure_compact
     },

     TypedData_Get_Struct(data->self, fiddle_closure, &closure_data_type, cl);
+
+    /* libffi is handed &cl, which never moves; cl->self is what the
+     * trampoline reads back, so it must be marked and relocated. */
+    RB_OBJ_WRITE(data->self, &cl->self, data->self);

-    VALUE self      = (VALUE)x->ctx;
+    VALUE self      = ((fiddle_closure *)x->ctx)->self;

 #if USE_FFI_CLOSURE_ALLOC
     result = ffi_prep_closure_loc(pcl, cif, callback,
-                                  (void *)(data->self), cl->code);
+                                  (void *)cl, cl->code);
 #else
-    result = ffi_prep_closure(pcl, cif, callback, (void *)(data->self));
+    result = ffi_prep_closure(pcl, cif, callback, (void *)cl);
 #endif

I built this against fiddle 1.1.8 and verified it:

  • the reproduction above passes 3/3 instead of segfaulting, and the control still passes
  • Closure::BlockCaller and Importer#bind through qsort both pass under compaction
  • the upstream test suite is unchanged: 227 tests, 608 assertions, 0 failures, 1 error, 3 omissions — byte-identical to the same suite run against stock 1.1.8 on this machine (that 1 error is pre-existing, not introduced)

rb_gc_mark_movable + dcompact avoids pinning; plain rb_gc_mark would also close the bug with a smaller diff, at the cost of pinning one object per live Closure.

Happy to send this as a PR with a regression test, if you'd like it in that form.

Environment

fiddle 1.1.8
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23]

Present on master as of today: .dmark = 0 at closure.c:60, the cast-back at :79, and the ffi_prep_closure_loc call at :320-321.

Context

Found while auditing this bug class across C extensions after fixing the equivalent problem in sqlite3-ruby (sparklemotion/sqlite3-ruby#723). Same root cause has now been confirmed in psych (ruby/psych#811) and nokogiri (sparklemotion/nokogiri#3665). ffi is not affected — it already passes a malloc'd Closure * as user-data and relocates the Ruby proc in its compact callbacks, which is the same shape as the fix proposed here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions