Project

General

Profile

Bug #22476 ยป rb_profile_frames_signal_crash.rb

Eregon (Benoit Daloze), 10/27/2026 01:01 PM

 
# Reproducer: rb_profile_frames() called from a signal handler (as profilers
# like Datadog's do) segfaults if the signal arrives while vm_make_env_each()
# is between escaping a parent frame and updating the child frame's prev EP.
#
# Usage:
# ruby rb_profile_frames_signal_crash.rb # MODE=class (default)
# MODE=define_method ruby rb_profile_frames_signal_crash.rb
# MODE=thread ruby rb_profile_frames_signal_crash.rb
# INTERVAL_US=1000 ruby rb_profile_frames_signal_crash.rb # 1 signal per ms instead of back-to-back

require 'tmpdir'
require 'rbconfig'

def inline_c_extension(c_code)
Dir.mktmpdir('inline_c_extension') do |dir|
File.write("#{dir}/cext.c", c_code)
File.write("#{dir}/extconf.rb", <<~RUBY)
require 'mkmf'
create_makefile('cext')
RUBY

out = IO.popen([RbConfig.ruby, 'extconf.rb'], chdir: dir, &:read)
raise "ruby extconf.rb failed: #{$?.inspect}\n#{out}" unless $?.success?

out = IO.popen(['make'], chdir: dir, &:read)
raise "make failed: #{$?.inspect}\n#{out}" unless $?.success?

require "#{dir}/cext.#{RbConfig::CONFIG['DLEXT']}"
end
end

inline_c_extension <<~C
#include <ruby.h>
#include <ruby/debug.h>
#include <pthread.h>
#include <signal.h>
#include <errno.h>
#include <unistd.h>

#define MAX_FRAMES 400

static pthread_t target_thread;
static pthread_t sampler_thread;
static volatile int running = 0;
static useconds_t interval_us;
static volatile unsigned long samples = 0;

static void sigprof_handler(int sig, siginfo_t *info, void *ucontext) {
int saved_errno = errno;
VALUE frames[MAX_FRAMES];
int lines[MAX_FRAMES];
rb_profile_frames(0, MAX_FRAMES, frames, lines);
samples++;
errno = saved_errno;
}

static void *sampler_loop(void *arg) {
while (running) {
pthread_kill(target_thread, SIGPROF);
if (interval_us) usleep(interval_us);
}
return NULL;
}

// Start sending SIGPROF to the calling (main) thread every interval_us microseconds
static VALUE pf_start(VALUE self, VALUE interval) {
struct sigaction sa = {0};
sa.sa_sigaction = sigprof_handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGPROF, &sa, NULL) != 0) rb_sys_fail("sigaction");

interval_us = NUM2UINT(interval);
target_thread = pthread_self();
running = 1;
if (pthread_create(&sampler_thread, NULL, sampler_loop, NULL) != 0) rb_sys_fail("pthread_create");
return Qnil;
}

static VALUE pf_stop(VALUE self) {
running = 0;
pthread_join(sampler_thread, NULL);
return Qnil;
}

static VALUE pf_samples(VALUE self) {
return ULONG2NUM(samples);
}

void Init_cext(void) {
VALUE mod = rb_define_module("ProfileFramesSigprof");
rb_define_module_function(mod, "start", pf_start, 1);
rb_define_module_function(mod, "stop", pf_stop, 0);
rb_define_module_function(mod, "samples", pf_samples, 0);
}
C

interval_us = Integer(ENV.fetch('INTERVAL_US', '0'))
iterations = Integer(ENV.fetch('ITERATIONS', '10_000_000'))
mode = ENV.fetch('MODE', 'class')

ProfileFramesSigprof.start(interval_us)

# Each class body execution is a fresh "local" frame whose ME_CREF is a cref
# (not a method entry) and which has no block handler (SPECVAL == 0).
# The block passed to #times is the frame that creates the Proc, whose prev EP is
# the class body EP, so vm_make_env_each() recurses into the class body frame.
case mode
when 'class'
iterations.times do
class Foo
1.times { proc {} }
end
end
when 'define_method'
iterations.times do
class Foo
1.times { define_method(:foo) {} }
end
end
when 'thread'
iterations.times do
class Foo
1.times { Thread.new {}.join }
end
end
else
abort "Unknown MODE: #{mode}"
end

ProfileFramesSigprof.stop
puts "No crash after #{iterations} iterations, #{ProfileFramesSigprof.samples} samples"
    (1-1/1)