Thread.handle_interrupt checks for interrupts after its block has returned but before re-raising the break/return that left the block. A signal trap that runs in that window clears ec->errinfo, which is where the throw data for the pending break/return lives, so the VM throws nil.
This small script reproduces the SEGV very quickly:
This is similar to https://bugs.ruby-lang.org/issues/22240 except that the same fix can be applied inside Ruby itself (instead of the fault being from native extensions).
[Bug #22415] Restore ec->errinfo after handling interrupts
Interrupts are checked at arbitrary points, including while a jump
(break/next/redo/return/raise/throw) is being propagated, and the payload
of that jump lives in ec->errinfo.
Handling an interrupt can run Ruby code: rb_signal_exec() runs a trap
handler and threadptr_interrupt_exec_exec() runs interrupt_exec tasks.
Neither preserves ec->errinfo, and any rescue or ensure clause that code
enters sets it to nil.
Thread.handle_interrupt checks for interrupts after its block returns but
before re-raising the jump that left the block. If a signal handler runs
in that window the throw data is replaced with nil and the VM dereferences
nil as a vm_throw_data:
THROW_DATA_CATCH_FRAME (obj=0x4) at vm_insnhelper.h:217
vm_exec_handle_exception (ec=..., errinfo=4) at vm.c:2880
vm_exec_loop / rb_vm_exec at vm.c:2829 / vm.c:2812
invoke_iseq_block_from_c / rb_yield / rb_ary_each
rb_postponed_job_flush() and rb_exec_event_hooks() already save and restore
ec->errinfo around the Ruby code they run; do the same for the rest of
rb_threadptr_execute_interrupts(). The paths that set errinfo on purpose
(rb_exc_raise, rb_threadptr_to_kill) jump out of the function and so do not
reach the restore.