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.
Summary
Fiddle::Closurehands libffi the rawVALUEof the Closure object as the trampoline's user-data, and casts it back on every invocation.closure_data_typedeclares.dmark = 0and no.dcompact, so nothing keeps that object in place. If GC compaction relocates the Closure betweeninitializeand a later call through the trampoline, libffi passes back a dead address and the process segfaults.Reproduction
Actual — 3/3 runs:
Expected, and what you get with the
GC.verify_compaction_referencesline removed (I ran that as a control):42.Fiddle::Closure::BlockCallerandFiddle::Importer#bindfail identically — e.g. passing aBlockCallerto libcqsortafter compaction segfaults at the same address.Cause
ext/fiddle/closure.c(line numbers from master):TypedData_Make_Structleaves the Closure movable. Being reachable keeps it alive but not in place; after relocationx->ctxrefers to whatever now occupies the address, andrb_iv_geton it is a wild read.Worth noting that fiddle already knows about this hazard class:
Fiddle::Pinnedexists precisely becauseFiddle.dlwrap/Fiddle::Pointer#to_valuestore an unpinnedVALUEas an integer, and wrapping the object inFiddle::Pinnedis the documented mitigation.Closureuses the same unsafe primitive internally, where a user has no opportunity to apply that mitigation.Ordinary
GC.compactdoes not reliably reproduce it — the object has to actually be relocated, whichverify_compaction_referencesforces and whichGC.auto_compact = truemakes possible in the wild.Proposed fix
Hand libffi the malloc'd
fiddle_closure *, which never moves, keep the Closure'sVALUEin 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); #endifI built this against fiddle 1.1.8 and verified it:
Closure::BlockCallerandImporter#bindthroughqsortboth pass under compactionrb_gc_mark_movable+dcompactavoids pinning; plainrb_gc_markwould 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
Present on master as of today:
.dmark = 0atclosure.c:60, the cast-back at:79, and theffi_prep_closure_loccall 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).
ffiis not affected — it already passes a malloc'dClosure *as user-data and relocates the Ruby proc in its compact callbacks, which is the same shape as the fix proposed here.