diff --git a/CHANGELOG.md b/CHANGELOG.md index 800bb5ca..6a020685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New Features + +* Add opt-in Ractor-safe `InstanceRegistry` via `RICE_RACTOR_SAFE` define. When enabled, a `std::recursive_mutex` protects all mutable registry operations, allowing C extensions built with Rice to be used safely from multiple Ruby Ractors (Ruby 3.4.x). Without the define, behavior is unchanged. + ## 4.11.4 (2026-03-13) ### Bug Fixes diff --git a/docs/why_rice.md b/docs/why_rice.md index 0e51129b..7dc6131c 100644 --- a/docs/why_rice.md +++ b/docs/why_rice.md @@ -74,7 +74,24 @@ Rice automatically converts common exceptions and provides a mechanism for conve ## Thread Safety -Rice provides no mechanisms for dealing with thread safety. Many common thread safety issues should be alleviated by YARV, which supports POSIX threads. +Rice provides no mechanisms for dealing with thread safety by default. Many common thread safety issues should be alleviated by YARV, which supports POSIX threads. + +### Ractor Support (opt-in) + +Ruby 3.4 introduced Ractors for true parallel execution. C extensions can declare themselves Ractor-safe via `rb_ext_ractor_safe(true)`, but Rice's internal `InstanceRegistry` — a `std::map` that tracks C++ object wrappers — is not thread-safe. Concurrent access from multiple Ractors corrupts the map, causing segfaults or hangs. + +To enable Ractor-safe operation, define `RICE_RACTOR_SAFE` before compilation: + +```ruby +# In your extconf.rb: +$CXXFLAGS << " -DRICE_RACTOR_SAFE" +``` + +This adds a `std::recursive_mutex` to `InstanceRegistry`, protecting all `lookup`, `add`, `remove`, and `clear` operations. Only `InstanceRegistry` requires this protection — the other Rice registries (`TypeRegistry`, `NativeRegistry`, `HandlerRegistry`, `ModuleRegistry`) are written once during initialization and are read-only at runtime. + +Without the define, the generated code is identical to the current Rice — no mutex, no overhead, no behavior change. + +**Compatibility:** Tested with Ruby 3.4.x Ractors. Ruby 4.x compatibility is pending validation. ## C++ Based API diff --git a/rice/detail/InstanceRegistry.hpp b/rice/detail/InstanceRegistry.hpp index dac0f29c..92bea9e0 100644 --- a/rice/detail/InstanceRegistry.hpp +++ b/rice/detail/InstanceRegistry.hpp @@ -3,6 +3,10 @@ #include +#ifdef RICE_RACTOR_SAFE +#include +#endif + namespace Rice::detail { class InstanceRegistry @@ -30,6 +34,9 @@ namespace Rice::detail private: bool shouldTrack(bool isOwner) const; +#ifdef RICE_RACTOR_SAFE + std::recursive_mutex mutex_; +#endif std::map objectMap_; }; } // namespace Rice::detail diff --git a/rice/detail/InstanceRegistry.ipp b/rice/detail/InstanceRegistry.ipp index a159df66..946cfbf7 100644 --- a/rice/detail/InstanceRegistry.ipp +++ b/rice/detail/InstanceRegistry.ipp @@ -5,6 +5,10 @@ namespace Rice::detail template inline VALUE InstanceRegistry::lookup(T* cppInstance, bool isOwner) { +#ifdef RICE_RACTOR_SAFE + std::lock_guard lock(mutex_); +#endif + if (!this->shouldTrack(isOwner)) { return Qnil; @@ -17,6 +21,10 @@ namespace Rice::detail template inline void InstanceRegistry::add(T* cppInstance, VALUE rubyInstance, bool isOwner) { +#ifdef RICE_RACTOR_SAFE + std::lock_guard lock(mutex_); +#endif + if (!this->shouldTrack(isOwner)) { return; @@ -27,11 +35,17 @@ namespace Rice::detail inline void InstanceRegistry::remove(void* cppInstance) { +#ifdef RICE_RACTOR_SAFE + std::lock_guard lock(mutex_); +#endif this->objectMap_.erase(cppInstance); } inline void InstanceRegistry::clear() { +#ifdef RICE_RACTOR_SAFE + std::lock_guard lock(mutex_); +#endif this->objectMap_.clear(); } diff --git a/test/ruby/ext/extconf.rb b/test/ruby/ext/extconf.rb new file mode 100644 index 00000000..f4940d7c --- /dev/null +++ b/test/ruby/ext/extconf.rb @@ -0,0 +1,9 @@ +require "mkmf-rice" + +# Use the local Rice source (with RICE_RACTOR_SAFE support) +rice_dir = File.expand_path("../../../../rice", __dir__) +$INCFLAGS.prepend("-I#{rice_dir} ") + +$CXXFLAGS << " -DRICE_RACTOR_SAFE" + +create_makefile("ractor_test_ext") diff --git a/test/ruby/ext/ractor_test_ext.cpp b/test/ruby/ext/ractor_test_ext.cpp new file mode 100644 index 00000000..52f42e07 --- /dev/null +++ b/test/ruby/ext/ractor_test_ext.cpp @@ -0,0 +1,54 @@ +// Minimal Rice extension for testing Ractor safety. +// Compiled with -DRICE_RACTOR_SAFE to enable mutex-protected InstanceRegistry. + +#include +#include + +#include + +namespace { + class Counter { + public: + Counter(int start) : value_(start) {} + int value() const { return value_; } + void increment() { value_++; } + private: + int value_; + }; + + // Object with an expensive constructor — spends significant time inside + // Rice's InstanceRegistry add/lookup cycle, widening the window for + // concurrent access from multiple Ractors. + class HeavyObject { + public: + HeavyObject(int size) { + data_.resize(size); + for (int i = 0; i < size; i++) { + data_[i] = i * i; + } + } + + int sum() const { + int s = 0; + for (auto v : data_) { s += v; } + return s; + } + + private: + std::vector data_; + }; +} + +extern "C" +void Init_ractor_test_ext() { + rb_ext_ractor_safe(true); + + Rice::define_class("Counter") + .define_constructor(Rice::Constructor()) + .define_method("value", &Counter::value) + .define_method("increment", &Counter::increment); + + Rice::define_class("HeavyObject") + .define_constructor(Rice::Constructor()) + .define_method("sum", &HeavyObject::sum); +} diff --git a/test/ruby/ext_unsafe/extconf.rb b/test/ruby/ext_unsafe/extconf.rb new file mode 100644 index 00000000..aa1d94ae --- /dev/null +++ b/test/ruby/ext_unsafe/extconf.rb @@ -0,0 +1,10 @@ +require "mkmf-rice" + +# Use the local Rice source — but WITHOUT RICE_RACTOR_SAFE. +# The InstanceRegistry will have no mutex protection. +rice_dir = File.expand_path("../../../../rice", __dir__) +$INCFLAGS.prepend("-I#{rice_dir} ") + +# Intentionally NO -DRICE_RACTOR_SAFE + +create_makefile("ractor_test_unsafe_ext") diff --git a/test/ruby/ext_unsafe/ractor_test_unsafe_ext.cpp b/test/ruby/ext_unsafe/ractor_test_unsafe_ext.cpp new file mode 100644 index 00000000..e6b649c5 --- /dev/null +++ b/test/ruby/ext_unsafe/ractor_test_unsafe_ext.cpp @@ -0,0 +1,51 @@ +// Same extension as ractor_test_ext but compiled WITHOUT -DRICE_RACTOR_SAFE. +// Used to verify that concurrent Ractor access segfaults without the mutex. + +#include +#include + +#include + +namespace { + class Counter { + public: + Counter(int start) : value_(start) {} + int value() const { return value_; } + void increment() { value_++; } + private: + int value_; + }; + + class HeavyObject { + public: + HeavyObject(int size) { + data_.resize(size); + for (int i = 0; i < size; i++) { + data_[i] = i * i; + } + } + + int sum() const { + int s = 0; + for (auto v : data_) { s += v; } + return s; + } + + private: + std::vector data_; + }; +} + +extern "C" +void Init_ractor_test_unsafe_ext() { + rb_ext_ractor_safe(true); + + Rice::define_class("Counter") + .define_constructor(Rice::Constructor()) + .define_method("value", &Counter::value) + .define_method("increment", &Counter::increment); + + Rice::define_class("HeavyObject") + .define_constructor(Rice::Constructor()) + .define_method("sum", &HeavyObject::sum); +} diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb new file mode 100644 index 00000000..141663c3 --- /dev/null +++ b/test/ruby/test_ractor.rb @@ -0,0 +1,180 @@ +require "minitest/autorun" + +# Build the test extension if needed +ext_dir = File.expand_path("ext", __dir__) +bundle = File.join(ext_dir, "ractor_test_ext.bundle") +so = File.join(ext_dir, "ractor_test_ext.so") + +unless File.exist?(bundle) || File.exist?(so) + Dir.chdir(ext_dir) do + system(RbConfig.ruby, "extconf.rb", exception: true) + system("make", "clean", exception: true) + system("make", exception: true) + end +end + +$LOAD_PATH.unshift(ext_dir) +require "ractor_test_ext" + +class RactorSafetyTest < Minitest::Test + def test_single_ractor + r = Ractor.new do + c = Counter.new(10) + c.increment + c.increment + c.value + end + + assert_equal 12, r.take + end + + def test_sequential_ractors + results = 5.times.map do |i| + r = Ractor.new(i) do |start| + c = Counter.new(start) + c.increment + c.value + end + r.take + end + + assert_equal [1, 2, 3, 4, 5], results + end + + def test_concurrent_ractors + r1 = Ractor.new do + c = Counter.new(100) + c.increment + c.value + end + + r2 = Ractor.new do + c = Counter.new(200) + c.increment + c.value + end + + assert_equal 101, r1.take + assert_equal 201, r2.take + end + + def test_many_objects_in_ractor + r = Ractor.new do + counters = 50.times.map { |i| Counter.new(i) } + counters.each(&:increment) + counters.map(&:value) + end + + result = r.take + assert_equal 50, result.size + assert_equal 50.times.map { |i| i + 1 }, result + end + + def test_main_thread_and_ractor + main_counter = Counter.new(0) + main_counter.increment + + r = Ractor.new do + c = Counter.new(1000) + c.increment + c.value + end + + assert_equal 1, main_counter.value + assert_equal 1001, r.take + end + + # --- Heavy concurrency tests --- + # + # Two strategies to maximize InstanceRegistry contention: + # + # 1. Few heavy objects: each HeavyObject.new takes ~1s, so the thread + # spends a long time inside Rice's wrapping code per object. + # + # 2. Many fast objects: calibrate how many Counter.new fit in ~1s, + # then create that many concurrently — thousands of rapid-fire + # registry writes overlapping between Ractors. + + HEAVY_TARGET_SECONDS = 1.0 + HEAVY_OBJECTS_PER_RACTOR = 3 + FAST_TARGET_SECONDS = 1.0 + + def test_concurrent_heavy_objects + size = calibrate_heavy_object_size + + r1 = Ractor.new(size) do |sz| + HEAVY_OBJECTS_PER_RACTOR.times.map { HeavyObject.new(sz).sum } + end + + r2 = Ractor.new(size) do |sz| + HEAVY_OBJECTS_PER_RACTOR.times.map { HeavyObject.new(sz).sum } + end + + results1 = r1.take + results2 = r2.take + + assert_equal HEAVY_OBJECTS_PER_RACTOR, results1.size + assert_equal HEAVY_OBJECTS_PER_RACTOR, results2.size + + expected_sum = results1.first + (results1 + results2).each { |s| assert_equal expected_sum, s } + end + + def test_concurrent_many_fast_objects + count = calibrate_fast_object_count + + r1 = Ractor.new(count) do |n| + n.times { Counter.new(0) } + :ok + end + + r2 = Ractor.new(count) do |n| + n.times { Counter.new(0) } + :ok + end + + assert_equal :ok, r1.take + assert_equal :ok, r2.take + end + + private + + # Find the size parameter that makes HeavyObject.new take ~1 second. + def calibrate_heavy_object_size + size = 1_000 + loop do + elapsed = measure_seconds { HeavyObject.new(size) } + break if elapsed >= HEAVY_TARGET_SECONDS + size *= 2 + end + + lo = size / 2 + hi = size + while hi - lo > lo / 10 + mid = (lo + hi) / 2 + if measure_seconds { HeavyObject.new(mid) } < HEAVY_TARGET_SECONDS + lo = mid + else + hi = mid + end + end + + (lo + hi) / 2 + end + + # Find how many Counter.new(0) fit in ~1 second. + def calibrate_fast_object_count + count = 1_000 + loop do + elapsed = measure_seconds { count.times { Counter.new(0) } } + return count if elapsed >= FAST_TARGET_SECONDS + count *= 2 + end + end + + def measure_seconds + t = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - t + end +end diff --git a/test/ruby/test_ractor_unsafe.rb b/test/ruby/test_ractor_unsafe.rb new file mode 100644 index 00000000..3053f7d1 --- /dev/null +++ b/test/ruby/test_ractor_unsafe.rb @@ -0,0 +1,85 @@ +require "minitest/autorun" + +# Build the UNSAFE test extension if needed (no RICE_RACTOR_SAFE) +ext_dir = File.expand_path("ext_unsafe", __dir__) +bundle = File.join(ext_dir, "ractor_test_unsafe_ext.bundle") +so = File.join(ext_dir, "ractor_test_unsafe_ext.so") + +unless File.exist?(bundle) || File.exist?(so) + Dir.chdir(ext_dir) do + system(RbConfig.ruby, "extconf.rb", exception: true) + system("make", "clean", exception: true) + system("make", exception: true) + end +end + +class RactorUnsafeTest < Minitest::Test + # Verify that WITHOUT RICE_RACTOR_SAFE, concurrent Ractor access to + # Rice-wrapped objects fails — either by crashing (segfault/abort) or + # by hanging (corrupted std::map causes infinite loop). + # + # This proves the mutex is necessary, not just defensive. + # + # Runs in a subprocess with a timeout so neither a crash nor a hang + # kills the test runner. + # + # The number of objects is calibrated to fill ~1 second of rapid-fire + # Counter.new calls, ensuring sufficient registry contention on any machine. + + TIMEOUT_SECONDS = 30 + + SUBPROCESS_SCRIPT = <<~'RUBY' + $LOAD_PATH.unshift(ARGV[0]) + require "ractor_test_unsafe_ext" + + # Calibrate: how many Counter.new(0) fit in ~1 second? + count = 1_000 + loop do + t = Process.clock_gettime(Process::CLOCK_MONOTONIC) + count.times { Counter.new(0) } + break if Process.clock_gettime(Process::CLOCK_MONOTONIC) - t >= 1.0 + count *= 2 + end + + # Launch concurrent Ractors with calibrated count + r1 = Ractor.new(count) { |n| n.times { Counter.new(0) }; :ok } + r2 = Ractor.new(count) { |n| n.times { Counter.new(0) }; :ok } + r1.take + r2.take + RUBY + + def test_concurrent_access_without_mutex_fails + ext_dir = File.expand_path("ext_unsafe", __dir__) + + pid = spawn( + RbConfig.ruby, "-e", SUBPROCESS_SCRIPT, ext_dir, + out: File::NULL, err: File::NULL + ) + + # Wait with timeout — hang is also a failure mode + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMEOUT_SECONDS + status = nil + + loop do + _, status = Process.wait2(pid, Process::WNOHANG) + break if status + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + Process.kill("KILL", pid) + _, status = Process.wait2(pid) + break + end + sleep 0.1 + end + + if status.success? + skip "Race condition did not manifest in this run (non-deterministic)" + elsif status.termsig == 9 && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - 1 + # We killed it after timeout — it was hanging (corrupted map) + pass # Hang confirms the corruption + else + # Crashed with a signal — segfault, abort, etc. + assert status.signaled?, + "Expected crash or hang without RICE_RACTOR_SAFE, got exit #{status.exitstatus}" + end + end +end