Bug #22499
openSIGSEGV in vm_exec_core inside Monitor#synchronize (4.1.0preview1)
Description
Summary¶
Ruby 4.1.0preview1 segfaults inside a Monitor#synchronize block while
dispatching an instruction in vm_exec_core. The crash is reproducible and
three core dumps show the same shape: the VM calls a code address that
lives in a writable, non-executable page.
Environment¶
ruby 4.1.0preview1 (2026-10-11 master 2d08b5f42f) +PRISM
[x86_64-freebsd16.0]
system FreeBSD 16.0-CURRENT, x86_64
built with ruby-install 0.10.1, rbenv, no JIT (yjit not enabled)
app que worker process: "que ./config"
(Foreman-launched; threads named worker.rb:41,
locker.rb:180, task.rb:157, "AR Pool Reaper")
gems pg (pg_ext.so), active_record, que
Reproduction¶
Reproduces on every run of the worker, typically within minutes:
cd ~/projects/r.uby.dev
RBENV_VERSION=4.1.0-preview1-debug \
RUBY_DEBUG=1 \
MALLOC_CONF=junk:true \
bundle exec foreman start
Cores are written to /var/coredumps/1001:
- ruby.39219.core 22:53
- ruby.84525.core 23:08
- ruby.41759.core 23:23
Backtrace (debug build, -O0 -g3, gdb)¶
#2 ruby_default_signal (sig=11) at signal.c:421
#3 rb_bug_for_fatal_signal (sig=11, fmt="Segmentation fault at %p%s")
at error.c:1171
#4 sigsegv (sig=11, info=0x826129830, ctx=0x8261294c0) at signal.c:977
#5 libthr.so.3 signal trampoline
#7 <signal handler called>
#8 0x0000000827846220 in ?? () <- heap
#9 0x000000084dcb9758 in ?? () <- the ec
#10 0x0000000827945d60 in ?? () <- heap
#11 vm_exec_core (ec=0x84dcb9758) at insns.def:864
864 JIT_EXEC(ec, val);
#12 rb_vm_exec (ec=0x84dcb9758) at vm.c:2926
#13 invoke_iseq_block_from_c (...) at vm.c:1934
#14 invoke_block_from_c_bh (...) at vm.c:1948
#15 vm_yield_with_cref (...) at vm.c:1991
#16 vm_yield (...) at vm.c:1999
#17 rb_ec_yield (ec=0x84dcb9758, val=36) at vm_eval.c:1392
#18 do_ec_yield (_ec=35664926552) at thread_sync.c:635
#19 rb_ec_ensure (b_proc=do_ec_yield, e_proc_arg=monitor_sync_ensure)
at eval.c:1177
#20 rb_monitor_synchronize (monitor=35684680768) at thread_sync.c:1514
Control frame at the fault:
cfp = { pc = 0x847848b00, sp = 0x827846470, _iseq = 0x81b7683e0,
self = 35684811280, ep = 0x827846468, block_code = 0x0,
jit_return = 0x81d860126 }
Ruby-level frame recovered from the iseq location:
location = { pathobj = 34995792800, label = 34849573248,
first_lineno = 52, node_id = 252,
code_location = { beg_pos = {lineno = 52, column = 32},
end_pos = {lineno = 55, column = 11} } }
which is the body of Turn::Watch#follow in the application:
def follow(agent_id)
start!
queue = Queue.new
synchronize { watchers(agent_id) << queue } <- crashed here
queue
end
def synchronize(&b)
MUTEX.synchronize(&b)
end
Evidence of memory corruption¶
-
The faulting instruction pointer is not in any loaded image. From the
first core:mc_rip = 0x826449d80
memory region 0x826449d80 -> [0x8263a7000-0x8264f8000) rw-A writable, non-executable page: the VM called a value that is not code.
-
The iseq's path object is corrupt in the core. It reports len = 125 but
its heap pointer reads back as 0x6f722f656d6f682f, which is the ASCII
text "/home/ro" interpreted as an address:{basic = {flags = 7534522177132955941, klass = 34820395680},
len = 125,
as = {heap = {ptr = 0x6f722f656d6f682f , ...}}}That run was started with MALLOC_CONF=junk:true, so freed objects are
filled with junk; this looks like a freed or overwritten object. -
Frame #7's return address is 0x00007ffffffff2e3 in the earlier cores - a
sentinel, not a real return address.
Context: ractors and llm.rb¶
The process runs llm.rb (gem "llm.rb", github: "r-uby-dev/llm") as its agent
runtime, and the workload that triggers this spawns tool calls in child
ractors. From the application's stream handler:
def on_tool_call(tool)
write(tool: tool.name)
stop_if_cancelled!
queue << (tool.mcp? ? tool.task(:ractor) :
tool.task(:async))
end
so MCP tool calls run on ractors while an agent turn is streaming, and the
crashing path is the SSE route that follows a turn:
That is the same process, same threads, same time as the ractor-spawned
tool calls. Ractor machinery is visible in the first core - a scheduler
thread parked in:
rb_native_cond_wait
ractor_sched_deq (thread_sched.c:1388)
nt_shared_loop (thread_sched.c:2074)
nt_start (thread_sched.c:2021)
Caveat, stated plainly: the faulting thread is a worker thread inside
Monitor#synchronize, not a ractor body, and we have not reproduced this
without llm.rb or without ractors in the process. What we can say is that
the crash happens in a process that is running ractor-spawned tool calls
concurrently, on a 4.1 preview whose scheduler is Ractor-aware.
Other threads¶
None of the other 12 threads are in the faulting path. They are parked in
syscalls: _umtx_op_err (condvars/mutexes), __sys_kevent, __sys_ppoll, plus
pg_ext.so threads in pgconn_wait_for_notify waiting for a socket. They
inherit the process SIGSEGV, they did not raise it.
Updated by altruby (Alt Ruby) 8 days ago
I'm not sure the JIT isn't involved.
If I run
'unset RUBY_YJIT_ENABLE'
and restart the process it doesn't appear
possible to reproduce. I confirmed YJIT
was enabled on that ruby (+YJIT is in -v).
Then once I set RUBY_YJIT_ENABLE again
I can reproduce. So pretty sure it is
related to the JIT.
Updated by altruby (Alt Ruby) 7 days ago
Another useful bit of information, maybe.
If I don't spawn mcp tools with Ractor and
use anything else instead, I cannot reproduce.
The SIGSEGV is 100% reliable. I just have to
run tool calls in a Ractor and it will happen.
However, it takes usually 5 or 6 of them to
run successfully first.
I don't have a minimal reproduction yet.
Updated by altruby (Alt Ruby) 7 days ago
Updated by altruby (Alt Ruby) 7 days ago
This is the most minimal example I have
found so far and it reproduces.
It requires the JIT. But it also requires
access to an LLM provider (i use deepseek)
and a GitHub Personal Access token.
If I can narrow it further or someone else
can I'll update the issue.
Confirmed on
reproducation.rb
Set $DEEPSEEK_API_KEY and $GITHUB_PAT.
Or $OPENAI_API_KEY, etc.
# frozen_string_literal: true
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "llm.rb", github: "r-uby-dev/llm"
end
require "llm"
require "monitor"
require "securerandom"
class Stream < LLM::Stream
def on_tool_call(tool)
if tool.mcp?
puts "queue #{tool.name} on ractor"
queue << tool.task(:ractor)
else
puts "queue #{tool.name} on thread"
queue << tool.task(:thread)
end
end
end
llm = LLM.deepseek
mcp = LLM::MCP.http(
url: "https://api.githubcopilot.com/mcp",
headers: {"Authorization" => "Bearer #{ENV['GITHUB_PAT']}"}
)
agent = LLM::Agent.new(llm, tools: mcp.tools, stream: Stream.new)
agent.talk "what's happening on ruby/ruby?"
Run it
RUBY_YJIT_ENABLE=1 ruby reproduction.rb