Introduction
Welcome to the rb-sys guide. This book will show you how to build Ruby extensions in Rust that are both powerful and reliable.
The primary goal of rb-sys
is to make building native Ruby extensions in Rust easier than it would be in C. If
it's not easy, it's a bug.
Key Features
- Battle-tested Rust bindings for the Ruby C API
- Support for Ruby 2.6+
- Support for all major platforms (Linux, macOS, Windows)
- Cross-compilation support for gems
- Integration with
rake-compiler
- Test helpers for Ruby extensions
Why Rust for Ruby Extensions?
Ruby extensions have traditionally been written in C, requiring manual memory management and careful handling of Ruby's internals. This approach is error-prone and often results in security vulnerabilities, memory leaks, and crashes.
While C extensions offer flexibility and minimal dependencies, Rust extensions provide a superior developer experience with improved safety guarantees and access to a rich ecosystem of libraries.
Rust offers a compelling alternative with several advantages:
- Memory safety without garbage collection
- Strong type system that catches errors at compile time
- Modern language features like pattern matching and traits
- Access to the vast Rust ecosystem via crates.io
- Strong tooling for testing, documentation, and deployment
Importantly, performance should not be the sole motivation for using Rust. With Ruby's YJIT compiler, pure Ruby code is now faster than ever. Instead, consider Rust when you need memory safety, type safety, or want to leverage the rich Rust ecosystem's capabilities.
The rb-sys Project Ecosystem
rb-sys consists of several components working together:
- rb-sys crate: Provides low-level Rust bindings to Ruby's C API
- rb_sys gem: Handles the Ruby side of extension compilation
- Magnus: A higher-level, ergonomic API for Rust/Ruby interoperability
- rb-sys-dock: Docker-based cross-compilation tooling
- GitHub Actions: Setup and cross-compilation automation for CI workflows
Most developers will use the Magnus API when building their extensions, as it provides a much more ergonomic interface than using rb-sys directly.
Here's how these components interact when building a typical Ruby gem with Rust:
During compilation:
You can click the eye icon () to see the hidden details in these diagrams.
Comparison with Traditional C Extensions
Let's compare writing extensions in Rust versus C:
Aspect | C Extensions | Rust Extensions |
---|---|---|
Memory Safety | Manual memory management | Guaranteed memory safety at compile time |
Type Safety | Weak typing, runtime errors | Strong static typing, compile-time checks |
API Ergonomics | Low-level C API | High-level Magnus API |
Development Speed | Slower, more error-prone | Faster, safer development cycle |
Ecosystem Access | Limited to C libraries | Full access to Rust crates |
Debugging | Harder to debug memory issues | Easier to debug with Rust's safety guarantees |
Cross-Compilation | Complex manual configuration | Simplified with rb-sys-dock |
While C extensions offer flexibility and minimal dependencies, Rust extensions provide a superior developer experience with improved safety guarantees and access to a rich ecosystem of libraries.
Real-World Examples
These gems demonstrate rb-sys in action:
- lz4-ruby - LZ4 compression library with rb-sys
- wasmtime-rb - WebAssembly runtime with rb-sys and Magnus
- oxi-test - Canonical example of how to use rb-sys (minimal, fully tested, cross-compiled)
- blake3-ruby - Fast cryptographic hash function with full cross-platform support
Supported Toolchains
- Ruby: 2.6+ (for full compatibility with Rubygems)
- Rust: 1.65+
Dependencies
To build a Ruby extension in Rust, you'll need:
- Ruby development headers (usually part of ruby-dev packages)
- Rust (via rustup)
- libclang (for bindgen)
- On macOS:
brew install llvm
- On Linux:
apt-get install libclang-dev
- On macOS:
Getting Help
If you have questions, please join our Slack channel or open an issue on GitHub.
Contributing to this book
This book is open source! Find a typo? Did we overlook something? Send us a pull request!. Help wanted!
License
rb-sys is licensed under either:
- Apache License, Version 2.0
- MIT license
at your option.
Next Steps
- Proceed to Getting Started to set up your development environment.
- Try the Quick Start to build your first extension.
- Explore core concepts in Build Process and Memory Management & Safety.
- Learn advanced topics like Cross-Platform Development and Testing Extensions.
Prerequisites and Installation
This chapter provides a streamlined setup guide for building Ruby extensions with Rust.
TL;DR
1. Install Prerequisites
# Install Ruby (3.0+ recommended)
# Using your preferred manager: rbenv, rvm, asdf, etc.
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install rb_sys gem
gem install rb_sys
2. Create a New Gem with Rust Extension
# Generate a new gem with Rust extension support
bundle gem --ext=rust mygem
cd mygem
# Build the extension
bundle install
bundle exec rake compile
# Try it out
bundle exec rake
That's it! You now have a working Ruby gem with Rust extension.
Detailed Installation
If you encounter issues with the quick start above, here are the detailed requirements:
Ruby Requirements
- Ruby 3.0+ recommended (2.6+ supported)
- Ruby development headers (usually part of official packages)
- Bundler (
gem install bundler
)
Rust Requirements
- Rust 1.65.0+ via rustup
- Make sure Cargo is in your PATH (typically
~/.cargo/bin
)
C Compiler Requirements
- macOS: Xcode Command Line Tools (
xcode-select --install
) - Linux: build-essential (Debian/Ubuntu) or Development Tools (Fedora/RHEL)
- Windows: Microsoft Visual Studio C++ Build Tools
libclang (for Ruby/Rust FFI bindings)
Simplest approach: add to your Gemfile
gem "libclang", "~> 14.0"
Verifying Your Setup
The simplest way to verify your setup is to create a test gem:
bundle gem --ext=rust hello_rusty
cd hello_rusty
bundle install
bundle exec rake compile
bundle exec rake test
If everything runs without errors, your environment is correctly set up.
Troubleshooting
If you encounter issues:
- Missing libclang: Add the
libclang
gem to your Gemfile - Missing C compiler: Install appropriate build tools for your platform
- Ruby headers not found: Install Ruby development package
For detailed troubleshooting, consult the rb-sys wiki.
Next Steps
- Validate your setup with the Quick Start.
- Dive into Build Process for deeper compilation insights.
- Explore Project Setup patterns.
- Learn Testing Extensions to add tests.
Quick Start: Your First Extension
This chapter shows you how to create a Ruby gem with a Rust extension using Bundler's built-in Rust support.
Creating a Gem with Bundler
The easiest way to create a new gem with a Rust extension is with Bundler:
# Create a new gem with a Rust extension
bundle gem --ext=rust hello_rusty
cd hello_rusty
This command generates everything you need to build a Ruby gem with a Rust extension.
Understanding the Generated Files
Let's examine the key files Bundler created:
hello_rusty/
├── ext/hello_rusty/ # Rust extension directory
│ ├── Cargo.toml # Rust dependencies
│ ├── extconf.rb # Ruby extension config
│ └── src/lib.rs # Rust code
├── lib/hello_rusty.rb # Main Ruby file
└── hello_rusty.gemspec # Gem specification
The Rust Code (lib.rs)
Bundler generates a simple "Hello World" implementation:
#![allow(unused)] fn main() { // ext/hello_rusty/src/lib.rs use magnus::{define_module, function, prelude::*, Error}; #[magnus::init] fn init() -> Result<(), Error> { let module = define_module("HelloRusty")?; module.define_singleton_method("hello", function!(|| "Hello from Rust!", 0))?; Ok(()) } }
You can click the "play" button on code blocks to try them out in the Rust Playground where appropriate. For code that depends on the Ruby API, you won't be able to run it directly, but you can experiment with Rust syntax and standard library functions.
The Extension Configuration (extconf.rb)
# ext/hello_rusty/extconf.rb
require "mkmf"
require "rb_sys/mkmf"
create_rust_makefile("hello_rusty/hello_rusty")
This file connects Ruby's build system to Cargo.
Enhancing the Default Implementation
Let's improve the default implementation by adding a simple class:
Click the eye icon () to reveal commented lines with additional functionality that you could add to your implementation.
Building and Testing
Compile the Extension
# Install dependencies and compile
bundle install
bundle exec rake compile
What happens during compilation:
- Ruby's
mkmf
reads yourextconf.rb
create_rust_makefile
generates a Makefile with Cargo commands- Cargo compiles your Rust code to a dynamic library
- The binary is copied to
lib/hello_rusty/hello_rusty.{so,bundle,dll}
Run the Tests
Bundler generates a basic test file. Let's update it:
Run the tests:
Try It in the Console
Once in the console, you can interact with your extension:
Customizing the Build
You can customize the build process with environment variables:
Remember that building in release mode will produce optimized, faster code but will increase compilation time.
Next Steps
Congratulations! You've created a Ruby gem with a Rust extension. In the next chapters, we'll explore:
- Better project organization
- Working with Ruby objects in Rust
- Memory management and safety
- Performance optimization
Project Structure
In this chapter, we'll explore how to set up and organize a Ruby gem with a Rust extension. We'll focus on practical patterns and highlight how to leverage valuable Rust libraries without introducing unnecessary complexity.
Enhanced Project Structure
Building on the structure created by bundle gem --ext=rust
, a well-organized rb-sys project typically looks like this:
my_gem/
├── Cargo.toml # Rust workspace configuration
├── Gemfile # Ruby dependencies
├── Rakefile # Build tasks
├── my_gem.gemspec # Gem specification
├── ext/
│ └── my_gem/
│ ├── Cargo.toml # Rust crate configuration
│ ├── extconf.rb # Ruby extension configuration
│ └── src/
│ └── lib.rs # Main Rust entry point
├── lib/
│ ├── my_gem.rb # Main Ruby file
│ └── my_gem/
│ └── version.rb # Version information
└── test/ # Tests
Let's examine a practical example using a useful but simple Rust library.
Example: URL Parsing with the url
crate
The url crate, developed by the Servo team, is a robust implementation of the URL Standard. It provides accurate URL parsing that would be complex to implement from scratch. Here's a simple example:
1. Extension Cargo.toml
# ext/url_parser/Cargo.toml
[package]
name = "url_parser"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
# High-level Ruby bindings with rb-sys feature
magnus = { version = "0.7", features = ["rb-sys"] }
# The main Rust library we're wrapping
url = "2.4"
[build-dependencies]
rb-sys-env = "0.1"
2. Main Rust Implementation
#![allow(unused)] fn main() { // ext/url_parser/src/lib.rs use magnus::{define_module, define_class, function, method, prelude::*, Error, Ruby}; use url::Url; // Simple URL wrapper class struct UrlWrapper { inner: Url, } #[magnus::wrap(class = "UrlParser::URL")] impl UrlWrapper { // Parse a URL string fn parse(url_str: String) -> Result<Self, Error> { match Url::parse(&url_str) { Ok(url) => Ok(UrlWrapper { inner: url }), Err(err) => { Err(Error::new(magnus::exception::arg_error(), format!("Invalid URL: {}", err))) } } } // Basic getters fn scheme(&self) -> String { self.inner.scheme().to_string() } fn host(&self) -> Option<String> { self.inner.host_str().map(|s| s.to_string()) } fn path(&self) -> String { self.inner.path().to_string() } fn query(&self) -> Option<String> { self.inner.query().map(|s| s.to_string()) } // String representation of the URL fn to_string(&self) -> String { self.inner.to_string() } } // Module-level utilities fn is_valid_url(url_str: String) -> bool { Url::parse(&url_str).is_ok() } // Module init function - Ruby extension entry point #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { // Define the main module let module = ruby.define_module("UrlParser")?; // Add utility function at module level module.define_singleton_method("valid_url?", function!(is_valid_url, 1))?; // Define and configure the URL class let class = module.define_class("URL", ruby.class_object())?; class.define_singleton_method("parse", function!(UrlWrapper::parse, 1))?; // Instance methods class.define_method("scheme", method!(UrlWrapper::scheme, 0))?; class.define_method("host", method!(UrlWrapper::host, 0))?; class.define_method("path", method!(UrlWrapper::path, 0))?; class.define_method("query", method!(UrlWrapper::query, 0))?; class.define_method("to_s", method!(UrlWrapper::to_string, 0))?; Ok(()) } }
3. Ruby Integration
# lib/url_parser.rb
require_relative "url_parser/version"
require_relative "url_parser/url_parser"
module UrlParser
class Error < StandardError; end
# Parse a URL string and return a URL object
def self.parse(url_string)
URL.parse(url_string)
rescue => e
raise Error, "Failed to parse URL: #{e.message}"
end
# Check if a URL has an HTTPS scheme
def self.https?(url_string)
return false unless valid_url?(url_string)
url = parse(url_string)
url.scheme == "https"
end
end
4. Simple Tests
# test/test_url_parser.rb
require "test_helper"
class TestUrlParser < Minitest::Test
def test_basic_url_parsing
url = UrlParser::URL.parse("https://example.com/path?query=value")
assert_equal "https", url.scheme
assert_equal "example.com", url.host
assert_equal "/path", url.path
assert_equal "query=value", url.query
end
def test_url_validation
assert UrlParser.valid_url?("https://example.com")
refute UrlParser.valid_url?("not a url")
end
def test_https_check
assert UrlParser.https?("https://example.com")
refute UrlParser.https?("http://example.com")
end
def test_invalid_url_raises_error
assert_raises UrlParser::Error do
UrlParser.parse("not://a.valid/url")
end
end
end
Key Aspects of this Project
1. Simplicity with Value
This example demonstrates how to:
- Wrap a useful Rust library (
url
) with minimal code - Expose only the most essential functionality
- Handle errors properly
- Integrate with Ruby idiomatically
2. Why Use Rust for URL Parsing?
Ruby has URI handling in its standard library, but the Rust url
crate offers advantages:
- Full compliance with the URL standard used by browsers
- Better handling of internationalized domain names (IDNs)
- Robust error detection
- Significant performance benefits for URL-heavy applications
3. Project Organization Principles
- Keep dependencies minimal: Just what you need, nothing more
- Clean public API: Expose only what users need
- Proper error handling: Map Rust errors to meaningful Ruby exceptions
- Simple tests: Verify both functionality and edge cases
Anatomy of a Rusty Ruby Gem: hello_rusty
This documentation provides a comprehensive walkthrough of the hello_rusty
gem, a simple but complete Ruby gem that
uses Rust for its native extension. This example demonstrates the key components of creating a Ruby gem with Rust using
rb-sys and magnus.
Project Structure
A properly structured Rusty Ruby Gem follows the standard Ruby gem conventions with the addition of Rust-specific
elements. Here's the structure of the hello_rusty
gem:
hello_rusty/
├── bin/ # Executable files
├── ext/ # Native extension code
│ └── hello_rusty/ # The Rust extension directory
│ ├── Cargo.toml # Rust package manifest
│ ├── extconf.rb # Ruby extension configuration
│ └── src/
│ └── lib.rs # Rust implementation
├── lib/ # Ruby code
│ ├── hello_rusty.rb # Main Ruby file
│ └── hello_rusty/
│ └── version.rb # Version definition
├── sig/ # RBS type signatures
│ └── hello_rusty.rbs # Type definitions
├── test/ # Test files
│ ├── test_hello_rusty.rb # Test for the gem
│ └── test_helper.rb # Test setup
├── Cargo.lock # Rust dependency lock file
├── Cargo.toml # Workspace-level Rust config (optional)
├── Gemfile # Ruby dependencies
├── LICENSE.txt # License file
├── Rakefile # Build tasks
├── README.md # Documentation
└── hello_rusty.gemspec # Gem specification
Key Components
1. Ruby Components
Gemspec (hello_rusty.gemspec
)
The gemspec defines metadata about the gem and specifies build requirements:
# frozen_string_literal: true
require_relative "lib/hello_rusty/version"
Gem::Specification.new do |spec|
spec.name = "hello_rusty"
spec.version = HelloRusty::VERSION
spec.authors = ["Ian Ker-Seymer"]
spec.email = ["hello@ianks.com"]
# ... metadata ...
spec.required_ruby_version = ">= 3.0.0"
# Files to include in the gem
spec.files = [...]
# IMPORTANT: This line tells RubyGems that this gem has a native extension
# and where to find the build configuration
spec.extensions = ["ext/hello_rusty/Cargo.toml"]
spec.require_paths = ["lib"]
end
Key points:
- The
extensions
field points to the Cargo.toml file - Version is defined in a separate Ruby file
- Required Ruby version is specified
Main Ruby file (lib/hello_rusty.rb
)
# frozen_string_literal: true
require_relative "hello_rusty/version"
require_relative "hello_rusty/hello_rusty" # Loads the compiled Rust extension
module HelloRusty
class Error < StandardError; end
# Additional Ruby code can go here
end
Key points:
- Requires the version file
- Requires the compiled native extension
- Defines a module matching the Rust module
Version file (lib/hello_rusty/version.rb
)
# frozen_string_literal: true
module HelloRusty
VERSION = "0.1.0"
end
Type Definitions (sig/hello_rusty.rbs
)
RBS type definitions for better IDE support and type checking:
module HelloRusty
VERSION: String
# Add type signatures for your methods here
end
2. Rust Components
Cargo Configuration (ext/hello_rusty/Cargo.toml
)
[package]
name = "hello_rusty"
version = "0.1.0"
edition = "2021"
authors = ["Ian Ker-Seymer <hello@ianks.com>"]
license = "MIT"
publish = false
[lib]
crate-type = ["cdylib"] # Outputs a dynamic library
[dependencies]
magnus = { version = "0.6.2" } # High-level Ruby bindings
Key points:
- Uses
cdylib
crate type to build a dynamic library - Depends on
magnus
for high-level Ruby bindings - Version should match the Ruby gem version
Rust Implementation (ext/hello_rusty/src/lib.rs
)
Here's the actual implementation from our example code:
#![allow(unused)] fn main() { use magnus::{function, prelude::*, Error, Ruby}; fn hello(subject: String) -> String { format!("Hello from Rust, {subject}!") } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("HelloRusty")?; module.define_singleton_method("hello", function!(hello, 1))?; Ok(()) } }
This code is included directly from the example project file. When the source file is updated, this documentation will automatically reflect those changes.
Key points:
- Uses the
magnus
crate for Ruby integration - The
#[magnus::init]
macro marks the entry point for the extension - Defines a Ruby module matching the gem name
- Exposes the
hello
Rust function as a Ruby method
3. Build System
Extension Configuration (ext/hello_rusty/extconf.rb
)
# frozen_string_literal: true
require "mkmf"
require "rb_sys/mkmf"
create_rust_makefile("hello_rusty/hello_rusty")
Key points:
- Uses
rb_sys/mkmf
to handle Rust compilation - Creates a makefile for the native extension
Rakefile (Rakefile
)
Here's the actual Rakefile from our example project:
The eye icon () reveals additional configuration options you can use in your Rakefile.
Key points:
- Uses
RbSys::ExtensionTask
to manage Rust compilation - Sets the output directory to
lib/hello_rusty
- Defines standard tasks for building, testing, and linting
4. Testing
Test File (test/test_hello_rusty.rb
)
# frozen_string_literal: true
require "test_helper"
class TestHelloRusty < Minitest::Test
def test_that_it_has_a_version_number
refute_nil ::HelloRusty::VERSION
end
def test_hello
result = HelloRusty.hello("World")
assert_equal "Hello from Rust, World!", result
end
end
Key points:
- Tests basic functionality of the gem
- Verifies the version is defined
- Tests the Rust-implemented
hello
method
Build Process
When building a Rusty Ruby Gem, the following steps occur:
rake compile
is run (either directly or throughrake build
)- The
RbSys::ExtensionTask
processes the extension:- It reads the
ext/hello_rusty/Cargo.toml
file - It sets up the appropriate build environment
- It runs
cargo build
with the appropriate options - It copies the resulting
.so
/.bundle
/.dll
tolib/hello_rusty/
- It reads the
- The compiled binary is then packaged into the gem
Usage
Once installed, this gem can be used in Ruby code as follows:
require "hello_rusty"
# Call the Rust-implemented method
greeting = HelloRusty.hello("Rusty Rubyist")
puts greeting # => "Hello from Rust, Rusty Rubyist!"
Key Concepts Demonstrated
- Module Structure: The gem defines a Ruby module that's implemented in Rust
- Function Exposure: Rust functions are exposed as Ruby methods
- Type Conversion: Rust handles string conversion automatically through magnus
- Error Handling: The Rust code uses
Result<T, Error>
for Ruby-compatible error handling - Build Integration: The gem uses rb-sys to integrate with Ruby's build system
- Testing: Standard Ruby testing tools work with the Rust-implemented functionality
Next Steps for Expansion
To expand this basic example, you could:
- Add Ruby classes backed by Rust structs using TypedData
- Implement more complex methods with various argument types
- Add error handling with custom Ruby exceptions
- Use the Ruby GVL (Global VM Lock) for thread safety
- Implement memory management through proper object marking
- Add benchmarks to demonstrate performance characteristics
This example provides a solid foundation for understanding the structure and implementation of Rusty Ruby Gems with rb-sys.
Development Approaches
When building Ruby extensions with Rust and rb-sys, you have two main approaches to choose from:
- Direct rb-sys usage: Working directly with Ruby's C API through the rb-sys bindings
- Higher-level wrappers: Using libraries like Magnus that build on top of rb-sys
This chapter will help you understand when to use each approach and how to mix them when needed.
Direct rb-sys Usage
The rb-sys crate provides low-level bindings to Ruby's C API. This approach gives you complete control over how your Rust code interacts with Ruby.
When to Use Direct rb-sys
- When you need maximum control over Ruby VM interaction
- For specialized extensions that need access to low-level Ruby internals
- When performance is absolutely critical and you need to eliminate any overhead
- When implementing functionality not yet covered by higher-level wrappers
Example: Simple Extension with Direct rb-sys
Here's a simple example of a Ruby extension using direct rb-sys:
#![allow(unused)] fn main() { use rb_sys::{ rb_define_module, rb_define_module_function, rb_str_new_cstr, rb_string_value_cstr, VALUE }; use std::ffi::CString; use std::os::raw::c_char; // Helper macro for creating C strings macro_rules! cstr { ($s:expr) => { concat!($s, "\0").as_ptr() as *const c_char }; } // Reverse a string unsafe extern "C" fn reverse(_: VALUE, s: VALUE) -> VALUE { let c_str = rb_string_value_cstr(&s); let rust_str = std::ffi::CStr::from_ptr(c_str).to_str().unwrap(); let reversed = rust_str.chars().rev().collect::<String>(); let c_string = CString::new(reversed).unwrap(); rb_str_new_cstr(c_string.as_ptr()) } // Module initialization function #[no_mangle] pub extern "C" fn Init_string_utils() { unsafe { let module = rb_define_module(cstr!("StringUtils")); rb_define_module_function( module, cstr!("reverse"), Some(reverse as unsafe extern "C" fn(VALUE, VALUE) -> VALUE), 1, ); } } }
Using rb_thread_call_without_gvl for Performance
When performing computationally intensive operations, it's important to release Ruby's Global VM Lock (GVL) to allow
other threads to run. The rb_thread_call_without_gvl
function provides this capability:
#![allow(unused)] fn main() { use magnus::{Error, Ruby, RString}; use rb_sys::rb_thread_call_without_gvl; use std::{ffi::c_void, panic::{self, AssertUnwindSafe}, ptr::null_mut}; /// Execute a function without holding the Global VM Lock (GVL). /// This allows other Ruby threads to run while performing CPU-intensive tasks. /// /// # Safety /// /// The passed function must not interact with the Ruby VM or Ruby objects /// as it runs without the GVL, which is required for safe Ruby operations. /// /// # Returns /// /// Returns the result of the function or a magnus::Error if the function panics. pub fn nogvl<F, R>(func: F) -> Result<R, Error> where F: FnOnce() -> R, R: Send + 'static, { struct CallbackData<F, R> { func: Option<F>, result: Option<Result<R, String>>, // Store either the result or a panic message } extern "C" fn call_without_gvl<F, R>(data: *mut c_void) -> *mut c_void where F: FnOnce() -> R, R: Send + 'static, { // Safety: We know this pointer is valid because we just created it below let data = unsafe { &mut *(data as *mut CallbackData<F, R>) }; // Use take() to move out of the Option, ensuring we don't try to run the function twice if let Some(func) = data.func.take() { // Use panic::catch_unwind to prevent Ruby process termination if the Rust code panics match panic::catch_unwind(AssertUnwindSafe(func)) { Ok(result) => data.result = Some(Ok(result)), Err(panic_info) => { // Convert panic info to a string message let panic_msg = if let Some(s) = panic_info.downcast_ref::<&'static str>() { s.to_string() } else if let Some(s) = panic_info.downcast_ref::<String>() { s.clone() } else { "Unknown panic occurred in Rust code".to_string() }; data.result = Some(Err(panic_msg)); } } } null_mut() } // Create a data structure to pass the function and receive the result let mut data = CallbackData { func: Some(func), result: None, }; unsafe { // Release the GVL and call our function rb_thread_call_without_gvl( Some(call_without_gvl::<F, R>), &mut data as *mut _ as *mut c_void, None, // No unblock function null_mut(), ); } // Extract the result or create an error if the function failed match data.result { Some(Ok(result)) => Ok(result), Some(Err(panic_msg)) => { // Convert the panic message to a Ruby RuntimeError let ruby = unsafe { Ruby::get_unchecked() }; Err(Error::new( ruby.exception_runtime_error(), format!("Rust panic in nogvl: {}", panic_msg) )) }, None => { // This should never happen if the callback runs, but handle it anyway let ruby = unsafe { Ruby::get_unchecked() }; Err(Error::new( ruby.exception_runtime_error(), "nogvl function was not executed" )) } } } // For checking large inputs pub fn nogvl_if_large<F, R>(input_len: usize, func: F) -> Result<R, Error> where F: FnOnce() -> R, R: Send + 'static, { const MAX_INPUT_LEN: usize = 8192; // Threshold for using GVL release if input_len > MAX_INPUT_LEN { nogvl(func) } else { // If the input is small, just run the function directly // but still wrap the result in a Result for consistency match panic::catch_unwind(AssertUnwindSafe(func)) { Ok(result) => Ok(result), Err(_) => { let ruby = unsafe { Ruby::get_unchecked() }; Err(Error::new( ruby.exception_runtime_error(), "Rust panic in small input path" )) } } } } // Example: Using with Magnus API fn compress(ruby: &Ruby, data: RString) -> Result<RString, Error> { let data_bytes = data.as_bytes(); let data_len = data_bytes.len(); // Use nogvl_if_large with proper error handling let compressed_bytes = nogvl_if_large(data_len, || { // CPU-intensive operation here that returns a Vec<u8> compression_algorithm(data_bytes) })?; // Propagate any errors // Create new Ruby string with compressed data let result = RString::from_slice(ruby, &compressed_bytes); Ok(result) } // Example: Registering the method #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("Compression")?; // Using method! for defining instance methods module.define_singleton_method("compress", function!(compress, 1))?; Ok(()) } }
How Direct rb-sys Works
When using rb-sys directly:
- You define C-compatible functions with the
extern "C"
calling convention - You manually convert between Ruby's
VALUE
type and Rust types - You're responsible for memory management and type safety
- You must use the
#[no_mangle]
attribute on the initialization function so Ruby can find it - All interactions with Ruby data happen through raw pointers and unsafe code
Higher-level Wrappers (Magnus)
Magnus provides a more ergonomic, Rust-like API on top of rb-sys. It handles many of the unsafe aspects of Ruby integration for you.
When to Use Magnus
- For most standard Ruby extensions where ease of development is important
- When you want to avoid writing unsafe code
- When you want idiomatic Rust error handling
- For extensions with complex type conversions
- When working with Ruby classes and objects in an object-oriented way
Example: Simple Extension with Magnus
Let's look at a simple example using Magnus, based on real-world usage patterns:
#![allow(unused)] fn main() { use magnus::{function, prelude::*, Error, Ruby}; fn hello(subject: String) -> String { format!("Hello from Rust, {subject}!") } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("StringUtils")?; module.define_singleton_method("hello", function!(hello, 1))?; Ok(()) } }
Looking at a more complex example from a real-world project (lz4-flex-rb):
#![allow(unused)] fn main() { use magnus::{function, prelude::*, Error, RModule, Ruby}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("Lz4Flex")?; // Define error classes let base_error = module.define_error("Error", magnus::exception::standard_error())?; let _ = module.define_error("EncodeError", base_error)?; let _ = module.define_error("DecodeError", base_error)?; // Define methods module.define_singleton_method("compress", function!(compress, 1))?; module.define_singleton_method("decompress", function!(decompress, 1))?; // Define aliases module.singleton_class()?.define_alias("deflate", "compress")?; module.singleton_class()?.define_alias("inflate", "decompress")?; // Define nested module let varint_module = module.define_module("VarInt")?; varint_module.define_singleton_method("compress", function!(compress_varint, 1))?; varint_module.define_singleton_method("decompress", function!(decompress_varint, 1))?; Ok(()) } }
How Magnus Works
Magnus builds on top of rb-sys and provides:
- Automatic type conversions between Ruby and Rust
- Rust-like error handling with
Result
types - Memory safety through RAII patterns
- More ergonomic APIs for defining modules, classes, and methods
- A more familiar development experience for Rust programmers
When to Choose Each Approach
Choose Direct rb-sys When:
- Performance is absolutely critical: You need to eliminate every bit of overhead
- You need low-level control: Your extension needs to do things not possible with Magnus
- GVL management is important: You need fine-grained control over when to release the GVL
- Compatibility with older Ruby versions: You need version-specific behavior
Choose Magnus When:
- Developer productivity is important: You want to write less code
- Memory safety is a priority: You want Rust's safety guarantees
- You're working with complex Ruby objects: You need convenient methods for Ruby class integration
- Error handling is complex: You want to leverage Rust's error handling
Mixing Approaches
You can also mix the two approaches when appropriate. Magnus provides access to the underlying rb-sys functionality when needed:
#![allow(unused)] fn main() { use magnus::{function, prelude::*, Error, Ruby}; use rb_sys; use std::os::raw::c_char; fn high_level() -> String { "High level".to_string() } unsafe extern "C" fn low_level(_: rb_sys::VALUE) -> rb_sys::VALUE { // Direct rb-sys implementation let c_string = std::ffi::CString::new("Low level").unwrap(); rb_sys::rb_str_new_cstr(c_string.as_ptr()) } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MixedExample")?; // Use Magnus for most things module.define_singleton_method("high_level", function!(high_level, 0))?; // Use rb-sys directly for special cases unsafe { rb_sys::rb_define_module_function( module.as_raw(), cstr!("low_level"), Some(low_level as unsafe extern "C" fn(rb_sys::VALUE) -> rb_sys::VALUE), 0, ); } Ok(()) } // Helper macro for C strings macro_rules! cstr { ($s:expr) => { concat!($s, "\0").as_ptr() as *const c_char }; } }
Enabling rb-sys Feature in Magnus
To access rb-sys through Magnus, enable the rb-sys
feature:
# Cargo.toml
[dependencies]
magnus = { version = "0.7", features = ["rb-sys"] }
Common Mixing Patterns
-
Use Magnus for most functionality, rb-sys for specific optimizations:
- Define your public API using Magnus for safety and ease
- Drop down to rb-sys in critical performance paths, especially when using
nogvl
-
Use rb-sys for core functionality, Magnus for complex conversions:
- Build core functionality with rb-sys for maximum control
- Use Magnus for handling complex Ruby objects or collections
-
Start with Magnus, optimize with rb-sys over time:
- Begin development with Magnus for rapid progress
- Profile your code and replace hot paths with direct rb-sys
Real-World Examples
Let's look at how real projects decide between these approaches:
Blake3-Ruby (Direct rb-sys)
Blake3-Ruby is a cryptographic hashing library that uses direct rb-sys to achieve maximum performance:
#![allow(unused)] fn main() { // Based on blake3-ruby use rb_sys::{ rb_define_module, rb_define_module_function, rb_string_value_cstr, rb_str_new_cstr, VALUE, }; #[no_mangle] pub extern "C" fn Init_blake3_ext() { unsafe { // Create module and class hierarchy let digest_module = /* ... */; let blake3_class = /* ... */; // Define methods directly using rb-sys for maximum performance rb_define_module_function( blake3_class, cstr!("digest"), Some(rb_blake3_digest as unsafe extern "C" fn(VALUE, VALUE) -> VALUE), 1, ); // More method definitions... } } unsafe extern "C" fn rb_blake3_digest(_klass: VALUE, string: VALUE) -> VALUE { // Extract data from Ruby VALUE let data_ptr = rb_string_value_cstr(&string); let data_len = /* ... */; // Release GVL for CPU-intensive operation let hash = nogvl(|| { blake3::hash(/* ... */) }); // Return result as Ruby string rb_str_new_cstr(/* ... */) } }
LZ4-Flex-RB (Mixed Approach)
The LZ4-Flex-RB gem demonstrates a more sophisticated approach mixing Magnus with direct rb-sys calls:
#![allow(unused)] fn main() { // Based on lz4-flex-rb use magnus::{function, prelude::*, Error, RModule, Ruby}; use rb_sys::{rb_str_locktmp, rb_str_unlocktmp, rb_thread_call_without_gvl}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("Lz4Flex")?; // High-level API using Magnus module.define_singleton_method("compress", function!(compress, 1))?; module.define_singleton_method("decompress", function!(decompress, 1))?; Ok(()) } // Functions that mix high-level Magnus with low-level rb-sys fn compress(input: LockedRString) -> Result<RString, Error> { let bufsize = get_maximum_output_size(input.len()); let mut output = RStringMut::buf_new(bufsize); // Use nogvl_if_large to release GVL for large inputs let outsize = nogvl_if_large(input.len(), || { lz4_flex::block::compress_into(input.as_slice(), output.as_mut_slice()) }).map_err(|e| Error::new(encode_error_class(), e.to_string()))?; output.set_len(outsize); Ok(output.into_inner()) } // Helper for locked RString (uses rb-sys directly) struct LockedRString(RString); impl LockedRString { fn new(string: RString) -> Self { unsafe { rb_str_locktmp(string.as_raw()) }; Self(string) } fn as_slice(&self) -> &[u8] { // Implementation using rb-sys functions } } impl Drop for LockedRString { fn drop(&mut self) { unsafe { rb_str_unlocktmp(self.0.as_raw()) }; } } }
Working with Ruby Objects
Basic Type Conversions
When writing Ruby extensions in Rust, one of the most common tasks is converting between Ruby and Rust types. The magnus crate provides a comprehensive set of conversion functions for this purpose.
Primitive Types
#![allow(unused)] fn main() { use magnus::{RString, Ruby, Value, Integer, Float, Boolean}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), magnus::Error> { // Convert Rust types to Ruby let rb_string: RString = RString::new(ruby, "Hello, Ruby!"); // Rust &str to Ruby String let rb_int: Integer = Integer::from_i64(42); // Rust i64 to Ruby Integer let rb_float: Float = Float::from_f64(3.14159); // Rust f64 to Ruby Float let rb_bool: Boolean = Boolean::from(true); // Rust bool to Ruby true/false // Convert Ruby types to Rust let rust_string: String = rb_string.to_string()?; // Ruby String to Rust String let rust_int: i64 = rb_int.to_i64()?; // Ruby Integer to Rust i64 let rust_float: f64 = rb_float.to_f64()?; // Ruby Float to Rust f64 let rust_bool: bool = rb_bool.to_bool(); // Ruby true/false to Rust bool Ok(()) } }
Checking Types
When working with Ruby objects, you often need to check their types:
#![allow(unused)] fn main() { use magnus::{RString, Ruby, Value, check_type}; fn process_value(ruby: &Ruby, val: Value) -> Result<(), magnus::Error> { if val.is_nil() { println!("Got nil"); } else if let Ok(s) = RString::try_convert(val) { println!("Got string: {}", s.to_string()?); } else if check_type::<Integer>(val) { println!("Got integer: {}", Integer::from_value(val)?.to_i64()?); } else { println!("Got some other type"); } Ok(()) } }
Strings, Arrays, and Hashes
Working with Ruby Strings
Ruby strings are encoded and have more complex behavior than Rust strings:
#![allow(unused)] fn main() { use magnus::{RString, Ruby, Encoding}; fn string_operations(ruby: &Ruby) -> Result<(), magnus::Error> { // Create a new Ruby string let hello = RString::new(ruby, "Hello"); // Concatenate strings let world = RString::new(ruby, " World!"); let message = hello.concat(ruby, world)?; // Get the encoding let encoding = message.encoding(); println!("String encoding: {}", encoding.name()); // Convert to different encoding let utf16 = Encoding::find("UTF-16BE").unwrap(); let utf16_str = message.encode(ruby, utf16, None)?; // Get bytes let bytes = message.as_bytes(); println!("Bytes: {:?}", bytes); // Create from bytes with specific encoding let latin1 = Encoding::find("ISO-8859-1").unwrap(); let bytes = [72, 101, 108, 108, 111]; // "Hello" in ASCII/Latin1 let latin1_str = RString::from_slice(ruby, &bytes, Some(latin1)); Ok(()) } }
Working with Ruby Arrays
Ruby arrays can hold any kind of Ruby object:
#![allow(unused)] fn main() { use magnus::{RArray, Ruby, Value}; fn array_operations(ruby: &Ruby) -> Result<(), magnus::Error> { // Create a new empty array let array = RArray::new(ruby); // Push elements array.push(ruby, 1)?; array.push(ruby, "two")?; array.push(ruby, 3.0)?; // Get length let length = array.len(); println!("Array length: {}", length); // Access elements let first: i64 = array.get(0)?; let second: String = array.get(1)?; let third: f64 = array.get(2)?; // Iterate through elements for i in 0..array.len() { let item: Value = array.get(i)?; println!("Item {}: {:?}", i, item); } // Another way to iterate array.each(|val| { println!("Item: {:?}", val); Ok(()) })?; // Create an array from Rust Vec let numbers = vec![1, 2, 3, 4, 5]; let rb_array = RArray::from_iter(ruby, numbers); // Convert to a Rust Vec let vec: Vec<i64> = rb_array.to_vec()?; Ok(()) } }
Working with Ruby Hashes
Ruby hashes are similar to Rust's HashMap but can use any Ruby object as keys:
#![allow(unused)] fn main() { use magnus::{RHash, Value, Symbol, Ruby}; fn hash_operations(ruby: &Ruby) -> Result<(), magnus::Error> { // Create a new hash let hash = RHash::new(ruby); // Add key-value pairs hash.aset(ruby, "name", "Alice")?; hash.aset(ruby, Symbol::new("age"), 30)?; hash.aset(ruby, 1, "one")?; // Get values let name: String = hash.get(ruby, "name")?; let age: i64 = hash.get(ruby, Symbol::new("age"))?; let one: String = hash.get(ruby, 1)?; // Check if key exists if hash.has_key(ruby, "name")? { println!("Has key 'name'"); } // Delete a key hash.delete(ruby, 1)?; // Iterate over key-value pairs hash.foreach(|k, v| { println!("Key: {:?}, Value: {:?}", k, v); Ok(()) })?; // Convert to a Rust HashMap (if keys and values are convertible) let map: std::collections::HashMap<String, String> = hash.to_hash()?; Ok(()) } }
Handling nil Values
Ruby's nil
is a special value that requires careful handling:
#![allow(unused)] fn main() { use magnus::{Value, Ruby, RNil}; fn handle_nil(ruby: &Ruby, val: Value) -> Result<(), magnus::Error> { // Check if a value is nil if val.is_nil() { println!("Value is nil"); } // Get nil let nil = ruby.nil(); // Options and nil let maybe_string: Option<String> = val.try_convert()?; match maybe_string { Some(s) => println!("Got string: {}", s), None => println!("No string (was nil or couldn't convert)"), } // Explicitly return nil from a function fn returns_nil() -> RNil { RNil::get() } Ok(()) } }
Converting Between Ruby and Rust Types
Magnus provides powerful type conversion traits that make it easy to convert between Ruby and Rust types.
From Rust to Ruby (TryConvert)
#![allow(unused)] fn main() { use magnus::{Value, Ruby, TryConvert, Error}; // Convert custom Rust types to Ruby objects struct Person { name: String, age: u32, } impl TryConvert for Person { fn try_convert(val: Value) -> Result<Self, Error> { let ruby = unsafe { Ruby::get_unchecked() }; let hash = RHash::try_convert(val)?; let name: String = hash.get(ruby, "name")?; let age: u32 = hash.get(ruby, "age")?; Ok(Person { name, age }) } } // Usage fn process_person(val: Value) -> Result<(), Error> { let person: Person = val.try_convert()?; println!("Person: {} ({})", person.name, person.age); Ok(()) } }
From Ruby to Rust (IntoValue)
#![allow(unused)] fn main() { use magnus::{Value, Ruby, IntoValue, Error}; struct Point { x: f64, y: f64, } impl IntoValue for Point { fn into_value_with(self, ruby: &Ruby) -> Result<Value, Error> { let hash = RHash::new(ruby); hash.aset(ruby, "x", self.x)?; hash.aset(ruby, "y", self.y)?; Ok(hash.as_value()) } } // Usage fn create_point(ruby: &Ruby) -> Result<Value, Error> { let point = Point { x: 10.5, y: 20.7 }; point.into_value_with(ruby) } }
Creating Ruby Objects from Rust
Creating Simple Objects
#![allow(unused)] fn main() { use magnus::{RObject, Ruby, Value, class, method}; fn create_objects(ruby: &Ruby) -> Result<(), magnus::Error> { // Create a basic Ruby Object let obj = RObject::new(ruby, ruby.class_object())?; // Instantiate a specific class let time_class = ruby.class_object::<Time>()?; let now = time_class.funcall(ruby, "now", ())?; // Create a Date object let date_class = class::object("Date")?; let today = date_class.funcall(ruby, "today", ())?; // Call methods on the object let formatted: String = today.funcall(ruby, "strftime", ("%Y-%m-%d",))?; Ok(()) } }
Creating Objects with Instance Variables
#![allow(unused)] fn main() { use magnus::{RObject, Ruby, Symbol}; fn create_with_ivars(ruby: &Ruby) -> Result<(), magnus::Error> { // Create a Ruby object let obj = RObject::new(ruby, ruby.class_object())?; // Set instance variables obj.ivar_set(ruby, "@name", "Alice")?; obj.ivar_set(ruby, "@age", 30)?; // Get instance variables let name: String = obj.ivar_get(ruby, "@name")?; let age: i64 = obj.ivar_get(ruby, "@age")?; // Alternatively, use symbols let name_sym = Symbol::new("@name"); let name_value = obj.ivar_get(ruby, name_sym)?; Ok(()) } }
Working with Ruby Methods
#![allow(unused)] fn main() { use magnus::{RObject, Ruby, prelude::*}; fn call_methods(ruby: &Ruby) -> Result<(), magnus::Error> { let array_class = ruby.class_object::<RArray>()?; // Creating an array with methods let array = array_class.funcall(ruby, "new", (5, "hello"))?; // Call methods with different argument patterns array.funcall(ruby, "<<", ("world",))?; // One argument array.funcall(ruby, "insert", (1, "inserted"))?; // Multiple arguments // Call with a block using a closure let mapped = array.funcall_with_block(ruby, "map", (), |arg| { if let Ok(s) = String::try_convert(arg) { Ok(s.len()) } else { Ok(0) } })?; // Methods with keyword arguments let hash_class = ruby.class_object::<RHash>()?; let merge_opts = [( Symbol::new("overwrite"), true )]; let hash = RHash::new(ruby); let other = RHash::new(ruby); hash.funcall_kw(ruby, "merge", (other,), merge_opts)?; Ok(()) } }
Advanced Techniques
Handling Arbitrary Ruby Values
Sometimes you need to work with Ruby values without knowing their type in advance:
#![allow(unused)] fn main() { use magnus::{Value, Ruby, CheckType, Error}; fn describe_value(val: Value) -> Result<String, Error> { let ruby = unsafe { Ruby::get_unchecked() }; if val.is_nil() { return Ok("nil".to_string()); } if let Ok(s) = String::try_convert(val) { return Ok(format!("String: {}", s)); } if let Ok(i) = i64::try_convert(val) { return Ok(format!("Integer: {}", i)); } if let Ok(f) = f64::try_convert(val) { return Ok(format!("Float: {}", f)); } if val.respond_to(ruby, "each")? { return Ok("Enumerable object".to_string()); } // Get the class name let class_name: String = val.class().name(); Ok(format!("Object of class: {}", class_name)) } }
Working with Duck Types
Ruby often uses duck typing rather than relying on concrete classes:
#![allow(unused)] fn main() { use magnus::{Error, Value, Ruby}; fn process_enumerable(ruby: &Ruby, val: Value) -> Result<Value, Error> { // Check if the object responds to 'each' if !val.respond_to(ruby, "each")? { return Err(Error::new( ruby.exception_type_error(), "Expected an object that responds to 'each'" )); } // We can now safely call 'map' which most enumerables support val.funcall_with_block(ruby, "map", (), |item| { if let Ok(n) = i64::try_convert(item) { Ok(n * 2) } else { Ok(item) // Pass through unchanged if not a number } }) } }
Best Practices
-
Always Handle Errors: Type conversions can fail, wrap them in proper error handling.
-
Use try_convert: Prefer
try_convert
over direct conversions to safely handle type mismatches. -
Remember Boxing Rules: All Ruby objects are reference types, while many Rust types are value types.
-
Be Careful with Magic Methods: Some Ruby methods like
method_missing
might not behave as expected when called from Rust. -
Cache Ruby Objects: If you're repeatedly using the same Ruby objects (like classes or symbols), consider caching them using
Lazy
or similar mechanisms. -
Check for nil: Always check for nil values before attempting conversions that don't handle nil.
-
Use Type Annotations: Explicitly specifying types when converting Ruby values to Rust can make your code clearer and avoid potential runtime errors.
-
Pass Ruby State: Always pass the
Ruby
instance through your functions when needed rather than usingRuby::get()
repeatedly, as this is more performant and clearer about dependencies.
Ruby Classes and Modules
This chapter covers how to define and work with Ruby classes and modules from Rust. It explains different approaches for creating Ruby objects, defining methods, and organizing your code.
Defining Modules
Modules in Ruby are used to namespace functionality and define mixins. Here's how to create and use modules in your Rust extension:
Creating a Basic Module
#![allow(unused)] fn main() { use magnus::{define_module, prelude::*, Error, Ruby}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { // Create a top-level module let module = ruby.define_module("MyExtension")?; // Define a method on the module module.define_singleton_method("version", function!(|| "1.0.0", 0))?; // Create a nested module let utils = module.define_module("Utils")?; utils.define_singleton_method("helper", function!(|| "Helper function", 0))?; Ok(()) } }
This creates a module structure that would look like this in Ruby:
module MyExtension
def self.version
"1.0.0"
end
module Utils
def self.helper
"Helper function"
end
end
end
Module Constants
You can define constants in your modules:
#![allow(unused)] fn main() { use magnus::{define_module, Module, Ruby, Error, Value, Symbol}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("Config")?; // Define constants module.const_set::<_, _, Value>(ruby, "VERSION", "1.0.0")?; module.const_set::<_, _, Value>(ruby, "MAX_CONNECTIONS", 100)?; module.const_set::<_, _, Value>(ruby, "DEFAULT_MODE", Symbol::new("production"))?; Ok(()) } }
Using Module Attributes
To maintain module state, a common pattern is storing attributes in the module itself:
#![allow(unused)] fn main() { use magnus::{define_module, function, prelude::*, Error, Module, Ruby}; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; // Store a counter in a static atomic static REQUEST_COUNT: AtomicUsize = AtomicUsize::new(0); // Store configuration in a mutex static CONFIG: Mutex<Option<String>> = Mutex::new(None); fn increment_counter() -> usize { REQUEST_COUNT.fetch_add(1, Ordering::SeqCst) } fn get_config() -> Result<String, Error> { match CONFIG.lock().unwrap().clone() { Some(config) => Ok(config), None => Ok("default".to_string()), } } fn set_config(value: String) -> Result<String, Error> { let mut config = CONFIG.lock().unwrap(); *config = Some(value.clone()); Ok(value) } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("Stats")?; module.define_singleton_method("increment", function!(increment_counter, 0))?; module.define_singleton_method("count", function!(|| REQUEST_COUNT.load(Ordering::SeqCst), 0))?; // Configuration methods module.define_singleton_method("config", function!(get_config, 0))?; module.define_singleton_method("config=", function!(set_config, 1))?; Ok(()) } }
Creating Ruby Classes from Rust Structs
Magnus provides several ways to define Ruby classes that wrap Rust structures. The approach you choose depends on your specific needs.
Using the TypedData Trait (Full Control)
For full control over memory management and Ruby integration:
#![allow(unused)] fn main() { use magnus::{class, define_class, method, prelude::*, DataTypeFunctions, TypedData, Error, Ruby}; // Define a Rust struct #[derive(Debug, TypedData)] #[magnus(class = "MyExtension::Point", free_immediately, size)] struct Point { x: f64, y: f64, } // Implement required trait impl DataTypeFunctions for Point {} // Implement methods impl Point { fn new(x: f64, y: f64) -> Self { Point { x, y } } fn x(&self) -> f64 { self.x } fn y(&self) -> f64 { self.y } fn distance(&self, other: &Point) -> f64 { ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt() } fn to_s(&self) -> String { format!("Point({}, {})", self.x, self.y) } } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyExtension")?; let class = module.define_class("Point", ruby.class_object())?; // Define the constructor class.define_singleton_method("new", function!(|x: f64, y: f64| { Point::new(x, y) }, 2))?; // Define instance methods class.define_method("x", method!(Point::x, 0))?; class.define_method("y", method!(Point::y, 0))?; class.define_method("distance", method!(Point::distance, 1))?; class.define_method("to_s", method!(Point::to_s, 0))?; Ok(()) } }
Using the Wrap Macro (Simplified Approach)
For a simpler approach with less boilerplate:
#![allow(unused)] fn main() { use magnus::{define_class, function, method, prelude::*, Error, Ruby}; // Define a Rust struct struct Rectangle { width: f64, height: f64, } // Use the wrap macro to handle the Ruby class mapping #[magnus::wrap(class = "MyExtension::Rectangle")] impl Rectangle { // Constructor fn new(width: f64, height: f64) -> Self { Rectangle { width, height } } // Instance methods fn width(&self) -> f64 { self.width } fn height(&self) -> f64 { self.height } fn area(&self) -> f64 { self.width * self.height } fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) } } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyExtension")?; let class = module.define_class("Rectangle", ruby.class_object())?; // Register class methods and instance methods class.define_singleton_method("new", function!(Rectangle::new, 2))?; class.define_method("width", method!(Rectangle::width, 0))?; class.define_method("height", method!(Rectangle::height, 0))?; class.define_method("area", method!(Rectangle::area, 0))?; class.define_method("perimeter", method!(Rectangle::perimeter, 0))?; Ok(()) } }
Using RefCell for Mutable Rust Objects
For Ruby objects that need interior mutability:
#![allow(unused)] fn main() { use std::cell::RefCell; use magnus::{define_class, function, method, prelude::*, Error, Ruby}; struct Counter { count: usize, } #[magnus::wrap(class = "MyExtension::Counter")] struct MutCounter(RefCell<Counter>); impl MutCounter { fn new(initial: usize) -> Self { MutCounter(RefCell::new(Counter { count: initial })) } fn count(&self) -> usize { self.0.borrow().count } fn increment(&self) -> usize { let mut counter = self.0.borrow_mut(); counter.count += 1; counter.count } fn increment_by(&self, n: usize) -> usize { let mut counter = self.0.borrow_mut(); counter.count += n; counter.count } // AVOID this pattern which can cause BorrowMutError fn bad_increment_method(&self) -> Result<usize, Error> { // Don't do this - it keeps the borrowing active while trying to borrow_mut if self.0.borrow().count > 10 { // This will panic with "already borrowed: BorrowMutError" self.0.borrow_mut().count += 100; } else { self.0.borrow_mut().count += 1; } Ok(self.0.borrow().count) } // CORRECT pattern - complete the first borrow before starting the second fn good_increment_method(&self) -> Result<usize, Error> { // Copy the value first let current_count = self.0.borrow().count; // Then the first borrow is dropped and we can borrow_mut safely if current_count > 10 { self.0.borrow_mut().count += 100; } else { self.0.borrow_mut().count += 1; } Ok(self.0.borrow().count) } } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyExtension")?; let class = module.define_class("Counter", ruby.class_object())?; class.define_singleton_method("new", function!(MutCounter::new, 1))?; class.define_method("count", method!(MutCounter::count, 0))?; class.define_method("increment", method!(MutCounter::increment, 0))?; class.define_method("increment_by", method!(MutCounter::increment_by, 1))?; class.define_method("good_increment", method!(MutCounter::good_increment_method, 0))?; Ok(()) } }
Implementing Ruby Methods
Magnus provides flexible macros to help define methods with various signatures.
Function vs Method Macros
Magnus provides two primary macros for defining callable Ruby code:
function!
- For singleton/class methods and module functionsmethod!
- For instance methods when you need access to the Rust object (&self
)
Here's how to use each:
#![allow(unused)] fn main() { use magnus::{function, method, define_class, prelude::*, Error, Ruby}; struct Calculator {} #[magnus::wrap(class = "Calculator")] impl Calculator { // Constructor - a class method fn new() -> Self { Calculator {} } // Regular instance method that doesn't raise exceptions fn add(&self, a: i64, b: i64) -> i64 { a + b } // Method that needs the Ruby interpreter to raise an exception fn divide(ruby: &Ruby, _rb_self: &Self, a: i64, b: i64) -> Result<i64, Error> { if b == 0 { return Err(Error::new( ruby.exception_zero_div_error(), "Division by zero" )); } Ok(a / b) } // Class method that doesn't need a Calculator instance fn version() -> &'static str { "1.0.0" } } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let class = ruby.define_class("Calculator", ruby.class_object())?; // Register the constructor with function! class.define_singleton_method("new", function!(Calculator::new, 0))?; // Register a class method with function! class.define_singleton_method("version", function!(Calculator::version, 0))?; // Register instance methods with method! class.define_method("add", method!(Calculator::add, 2))?; class.define_method("divide", method!(Calculator::divide, 2))?; Ok(()) } }
Method Signature Patterns
There are several common method signature patterns depending on what your method needs to do:
Basic Method (no Ruby access, no exceptions)
#![allow(unused)] fn main() { fn add(&self, a: i64, b: i64) -> i64 { a + b } }
Method that Raises Exceptions
#![allow(unused)] fn main() { fn divide(ruby: &Ruby, _rb_self: &Self, a: i64, b: i64) -> Result<i64, Error> { if b == 0 { return Err(Error::new( ruby.exception_zero_div_error(), "Division by zero" )); } Ok(a / b) } }
Method that Needs to Access Self by Value
#![allow(unused)] fn main() { // Usually for cloning or consuming self fn clone_and_modify(rb_self: Value) -> Result<Value, Error> { let ruby = unsafe { Ruby::get_unchecked() }; let obj = ruby.class_object::<Calculator>()?.new_instance(())?; // Modify obj... Ok(obj) } }
Method with Ruby Block
#![allow(unused)] fn main() { fn with_retries(ruby: &Ruby, _rb_self: &Self, max_retries: usize, block: Proc) -> Result<Value, Error> { let mut retries = 0; loop { match block.call(ruby, ()) { Ok(result) => return Ok(result), Err(e) if retries < max_retries => { retries += 1; // Maybe backoff or log error }, Err(e) => return Err(e), } } } }
Class Inheritance and Mixins
Ruby supports a rich object model with single inheritance and multiple module inclusion. Magnus allows you to replicate this model in your Rust extension.
Creating a Subclass
#![allow(unused)] fn main() { use magnus::{Module, class, define_class, method, prelude::*, Error, Ruby}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { // Get the parent class (Ruby's built-in Array) let array_class = ruby.class_object::<RArray>()?; // Create a subclass let sorted_array = ruby.define_class("SortedArray", array_class)?; // Override the << (push) method to keep the array sorted sorted_array.define_method("<<", method!(|ruby, rb_self: Value, item: Value| { let array = RArray::from_value(rb_self)?; array.push(ruby, item)?; // Call sort! to keep the array sorted array.funcall(ruby, "sort!", ())?; Ok(rb_self) // Return self for method chaining }, 1))?; Ok(()) } }
Including Modules (Mixins)
#![allow(unused)] fn main() { use magnus::{Module, class, define_class, define_module, method, prelude::*, Error, Ruby}; fn make_comparable(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyComparable")?; // Define methods for the module module.define_method("<=>", method!(|_ruby, rb_self: Value, other: Value| { // Implementation of the spaceship operator for comparison let self_num: Result<i64, _> = rb_self.try_convert(); let other_num: Result<i64, _> = other.try_convert(); match (self_num, other_num) { (Ok(a), Ok(b)) => Ok(a.cmp(&b) as i8), _ => Ok(nil()), } }, 1))?; // Define methods that depend on <=> module.define_method("==", method!(|ruby, rb_self: Value, other: Value| { let result: i8 = rb_self.funcall(ruby, "<=>", (other,))?; Ok(result == 0) }, 1))?; module.define_method(">", method!(|ruby, rb_self: Value, other: Value| { let result: i8 = rb_self.funcall(ruby, "<=>", (other,))?; Ok(result > 0) }, 1))?; module.define_method("<", method!(|ruby, rb_self: Value, other: Value| { let result: i8 = rb_self.funcall(ruby, "<=>", (other,))?; Ok(result < 0) }, 1))?; Ok(()) } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { // Create our module make_comparable(ruby)?; // Create a class let score = ruby.define_class("Score", ruby.class_object())?; // Define methods score.define_singleton_method("new", function!(|value: i64| { let obj = RObject::new(ruby.class_object::<Score>())?; obj.ivar_set(ruby, "@value", value)?; Ok(obj) }, 1))?; score.define_method("value", method!(|ruby, rb_self: Value| { rb_self.ivar_get::<_, i64>(ruby, "@value") }, 0))?; // Include our module let comparable = ruby.define_module("MyComparable")?; score.include_module(ruby, comparable)?; Ok(()) } }
Working with Singleton Methods
Singleton methods in Ruby are methods attached to individual objects, not to their class. The most common use is defining class methods, but they can be applied to any object.
Defining a Class with Both Instance and Singleton Methods
#![allow(unused)] fn main() { use magnus::{class, define_class, function, method, prelude::*, Error, Ruby, Value}; #[magnus::wrap(class = "Logger")] struct Logger { level: String, } impl Logger { fn new(level: String) -> Self { Logger { level } } fn log(&self, message: String) -> String { format!("[{}] {}", self.level, message) } // Class methods (singleton methods) fn default_level() -> &'static str { "INFO" } fn create_default(ruby: &Ruby) -> Result<Value, Error> { let class = ruby.class_object::<Logger>()?; let default_level = Self::default_level(); class.new_instance((default_level,)) } } #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let class = ruby.define_class("Logger", ruby.class_object())?; // Instance methods class.define_singleton_method("new", function!(Logger::new, 1))?; class.define_method("log", method!(Logger::log, 1))?; // Class methods using function! macro class.define_singleton_method("default_level", function!(Logger::default_level, 0))?; class.define_singleton_method("create_default", function!(Logger::create_default, 0))?; Ok(()) } }
Attaching Methods to a Specific Object (True Singleton Methods)
#![allow(unused)] fn main() { use magnus::{module, function, prelude::*, Error, Ruby, Value}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { // Create a single object let config = ruby.eval::<Value>("Object.new")?; // Define singleton methods directly on that object config.define_singleton_method(ruby, "get", function!(|| { "Configuration value" }, 0))?; config.define_singleton_method(ruby, "enabled?", function!(|| { true }, 0))?; // Make it globally accessible ruby.define_global_const("CONFIG", config)?; Ok(()) } }
This creates an object that can be used in Ruby like:
CONFIG.get # => "Configuration value"
CONFIG.enabled? # => true
CONFIG.class # => Object
Best Practices
-
Use magnus macros for class definition: The
wrap
andTypedData
macros simplify class definition significantly. -
Consistent naming: Keep Ruby and Rust naming conventions consistent within their domains (snake_case for Ruby methods, CamelCase for Ruby classes).
-
Layer your API: Consider providing both low-level and high-level APIs for complex functionality.
-
Document method signatures: When using methods that can raise exceptions, document which exceptions can be raised.
-
RefCell borrowing pattern: Always release a
borrow()
before callingborrow_mut()
by copying any needed values. -
Method macro selection: Use
function!
for singleton methods andmethod!
for instance methods. -
Include the Ruby parameter: Always include
ruby: &Ruby
in your method signature if your method might raise exceptions or interact with the Ruby runtime. -
Reuse existing Ruby patterns: When designing your API, follow existing Ruby conventions that users will already understand.
-
Cache Ruby classes and modules: Use
Lazy
to cache frequently accessed classes and modules. -
Maintain object hierarchy: Properly use Ruby's inheritance and module system to organize your code."
Error Handling in Rust Ruby Extensions
Proper error handling is critical for robust Ruby extensions. This guide covers how to handle errors in Rust and map them to appropriate Ruby exceptions.
Improper error handling can lead to crashes that take down the entire Ruby VM. This chapter shows you how to properly raise and handle exceptions in your Rust extensions.
Overview of Error Handling Approaches
When building Ruby extensions with Rust, you'll typically use one of these error handling patterns:
- Result-based error handling: Using Rust's
Result<T, E>
type to return errors - Ruby exception raising: Converting Rust errors into Ruby exceptions
- Panic catching: Handling unexpected Rust panics and converting them to Ruby exceptions
The most common pattern in rb-sys extensions is to use Rust's Result<T, magnus::Error>
type, where the Error
type
represents a Ruby exception that can be raised.
The Result Type and Magnus::Error
Magnus uses Result<T, Error>
as the standard way to handle errors. The Error
type represents a Ruby exception that
can be raised:
#![allow(unused)] fn main() { use magnus::{Error, Ruby}; fn might_fail(ruby: &Ruby, value: i64) -> Result<i64, Error> { if value < 0 { return Err(Error::new( ruby.exception_arg_error(), "Value must be positive" )); } Ok(value * 2) } }
The Error
type:
- Contains a reference to a Ruby exception class
- Includes an error message
- Can be created from an existing Ruby exception
Mapping Rust Errors to Ruby Exceptions
Standard Ruby Exception Types
Magnus provides access to all standard Ruby exception types:
#![allow(unused)] fn main() { use magnus::{Error, Ruby}; fn divide(ruby: &Ruby, a: f64, b: f64) -> Result<f64, Error> { if b == 0.0 { return Err(Error::new( ruby.exception_zero_div_error(), "Division by zero" )); } Ok(a / b) } fn process_array(ruby: &Ruby, index: isize, array: RArray) -> Result<Value, Error> { if index < 0 || index >= array.len() as isize { return Err(Error::new( ruby.exception_index_error(), format!("Index {} out of bounds (0..{})", index, array.len() - 1) )); } array.get(index as usize) } fn parse_number(ruby: &Ruby, input: &str) -> Result<i64, Error> { match input.parse::<i64>() { Ok(num) => Ok(num), Err(_) => Err(Error::new( ruby.exception_arg_error(), format!("Cannot parse '{}' as a number", input) )), } } }
Common Ruby exception types available through the Ruby API:
Method | Exception Class | Typical Use Case |
---|---|---|
ruby.exception_arg_error() | ArgumentError | Invalid argument value or type |
ruby.exception_index_error() | IndexError | Array/string index out of bounds |
ruby.exception_key_error() | KeyError | Hash key not found |
ruby.exception_name_error() | NameError | Reference to undefined name |
ruby.exception_no_memory_error() | NoMemoryError | Memory allocation failure |
ruby.exception_not_imp_error() | NotImplementedError | Feature not implemented |
ruby.exception_range_error() | RangeError | Value outside valid range |
ruby.exception_regexp_error() | RegexpError | Invalid regular expression |
ruby.exception_runtime_error() | RuntimeError | General runtime error |
ruby.exception_script_error() | ScriptError | Problem in script execution |
ruby.exception_syntax_error() | SyntaxError | Invalid syntax |
ruby.exception_type_error() | TypeError | Type mismatch |
ruby.exception_zero_div_error() | ZeroDivisionError | Division by zero |
Creating Custom Exception Classes
You can define custom exception classes for your extension:
#![allow(unused)] fn main() { use magnus::{class, Error, Ruby}; #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyExtension")?; // Create custom exception classes let std_error = ruby.exception_standard_error(); let custom_error = module.define_class("CustomError", std_error)?; let validation_error = module.define_class("ValidationError", custom_error)?; // Register them as constants for easier access ruby.define_global_const("MY_CUSTOM_ERROR", custom_error)?; Ok(()) } // Using the custom exception fn validate(ruby: &Ruby, value: &str) -> Result<(), Error> { if value.is_empty() { return Err(Error::new( ruby.class_path_to_value("MyExtension::ValidationError"), "Validation failed: value cannot be empty" )); } Ok(()) } }
Passing and Re-raising Ruby Exceptions
You can pass along existing Ruby exceptions:
#![allow(unused)] fn main() { use magnus::{Error, Ruby, Value}; fn process_data(ruby: &Ruby, input: Value) -> Result<Value, Error> { // Call a method that might raise let result = match input.funcall(ruby, "process", ()) { Ok(val) => val, Err(err) => return Err(err), // Pass along the original error }; // Or with the ? operator let result = input.funcall(ruby, "process", ())?; Ok(result) } }
For wrapping and adding context to errors:
#![allow(unused)] fn main() { fn compute_with_context(ruby: &Ruby, input: Value) -> Result<Value, Error> { match complex_operation(ruby, input) { Ok(result) => Ok(result), Err(err) => { // Create a new error with additional context Err(Error::new( ruby.exception_runtime_error(), format!("Computation failed: {}", err.message(ruby).unwrap_or_default()) )) } } } }
Handling Rust Panics
Rust panics should be caught and converted to Ruby exceptions to prevent crashing the Ruby VM:
#![allow(unused)] fn main() { use magnus::{Error, Ruby}; use std::panic::{self, catch_unwind}; fn dangerous_operation(ruby: &Ruby, input: i64) -> Result<i64, Error> { // Catch any potential panics let result = catch_unwind(|| { // Code that might panic if input == 0 { panic!("Unexpected zero value"); } input * 2 }); match result { Ok(value) => Ok(value), Err(_) => Err(Error::new( ruby.exception_runtime_error(), "Internal error: Rust panic occurred" )), } } }
Error Handling Patterns
The Question Mark Operator
The ?
operator simplifies error handling by automatically propagating errors:
#![allow(unused)] fn main() { fn multi_step_operation(ruby: &Ruby, value: i64) -> Result<i64, Error> { // Each operation can fail, ? will return early on error let step1 = validate_input(ruby, value)?; let step2 = transform_data(ruby, step1)?; let step3 = final_calculation(ruby, step2)?; Ok(step3) } }
Pattern Matching on Errors
For more sophisticated error handling, pattern match on error types:
#![allow(unused)] fn main() { fn handle_specific_errors(ruby: &Ruby, value: Value) -> Result<Value, Error> { let result = value.funcall(ruby, "some_method", ()); match result { Ok(val) => Ok(val), Err(err) if err.is_kind_of(ruby, ruby.exception_zero_div_error()) => { // Handle division by zero specially Ok(ruby.integer_from_i64(0)) }, Err(err) if err.is_kind_of(ruby, ruby.exception_arg_error()) => { // Convert ArgumentError to a different type Err(Error::new( ruby.exception_runtime_error(), format!("Invalid argument: {}", err.message(ruby).unwrap_or_default()) )) }, Err(err) => Err(err), // Pass through other errors } } }
Context Managers / RAII Pattern
Use Rust's RAII (Resource Acquisition Is Initialization) pattern for cleanup operations:
#![allow(unused)] fn main() { use magnus::{Error, Ruby}; use std::fs::File; use std::io::{self, Read}; struct TempResource { data: Vec<u8>, } impl TempResource { fn new() -> Self { // Allocate resource TempResource { data: Vec::new() } } } impl Drop for TempResource { fn drop(&mut self) { // Clean up will happen automatically, even if an error occurs println!("Cleaning up resource"); } } fn process_with_resource(ruby: &Ruby) -> Result<Value, Error> { // Resource is created let mut resource = TempResource::new(); // If an error occurs here, resource will still be cleaned up let file_result = File::open("data.txt"); let mut file = match file_result { Ok(f) => f, Err(e) => return Err(Error::new( ruby.exception_io_error(), format!("Could not open file: {}", e) )), }; // Resource will be dropped at the end of this scope Ok(ruby.ary_new_from_ary(&[1, 2, 3])) } }
Best Practices for Error Handling
Following these practices will make your extensions more robust and provide a better experience for users.
1. Be Specific with Exception Types
Choose the most appropriate Ruby exception type:
Ruby has a rich hierarchy of exception types. Using the specific exception type helps users handle errors properly in their Ruby code.
2. Provide Clear Error Messages
Include relevant details in error messages:
#![allow(unused)] fn main() { // ✅ GOOD: Descriptive error with context let err_msg = format!( "Cannot parse '{}' as a number in range {}-{}", input, min, max ); // ❌ BAD: Vague error message let err_msg = "Invalid input"; }
3. Maintain Ruby Error Hierarchies
Respect Ruby's exception hierarchy:
#![allow(unused)] fn main() { // ✅ GOOD: Proper exception hierarchy let io_error = ruby.exception_io_error(); let file_error = module.define_class("FileError", io_error)?; let format_error = module.define_class("FormatError", file_error)?; // ❌ BAD: Improper exception hierarchy let format_error = module.define_class("FormatError", ruby.class_object())?; // Not inheriting from StandardError }
4. Avoid Panicking
Use Result
instead of panic:
#![allow(unused)] fn main() { // ✅ GOOD: Return Result for expected error conditions fn process(value: i64) -> Result<i64, Error> { if value < 0 { return Err(Error::new( ruby.exception_arg_error(), "Value must be positive" )); } Ok(value * 2) } // ❌ BAD: Panicking on expected error condition fn process(value: i64) -> i64 { if value < 0 { panic!("Value must be positive"); // Will crash the Ruby VM! } value * 2 } }
5. Catch All Ruby Exceptions
When calling Ruby methods, always handle exceptions:
#![allow(unused)] fn main() { // ✅ GOOD: Catch exceptions from Ruby method calls let result = match obj.funcall(ruby, "some_method", ()) { Ok(val) => val, Err(err) => { // Handle or re-raise the error return Err(err); } }; // ❌ BAD: Not handling potential Ruby exceptions let result = obj.funcall(ruby, "some_method", ()).unwrap(); // May panic! }
Error Handling with RefCell
When using RefCell
for interior mutability, handle borrow errors gracefully:
#![allow(unused)] fn main() { use std::cell::RefCell; use magnus::{Error, Ruby}; #[magnus::wrap(class = "Counter")] struct MutCounter(RefCell<u64>); impl MutCounter { fn new() -> Self { MutCounter(RefCell::new(0)) } fn increment(ruby: &Ruby, self_: &Self) -> Result<u64, Error> { match self_.0.try_borrow_mut() { Ok(mut value) => { *value += 1; Ok(*value) }, Err(_) => Err(Error::new( ruby.exception_runtime_error(), "Cannot modify counter: already borrowed" )), } } // Better approach: complete borrows before starting new ones fn safe_increment(&self) -> u64 { let mut value = self.0.borrow_mut(); *value += 1; *value } } }
Conclusion
Never use unwrap()
or expect()
in production code for your Ruby extensions. These can cause panics that will crash
the Ruby VM. Always use proper error handling with Result
and Error
types.
Effective error handling makes your Ruby extensions more robust and user-friendly. By using the right exception types and providing clear error messages, you create a better experience for users of your extension.
Remember these key points:
- Use
Result<T, Error>
for functions that can fail - Choose appropriate Ruby exception types
- Provide clear, detailed error messages
- Handle Rust panics to prevent VM crashes
- Respect Ruby's exception hierarchy
After you've handled errors in your Rust code, try to test your extension with invalid inputs to ensure it fails gracefully with appropriate Ruby exceptions rather than crashing.
Memory Management & Safety
One of the most important aspects of writing Ruby extensions is proper memory management. This chapter covers how Ruby's garbage collector interacts with Rust objects and how to ensure your extensions don't leak memory or cause segmentation faults.
Improper memory management is the leading cause of crashes and security vulnerabilities in native extensions. Rust's safety guarantees help prevent many common issues, but you still need to carefully manage the boundary between Ruby and Rust memory.
Ruby's Garbage Collection System
Ruby uses a mark-and-sweep garbage collector to manage memory. Understanding how it works is essential for writing safe extensions:
- Marking Phase: Ruby traverses all visible objects, marking them as "in use"
- Sweeping Phase: Objects that weren't marked are considered garbage and freed
When you create Rust objects that reference Ruby objects, you need to tell Ruby's GC about these references to prevent
premature garbage collection. The TypedData
trait and mark
method provide the mechanism to do this.
TypedData and DataTypeFunctions
Magnus provides a TypedData
trait and DataTypeFunctions
trait for managing Ruby objects that wrap Rust structs. This
is the recommended way to handle complex objects in Rust.
Basic TypedData Example
Here's how to define a simple Ruby object that wraps a Rust struct:
#![allow(unused)] fn main() { use magnus::{prelude::*, Error, Ruby, TypedData, DataTypeFunctions}; // Define your Rust struct #[derive(TypedData)] #[magnus(class = "MyExtension::Counter", free_immediately)] struct Counter { count: i64, } // Implement required functions impl DataTypeFunctions for Counter {} // Implement methods for your struct impl Counter { fn new(initial_value: i64) -> Self { Counter { count: initial_value } } fn increment(&mut self, amount: i64) -> i64 { self.count += amount; self.count } fn value(&self) -> i64 { self.count } } // Register with Ruby fn init(ruby: &Ruby) -> Result<(), Error> { let class = ruby.define_class("Counter", ruby.class_object())?; class.define_singleton_method("new", function!(|initial: i64| { Ok(class.wrap(Counter::new(initial))) }, 1))?; class.define_method("increment", method!(Counter::increment, 1))?; class.define_method("value", method!(Counter::value, 0))?; Ok(()) } }
Implementing GC Marking
When your Rust struct holds references to Ruby objects, you need to implement the mark
method to tell Ruby's GC about
those references. Here's a simple example:
#![allow(unused)] fn main() { use magnus::{ prelude::*, Error, Ruby, Value, TypedData, DataTypeFunctions, gc::Marker, typed_data::Obj }; // A struct that holds references to Ruby objects #[derive(TypedData)] #[magnus(class = "MyExtension::Person", free_immediately, mark)] struct Person { // Reference to a Ruby string (their name) name: Value, // Reference to Ruby array (their hobbies) hobbies: Value, // Optional reference to another Person (their friend) friend: Option<Obj<Person>>, } // Implement DataTypeFunctions with mark method impl DataTypeFunctions for Person { // This is called during GC mark phase fn mark(&self, marker: &Marker) { // Mark the Ruby objects we reference marker.mark(self.name); marker.mark(self.hobbies); // If we have a friend, mark them too if let Some(ref friend) = self.friend { marker.mark(*friend); } } } impl Person { fn new(name: Value, hobbies: Value) -> Self { Self { name, hobbies, friend: None, } } fn add_friend(&mut self, friend: Obj<Person>) { self.friend = Some(friend); } fn name(&self) -> Value { self.name } fn hobbies(&self) -> Value { self.hobbies } fn friend(&self) -> Option<Obj<Person>> { self.friend.clone() } } // Register with Ruby fn init(ruby: &Ruby) -> Result<(), Error> { let class = ruby.define_class("Person", ruby.class_object())?; class.define_singleton_method("new", function!(|name: Value, hobbies: Value| { Ok(class.wrap(Person::new(name, hobbies))) }, 2))?; class.define_method("name", method!(Person::name, 0))?; class.define_method("hobbies", method!(Person::hobbies, 0))?; class.define_method("friend", method!(Person::friend, 0))?; class.define_method("add_friend", method!(Person::add_friend, 1))?; Ok(()) } }
In this example:
- The
Person
struct holds references to Ruby objects (name
andhobbies
) and another wrapped Rust object (friend
) - We implement the
mark
method to tell Ruby's GC about all these references - During garbage collection, Ruby will know not to collect these objects as long as the
Person
is alive
A Real-World Example: Trap from wasmtime-rb
Here's a slightly simplified version of a real-world example from the wasmtime-rb project:
#![allow(unused)] fn main() { use magnus::{ prelude::*, method, Error, Ruby, TypedData, DataTypeFunctions, typed_data::Obj, Symbol }; // A struct representing a WebAssembly trap (error) #[derive(TypedData)] #[magnus(class = "Wasmtime::Trap", size, free_immediately)] pub struct Trap { trap: wasmtime::Trap, wasm_backtrace: Option<wasmtime::WasmBacktrace>, } // No references to Ruby objects, so mark is empty impl DataTypeFunctions for Trap {} impl Trap { pub fn new(trap: wasmtime::Trap, wasm_backtrace: Option<wasmtime::WasmBacktrace>) -> Self { Self { trap, wasm_backtrace, } } // Return a text description of the trap error pub fn message(&self) -> String { self.trap.to_string() } // Return the wasm backtrace if available pub fn wasm_backtrace_message(&self) -> Option<String> { self.wasm_backtrace.as_ref().map(|bt| format!("{bt}")) } // Return the trap code as a Ruby symbol pub fn code(&self) -> Result<Option<Symbol>, Error> { match self.trap { wasmtime::Trap::StackOverflow => Ok(Some(Symbol::new("STACK_OVERFLOW"))), wasmtime::Trap::MemoryOutOfBounds => Ok(Some(Symbol::new("MEMORY_OUT_OF_BOUNDS"))), // More cases... _ => Ok(Some(Symbol::new("UNKNOWN"))), } } // Custom inspect method pub fn inspect(rb_self: Obj<Self>) -> Result<String, Error> { Ok(format!( "#<Wasmtime::Trap:0x{:016x} @trap_code={}>", rb_self.as_raw(), rb_self.code()?.map_or("nil".to_string(), |s| s.to_string()) )) } } // Register with Ruby pub fn init(ruby: &Ruby) -> Result<(), Error> { let class = ruby.define_class("Trap", ruby.class_object())?; class.define_method("message", method!(Trap::message, 0))?; class.define_method("wasm_backtrace_message", method!(Trap::wasm_backtrace_message, 0))?; class.define_method("code", method!(Trap::code, 0))?; class.define_method("inspect", method!(Trap::inspect, 0))?; Ok(()) } }
This example shows:
- A Rust struct that wraps WebAssembly-specific types
- Methods that convert Rust values to Ruby-friendly types
- A simple implementation of
DataTypeFunctions
(since there are no Ruby object references to mark)
More Complex Example: Memory References
Let's look at a more complex scenario involving memory management:
#![allow(unused)] fn main() { use magnus::{ prelude::*, gc::Marker, Error, Ruby, TypedData, DataTypeFunctions, typed_data::Obj, value::Opaque, RString }; // Represents WebAssembly memory #[derive(TypedData)] #[magnus(class = "Wasmtime::Memory", free_immediately, mark)] struct Memory { // Reference to a store (context) object store: StoreContext, // The actual WebAssembly memory memory: WasmMemory, } impl DataTypeFunctions for Memory { fn mark(&self, marker: &Marker) { // Mark the store so it stays alive self.store.mark(marker); } } // A guard that ensures memory access is safe struct MemoryGuard { // Reference to Memory object memory: Opaque<Obj<Memory>>, // Size when created, to detect resizing original_size: u64, } impl MemoryGuard { fn new(memory: Obj<Memory>) -> Result<Self, Error> { let original_size = memory.size()?; Ok(Self { memory: memory.into(), original_size, }) } fn get(&self) -> Result<&Memory, Error> { let ruby = Ruby::get().unwrap(); let mem = ruby.get_inner_ref(&self.memory); // Check that memory size hasn't changed if mem.size()? != self.original_size { return Err(Error::new( magnus::exception::runtime_error(), "memory was resized, reference is no longer valid" )); } Ok(mem) } fn mark(&self, marker: &Marker) { marker.mark(self.memory); } } // A slice of WebAssembly memory #[derive(TypedData)] #[magnus(class = "Wasmtime::MemorySlice", free_immediately, mark)] struct MemorySlice { guard: MemoryGuard, offset: usize, size: usize, } impl DataTypeFunctions for MemorySlice { fn mark(&self, marker: &Marker) { // Mark the memory guard, which marks the memory object self.guard.mark(marker); } } impl MemorySlice { fn new(memory: Obj<Memory>, offset: usize, size: usize) -> Result<Self, Error> { let guard = MemoryGuard::new(memory)?; // Validate the slice is in bounds let mem = guard.get()?; if offset + size > mem.data_size()? { return Err(Error::new( magnus::exception::range_error(), "memory slice out of bounds" )); } Ok(Self { guard, offset, size, }) } // Read the slice as a Ruby string (efficiently, without copying) fn to_str(&self) -> Result<RString, Error> { let ruby = unsafe { Ruby::get_unchecked() }; let mem = self.guard.get()?; let data = mem.data()?; // Extract the relevant slice let slice = &data[self.offset..self.offset + self.size]; // Create a Ruby string directly from the slice (zero-copy) Ok(ruby.str_from_slice(slice)) } // Read the slice as a UTF-8 string (with validation) fn to_utf8_str(&self) -> Result<RString, Error> { let ruby = unsafe { Ruby::get_unchecked() }; let mem = self.guard.get()?; let data = mem.data()?; // Extract the relevant slice let slice = &data[self.offset..self.offset + self.size]; // Validate UTF-8 and create a Ruby string match std::str::from_utf8(slice) { Ok(s) => Ok(RString::new(s)), Err(e) => Err(Error::new( magnus::exception::encoding_error(), format!("invalid UTF-8: {}", e) )) } } } }
This more advanced example demonstrates:
- Guarded Resource Access: The
MemoryGuard
ensures memory operations are safe by checking for resizing - Proper GC Integration: Both structs implement marking to ensure referenced objects aren't collected
- Efficient String Creation: Using
str_from_slice
to create strings directly from memory without extra copying - Error Handling: All operations that might fail return meaningful errors
- Resource Validation: The code validates bounds before accessing memory
Common Memory Management Pitfalls
These pitfalls can lead to crashes, memory leaks, or undefined behavior in your Ruby extensions. Understanding and avoiding them is crucial for writing reliable code.
1. Forgetting to Mark References
If your Rust struct holds Ruby objects but doesn't implement marking, those objects might be collected while still in use:
Click the eye icon () to see an additional example of marking multiple references in a more complex struct.
2. Creating Cyclic References
Cyclic references (A references B, which references A) can lead to memory leaks. Consider using weak references or redesigning your object graph.
3. Inefficient String Creation
String handling is often a performance bottleneck in Ruby extensions. Using the right APIs can significantly improve performance.
Creating strings inefficiently can significantly impact performance:
Both memory usage and performance are significantly improved by avoiding unnecessary allocations and copies. The eye icon () reveals additional efficient string handling examples.
4. Not Handling Exceptions Properly
Ruby exceptions can disrupt the normal flow of your code. Ensure resources are cleaned up even when exceptions occur.
RefCell and Interior Mutability
When creating Ruby objects with Rust, you'll often need to use interior mutability patterns. The most common approach is
using RefCell
to allow your Ruby objects to be mutated even when users hold immutable references to them.
Understanding RefCell and Borrowing
Rust's RefCell
allows mutable access to data through shared references, but enforces Rust's borrowing rules at
runtime. This is perfect for Ruby extension objects, where Ruby owns the object and we interact with it via method
calls.
A common pattern is to wrap your Rust struct in a RefCell
:
#![allow(unused)] fn main() { use std::cell::RefCell; use magnus::{prelude::*, Error, Ruby}; struct Counter { count: i64, } #[magnus::wrap(class = "MyExtension::Counter")] struct MutCounter(RefCell<Counter>); impl MutCounter { fn new(initial: i64) -> Self { Self(RefCell::new(Counter { count: initial })) } fn count(&self) -> i64 { self.0.borrow().count } fn increment(&self) -> i64 { let mut counter = self.0.borrow_mut(); counter.count += 1; counter.count } } }
The BorrowMutError Problem
A common mistake when using RefCell
is trying to borrow mutably when you already have an active immutable borrow. This
leads to a BorrowMutError
panic:
#![allow(unused)] fn main() { // BAD - will panic with "already borrowed: BorrowMutError" fn buggy_add(&self, val: i64) -> Result<i64, Error> { // First borrow is still active when we try to borrow_mut below if let Some(sum) = self.0.borrow().count.checked_add(val) { self.0.borrow_mut().count = sum; // ERROR - already borrowed above Ok(sum) } else { Err(Error::new( ruby.exception_range_error(), "result too large" )) } } }
The problem is that the borrow()
in the if
condition is still active when we try to use borrow_mut()
in the body.
Rust's borrow checker would catch this at compile time for normal references, but RefCell
defers this check to
runtime, resulting in a panic.
The Solution: Complete Borrows Before Mutating
The solution is to complete all immutable borrows before starting mutable ones:
#![allow(unused)] fn main() { // GOOD - copy the value first to complete the borrow fn safe_add(&self, val: i64) -> Result<i64, Error> { // Get the current count, completing this borrow let current_count = self.0.borrow().count; // Now we can safely borrow mutably if let Some(sum) = current_count.checked_add(val) { self.0.borrow_mut().count = sum; // Safe now Ok(sum) } else { Err(Error::new( ruby.exception_range_error(), "result too large" )) } } }
By copying count
to a local variable, we complete the immutable borrow before starting the mutable one, avoiding the
runtime panic.
Complex Example with Multiple Operations
When working with more complex data structures:
#![allow(unused)] fn main() { struct Game { players: Vec<String>, current_player: usize, score: i64, } #[magnus::wrap(class = "MyGame")] struct MutGame(RefCell<Game>); impl MutGame { fn new() -> Self { Self(RefCell::new(Game { players: Vec::new(), current_player: 0, score: 0, })) } // INCORRECT: Multiple borrows that will cause issues fn buggy_next_player_scores(&self, points: i64) -> Result<String, Error> { let game = self.0.borrow(); if game.players.is_empty() { return Err(Error::new( magnus::exception::runtime_error(), "No players in game" )); } // This would panic - we're still borrowing game let mut game_mut = self.0.borrow_mut(); game_mut.score += points; let player = game_mut.current_player; game_mut.current_player = (player + 1) % game_mut.players.len(); Ok(format!("{} scored {} points! New total: {}", game_mut.players[player], points, game_mut.score)) } // CORRECT: Copy all needed data before releasing the borrow fn safe_next_player_scores(&self, points: i64) -> Result<String, Error> { // Read all the data we need first let player_name: String; let new_player_index: usize; let new_score: i64; { // Create a block scope to ensure the borrow is dropped let game = self.0.borrow(); if game.players.is_empty() { return Err(Error::new( magnus::exception::runtime_error(), "No players in game" )); } player_name = game.players[game.current_player].clone(); new_player_index = (game.current_player + 1) % game.players.len(); new_score = game.score + points; } // borrow is dropped here // Now we can borrow mutably let mut game = self.0.borrow_mut(); game.score = new_score; game.current_player = new_player_index; Ok(format!("{} scored {} points! New total: {}", player_name, points, new_score)) } } }
Using Temporary Variables Instead of Block Scopes
If you prefer, you can use temporary variables instead of block scopes:
#![allow(unused)] fn main() { fn add_player(&self, player: String) -> Result<usize, Error> { // Get the current number of players first let player_count = self.0.borrow().players.len(); // Now we can mutate let mut game = self.0.borrow_mut(); game.players.push(player); Ok(player_count + 1) // Return new count } }
RefCell Best Practices
-
Complete All Borrows: Always complete immutable borrows before starting mutable borrows.
-
Use Block Scopes or Variables: Either use block scopes to limit borrow lifetimes or copy needed values to local variables.
-
Minimize Borrow Scope: Keep the scope of borrows as small as possible.
-
Clone When Necessary: If you need to keep references to data while mutating other parts, clone the data you need to keep.
-
Consider Data Design: Structure your data to minimize the need for complex borrowing patterns.
-
Error When Conflicting: If you can't resolve a borrowing conflict cleanly, make the operation an error rather than trying to force it.
Best Practices
- Use TypedData and DataTypeFunctions: They provide a safe framework for memory management
- Always Implement Mark Methods: Mark all Ruby objects your struct references
- Validate Assumptions: Check that resources are valid before using them
- Use Zero-Copy APIs: Leverage APIs like
str_from_slice
to avoid unnecessary copying - Use Guards for Changing Data: Validate assumptions before accessing data that might change
- Test Thoroughly with GC Stress: Run tests with
GC.stress = true
to expose memory issues - Handle RefCell Borrowing Carefully: Complete all immutable borrows before starting mutable ones to avoid runtime panics
By following these practices, you can write Ruby extensions in Rust that are both memory-safe and efficient.
Next Steps
In the next chapter, we'll explore performance optimization techniques that leverage Rust's strengths while maintaining memory safety.
The Build Process
Overview
This chapter explains what happens behind the scenes when rb-sys compiles your Rust extension, helping you debug issues and optimize builds.
This chapter explains what happens behind the scenes when rb-sys compiles your Rust extension. Understanding this process will help you debug issues and optimize your extension.
How rb-sys Compiles Your Code
When you run bundle exec rake compile
, several steps happen in sequence:
- Ruby's
mkmf
system reads yourextconf.rb
file - The
create_rust_makefile
function generates a Makefile - Cargo builds your Rust code as a dynamic library
- The resulting binary is copied to your gem's lib directory
Let's examine each step in detail.
The Role of extconf.rb
The extconf.rb
file is the entry point for Ruby's native extension system. For rb-sys projects, it typically looks
like this:
# extconf.rb
require "mkmf"
require "rb_sys/mkmf"
create_rust_makefile("my_gem/my_gem")
The create_rust_makefile
function:
- Sets up the environment for compiling Rust code
- Generates a Makefile with appropriate Cargo commands
- Configures where the compiled library should be placed
Configuration Options
You can customize the build process by passing a block to create_rust_makefile
:
create_rust_makefile("my_gem/my_gem") do |config|
# Set cargo profile (defaults to ENV["RB_SYS_CARGO_PROFILE"] or :dev)
config.profile = ENV.fetch("MY_GEM_PROFILE", :dev).to_sym
# Enable specific cargo features
config.features = ["feature1", "feature2"]
# Set environment variables for cargo
config.env = { "SOME_VAR" => "value" }
# Specify extra Rust flags
config.extra_rustflags = ["--cfg=feature=\"custom_feature\""]
# Clean up target directory after installation to reduce gem size
config.clean_after_install = true
# Force installation of Rust toolchain if not present
config.force_install_rust_toolchain = "stable"
# Auto-install Rust toolchain during gem installation
config.auto_install_rust_toolchain = true
end
For a complete reference of all available configuration options, see the rb_sys Gem Configuration documentation.
Environment Variables
Several environment variables affect the build process:
Variable | Description | Default |
---|---|---|
RB_SYS_CARGO_PROFILE | Cargo profile to use (dev or release ) | dev |
RB_SYS_CARGO_FEATURES | Comma-separated list of features to enable | None |
RB_SYS_CARGO_ARGS | Additional arguments to pass to cargo | None |
For example:
RB_SYS_CARGO_PROFILE=release bundle exec rake compile
Debugging the Build Process
When things go wrong, you can debug the build process:
1. Enable Verbose Output
bundle exec rake compile VERBOSE=1
2. Inspect Generated Files
Look at the generated Makefile and Cargo configuration:
cat ext/my_gem/Makefile
3. Run Cargo Directly
You can run Cargo commands directly in the extension directory:
cd ext/my_gem
cargo build -v
Optimizing the Build
Development vs. Release Builds
During development, use the default dev profile for faster compilation:
RB_SYS_CARGO_PROFILE=dev bundle exec rake compile
For production releases, use the release profile for optimized performance:
RB_SYS_CARGO_PROFILE=release bundle exec rake compile
Cargo Configuration
In your Cargo.toml, you can customize optimization levels:
[profile.release]
lto = true # Link-time optimization
opt-level = 3 # Maximum optimization
codegen-units = 1 # Optimize for size at the expense of compile time
Build Scripts (build.rs)
For advanced customization, you can use Rust's build script feature:
// ext/my_gem/build.rs fn main() { // Detect features at build time if std::env::var("TARGET").unwrap().contains("windows") { println!("cargo:rustc-cfg=feature=\"windows\""); } // Link to system libraries if needed println!("cargo:rustc-link-lib=dylib=ssl"); // Rerun if specific files change println!("cargo:rerun-if-changed=src/native_code.h"); }
Remember to add this to your Cargo.toml
:
# ext/my_gem/Cargo.toml
[package]
# ...
build = "build.rs"
Cross-Compilation with rb-sys-dock
The real power of rb-sys is its ability to cross-compile extensions using rb-sys-dock
. This tool runs your build in
Docker containers configured for different platforms.
Basic Cross-Compilation
To set up cross-compilation with the RbSys::ExtensionTask
:
# Rakefile
RbSys::ExtensionTask.new("my_gem", GEMSPEC) do |ext|
ext.lib_dir = "lib/my_gem"
ext.cross_compile = true
ext.cross_platform = ['x86_64-linux', 'x86_64-darwin', 'arm64-darwin']
end
Then you can cross-compile with:
bundle exec rake native:my_gem:x86_64-linux
Using rb-sys-dock Directly
You can also use rb-sys-dock directly:
bundle exec rb-sys-dock --platform x86_64-linux --build
Supported Platforms
rb-sys supports many platforms, including:
- x86_64-linux (Linux on Intel/AMD 64-bit)
- x86_64-linux-musl (Static Linux builds)
- aarch64-linux (Linux on ARM64)
- x86_64-darwin (macOS on Intel)
- arm64-darwin (macOS on Apple Silicon)
- x64-mingw-ucrt (Windows 64-bit UCRT)
CI/CD with oxidize-rb/actions
The oxidize-rb/actions repository provides GitHub Actions specifically designed for rb-sys projects:
setup-ruby-and-rust
# .github/workflows/ci.yml
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
ruby: [3.0, 3.1, 3.2, 3.3]
steps:
- uses: actions/checkout@v4
- uses: oxidize-rb/actions/setup-ruby-and-rust@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
cargo-cache: true
- name: Compile
run: bundle exec rake compile
- name: Test
run: bundle exec rake test
cross-gem
# .github/workflows/cross-gem.yml
jobs:
cross_gems:
runs-on: ubuntu-latest
strategy:
matrix:
platform: ["x86_64-linux", "x86_64-darwin", "arm64-darwin"]
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- uses: oxidize-rb/actions/cross-gem@v1
with:
platform: ${{ matrix.platform }}
For a complete CI/CD setup, combine these actions to test your extension on multiple Ruby versions and platforms, then cross-compile for release.
Next Steps
- Explore Cross-Platform Development to learn about cross-compilation.
- Learn Debugging techniques to troubleshoot build failures.
- See Testing Extensions for CI/CD testing strategies.
- Dive into Project Setup for organizing your gem’s structure.
Cross-Platform Development
Overview
One of rb-sys's greatest strengths is its support for cross-platform Ruby extensions. This chapter covers how to develop, test, and distribute extensions across multiple platforms.
Supported Platforms
rb-sys supports cross-compilation to the following platforms:
Platform | Supported | Docker Image |
---|---|---|
x86_64-linux | ✅ | rbsys/x86_64-linux |
x86_64-linux-musl | ✅ | rbsys/x86_64-linux-musl |
aarch64-linux | ✅ | rbsys/aarch64-linux |
aarch64-linux-musl | ✅ | rbsys/aarch64-linux-musl |
arm-linux | ✅ | rbsys/arm-linux |
arm64-darwin | ✅ | rbsys/arm64-darwin |
x64-mingw32 | ✅ | rbsys/x64-mingw32 |
x64-mingw-ucrt | ✅ | rbsys/x64-mingw-ucrt |
mswin | ✅ | not available on Docker |
truffleruby | ✅ | not available on Docker |
The Docker images are available on Docker Hub and are automatically updated with each rb-sys release.
Platform Considerations
Ruby extensions face several cross-platform challenges:
- Different operating systems (Linux, macOS, Windows)
- Different CPU architectures (x86_64, ARM64)
- Different Ruby implementations
- Different compilers and linkers
- System libraries and dependencies
rb-sys provides tools to handle these differences effectively.
Understanding Platform Targets
Ruby identifies platforms with standardized strings:
Platform String | Description |
---|---|
x86_64-linux | 64-bit Linux on Intel/AMD |
aarch64-linux | 64-bit Linux on ARM |
x86_64-darwin | 64-bit macOS on Intel |
arm64-darwin | 64-bit macOS on Apple Silicon |
x64-mingw-ucrt | 64-bit Windows (UCRT) |
x64-mingw32 | 64-bit Windows (older) |
These platform strings are used by:
- RubyGems to select the correct pre-built binary
- rake-compiler for cross-compilation
- rb-sys-dock to build for different platforms
Conditional Compilation
Rust's conditional compilation features allow you to write platform-specific code:
#![allow(unused)] fn main() { // Platform-specific code #[cfg(target_os = "windows")] fn platform_specific() { // Windows-specific implementation } #[cfg(target_os = "macos")] fn platform_specific() { // macOS-specific implementation } #[cfg(target_os = "linux")] fn platform_specific() { // Linux-specific implementation } }
For architectures:
#![allow(unused)] fn main() { #[cfg(target_arch = "x86_64")] fn arch_specific() { // x86_64 implementation } #[cfg(target_arch = "aarch64")] fn arch_specific() { // ARM64 implementation } }
Complete Example: File Path Handling
Here's a real-world example of handling paths differently across platforms:
#![allow(unused)] fn main() { use std::path::PathBuf; fn get_config_path() -> PathBuf { #[cfg(target_os = "windows")] { let mut path = PathBuf::new(); if let Some(profile) = std::env::var_os("USERPROFILE") { path.push(profile); path.push("AppData"); path.push("Roaming"); path.push("MyApp"); path.push("config.toml"); } path } #[cfg(target_os = "macos")] { let mut path = PathBuf::new(); if let Some(home) = std::env::var_os("HOME") { path.push(home); path.push("Library"); path.push("Application Support"); path.push("MyApp"); path.push("config.toml"); } path } #[cfg(target_os = "linux")] { let mut path = PathBuf::new(); if let Some(config_dir) = std::env::var_os("XDG_CONFIG_HOME") { path.push(config_dir); } else if let Some(home) = std::env::var_os("HOME") { path.push(home); path.push(".config"); } path.push("myapp"); path.push("config.toml"); path } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { // Default for other platforms PathBuf::from("config.toml") } } }
Platform-Specific Dependencies
Cargo.toml supports platform-specific dependencies:
[dependencies]
# Common dependencies...
[target.'cfg(target_os = "linux")'.dependencies]
jemallocator = { version = "0.5", features = ["disable_initial_exec_tls"] }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["winbase"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
Example: System-specific Memory Allocation
#![allow(unused)] fn main() { #[cfg(target_os = "linux")] use jemallocator::Jemalloc; #[cfg(target_os = "linux")] #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; // Rest of your code... }
Using build.rs for Platform Detection
The Rust build script (build.rs
) can be used to detect platforms and configure builds:
// ext/my_gem/build.rs fn main() { // Detect OS let target = std::env::var("TARGET").unwrap_or_default(); if target.contains("windows") { println!("cargo:rustc-link-lib=dylib=user32"); println!("cargo:rustc-cfg=feature=\"windows_specific\""); } else if target.contains("darwin") { println!("cargo:rustc-link-lib=framework=CoreFoundation"); println!("cargo:rustc-cfg=feature=\"macos_specific\""); } else if target.contains("linux") { println!("cargo:rustc-link-lib=dylib=dl"); println!("cargo:rustc-cfg=feature=\"linux_specific\""); } // Tell Cargo to invalidate the built crate whenever the build script changes println!("cargo:rerun-if-changed=build.rs"); }
Then in your code:
#![allow(unused)] fn main() { #[cfg(feature = "windows_specific")] fn platform_init() { // Windows initialization code } #[cfg(feature = "macos_specific")] fn platform_init() { // macOS initialization code } #[cfg(feature = "linux_specific")] fn platform_init() { // Linux initialization code } }
Cross-Compilation with rb-sys-dock
rb-sys-dock is a Docker-based tool that simplifies cross-compilation:
Setting Up rb-sys-dock in Your Gem
- Add rb-sys-dock to your Gemfile:
# Gemfile
group :development do
gem "rb-sys-dock", "~> 0.1"
end
- Configure your Rakefile for cross-compilation:
# Rakefile
require "rb_sys/extensiontask"
GEMSPEC = Gem::Specification.load("my_gem.gemspec")
RbSys::ExtensionTask.new("my_gem", GEMSPEC) do |ext|
ext.lib_dir = "lib/my_gem"
ext.cross_compile = true
ext.cross_platform = [
"x86_64-linux",
"aarch64-linux",
"x86_64-darwin",
"arm64-darwin",
"x64-mingw-ucrt"
]
end
Building for a Specific Platform
To build for a specific platform:
bundle exec rake native:my_gem:x86_64-linux
This creates a platform-specific gem in the pkg
directory.
Building for All Platforms
To build for all configured platforms:
bundle exec rake native
Using rb-sys-dock Directly
For more control, use rb-sys-dock directly:
# Build for a specific platform
bundle exec rb-sys-dock --platform x86_64-linux --build
# Start a shell in the Docker container
bundle exec rb-sys-dock --platform x86_64-linux --shell
Testing Cross-Platform Builds
Local Testing with Docker
You can test your cross-compiled Linux extensions locally:
# Run tests inside a Docker container
bundle exec rb-sys-dock --platform x86_64-linux --command "bundle exec rake test"
Local Testing on macOS
If you're on macOS with Apple Silicon, you can test both architectures:
# Test arm64-darwin build (native)
bundle exec rake test
# Test x86_64-darwin build (cross-compiled)
arch -x86_64 bundle exec rake test
CI/CD for Multiple Platforms
GitHub Actions is ideal for testing across platforms:
Testing on Multiple Platforms
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
ruby: ["3.0", "3.1", "3.2", "3.3"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oxidize-rb/actions/setup-ruby-and-rust@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake compile
- run: bundle exec rake test
Ruby-Head Compatibility
When supporting ruby-head
or development versions of Ruby, you must publish a source gem alongside your precompiled gems. This is necessary because:
- The Ruby ABI (Application Binary Interface) can change between development versions
- Precompiled binary gems built against one ruby-head version may be incompatible with newer ruby-head versions
- Source gems allow users to compile the extension against their specific ruby-head version
To ensure compatibility, add a source gem to your release process:
# Rakefile
RbSys::ExtensionTask.new("my_gem", GEMSPEC) do |ext|
# Configure cross-platform gems as usual
ext.cross_compile = true
ext.cross_platform = ['x86_64-linux', 'arm64-darwin', ...]
# The default platform will build the source gem
end
Then in your CI/CD pipeline, include both platform-specific and source gem builds:
# .github/workflows/release.yml
jobs:
# First build all platform-specific gems
cross_compile:
# ...
# Then build the source gem
source_gem:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- run: bundle install
- run: bundle exec rake build # Builds the source gem
- uses: actions/upload-artifact@v3
with:
name: source-gem
path: pkg/*.gem # Include source gem without platform suffix
Cross-Compiling for Release
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
jobs:
cross_compile:
strategy:
fail-fast: false
matrix:
platform: ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "arm64-darwin", "x64-mingw-ucrt"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1"
- uses: oxidize-rb/actions/cross-gem@v1
with:
platform: ${{ matrix.platform }}
- uses: actions/upload-artifact@v3
with:
name: gem-${{ matrix.platform }}
path: pkg/*-${{ matrix.platform }}.gem
Complete CI Workflow Example
Here's a more complete workflow showing an automated release process with tests and cross-compilation:
# .github/workflows/gem-release.yml
name: Gem Release
on:
push:
tags:
- "v*"
jobs:
fetch-data:
runs-on: ubuntu-latest
outputs:
platforms: ${{ steps.fetch.outputs.supported-ruby-platforms }}
steps:
- id: fetch
uses: oxidize-rb/actions/fetch-ci-data@v1
with:
supported-ruby-platforms: |
exclude: [x86-linux, x86-darwin, arm-linux]
test:
needs: fetch-data
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
ruby: ["3.0", "3.1", "3.2", "3.3"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oxidize-rb/actions/setup-ruby-and-rust@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rake compile
- run: bundle exec rake test
cross-compile:
needs: [fetch-data, test]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: ${{ fromJSON(needs.fetch-data.outputs.platforms) }}
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1"
- uses: oxidize-rb/actions/cross-gem@v1
with:
platform: ${{ matrix.platform }}
- uses: actions/upload-artifact@v3
with:
name: gem-${{ matrix.platform }}
path: pkg/*-${{ matrix.platform }}.gem
release:
needs: cross-compile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1"
- uses: actions/download-artifact@v3
with:
path: artifacts
- name: Move gems to pkg directory
run: |
mkdir -p pkg
find artifacts -name "*.gem" -exec mv {} pkg/ \;
- name: Publish to RubyGems
run: |
mkdir -p ~/.gem
echo -e "---\n:rubygems_api_key: ${RUBYGEMS_API_KEY}" > ~/.gem/credentials
chmod 0600 ~/.gem/credentials
gem push pkg/*.gem
env:
RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
Platform-Specific Issues and Solutions
Windows
Windows presents unique challenges for Ruby extensions:
-
Path Handling: Use forward slashes (
/
) in paths, not backslashes (\
)#![allow(unused)] fn main() { // Instead of this: let path = "C:\\Users\\Name\\file.txt"; // Do this: let path = "C:/Users/Name/file.txt"; }
-
DLL Loading: Handle DLL loading carefully
#![allow(unused)] fn main() { #[cfg(target_os = "windows")] fn load_library(name: &str) -> Result<(), Error> { use std::os::windows::ffi::OsStrExt; use std::ffi::OsStr; use winapi::um::libloaderapi::LoadLibraryW; let name_wide: Vec<u16> = OsStr::new(name) .encode_wide() .chain(std::iter::once(0)) .collect(); let handle = unsafe { LoadLibraryW(name_wide.as_ptr()) }; if handle.is_null() { return Err(Error::new("Failed to load library")); } Ok(()) } }
-
Asynchronous I/O: Windows has different async I/O APIs
#![allow(unused)] fn main() { #[cfg(target_os = "windows")] use windows_specific_io::read_file; #[cfg(not(target_os = "windows"))] use posix_specific_io::read_file; }
macOS
-
Architectures: Support both Intel and Apple Silicon
# Rakefile RbSys::ExtensionTask.new("my_gem", GEMSPEC) do |ext| ext.cross_platform = ["x86_64-darwin", "arm64-darwin"] end
-
Framework Linking: Link against macOS frameworks
#![allow(unused)] fn main() { // build.rs #[cfg(target_os = "macos")] { println!("cargo:rustc-link-lib=framework=Security"); println!("cargo:rustc-link-lib=framework=CoreFoundation"); } }
-
Universal Binary: Consider building universal binaries
# extconf.rb if RUBY_PLATFORM =~ /darwin/ ENV['RUSTFLAGS'] = "-C link-arg=-arch -C link-arg=arm64 -C link-arg=-arch -C link-arg=x86_64" end
Linux
-
glibc vs musl: Consider both glibc and musl for maximum compatibility
# Rakefile RbSys::ExtensionTask.new("my_gem", GEMSPEC) do |ext| ext.cross_platform = ["x86_64-linux", "x86_64-linux-musl"] end
-
Static Linking: Increase portability with static linking
# Cargo.toml [target.'cfg(target_os = "linux")'.dependencies] openssl-sys = { version = "0.9", features = ["vendored"] }
-
Multiple Distributions: Test on different distributions in CI
# .github/workflows/linux-test.yml jobs: test: strategy: matrix: container: ["ubuntu:20.04", "debian:bullseye", "alpine:3.15"] container: ${{ matrix.container }}
Best Practices
- Start with cross-compilation early - Don't wait until release time
- Test on all target platforms - Ideally in CI
- Use platform-specific code sparingly - Abstract platform differences when possible
- Prefer conditional compilation over runtime checks - Better performance and safer code
- Document platform requirements - Make dependencies clear to users
- Use feature flags for optional platform support - Allow users to opt-in to platform-specific features
Example: Good Platform Abstraction
#![allow(unused)] fn main() { // Platform abstraction module mod platform { pub struct FileHandle(PlatformSpecificHandle); impl FileHandle { pub fn open(path: &str) -> Result<Self, Error> { #[cfg(target_os = "windows")] { // Windows-specific implementation // ... } #[cfg(unix)] { // Unix-based implementation (Linux, macOS, etc.) // ... } #[cfg(not(any(target_os = "windows", unix)))] { return Err(Error::new("Unsupported platform")); } } pub fn read(&self, buf: &mut [u8]) -> Result<usize, Error> { // Platform-specific reading implementation // ... } pub fn write(&self, buf: &[u8]) -> Result<usize, Error> { // Platform-specific writing implementation // ... } } } // User code just uses the abstraction use platform::FileHandle; fn process_file(path: &str) -> Result<(), Error> { let file = FileHandle::open(path)?; // Common code without platform-specific details Ok(()) } }
Complete Example: Cross-Platform Release Workflow
Here's a complete example for releasing a cross-platform gem:
- Develop locally on your preferred platform
- Test your changes locally with
bundle exec rake test
- Verify cross-platform builds with
bundle exec rb-sys-dock --platform x86_64-linux --command "bundle exec rake test"
- Commit and push your changes
- CI tests run on all supported platforms
- Create a release tag when ready (
git tag v1.0.0 && git push --tags
) - Cross-compilation workflow builds platform-specific gems
- Publish gems to RubyGems or your private repository
By following this workflow, you can be confident your extension works consistently across platforms.
Real-World Examples
Many real-world gems use rb-sys for cross-platform development:
- blake3-ruby - Fast cryptographic hash function implementation with full cross-platform support
- lz4-ruby - LZ4 compression library with rb-sys
- wasmtime-rb - WebAssembly runtime
These projects demonstrate successful cross-platform strategies and can serve as references for your own extensions.
Example from wasmtime-rb
wasmtime-rb wraps platform-specific functionality while presenting a consistent API:
#![allow(unused)] fn main() { #[cfg(unix)] mod unix { pub unsafe fn map_memory(addr: *mut u8, len: usize) -> Result<(), Error> { // Unix-specific memory mapping } } #[cfg(windows)] mod windows { pub unsafe fn map_memory(addr: *mut u8, len: usize) -> Result<(), Error> { // Windows-specific memory mapping } } // Public API uses the platform-specific implementation pub unsafe fn map_memory(addr: *mut u8, len: usize) -> Result<(), Error> { #[cfg(unix)] { return unix::map_memory(addr, len); } #[cfg(windows)] { return windows::map_memory(addr, len); } #[cfg(not(any(unix, windows)))] { return Err(Error::new("Unsupported platform")); } } }
Summary
Cross-platform development with rb-sys leverages Rust's excellent platform-specific features:
- Conditional compilation provides platform-specific code paths
- Platform-specific dependencies allow different libraries per platform
- rb-sys-dock enables easy cross-compilation for multiple platforms
- GitHub Actions integration automates testing and releases
By following the patterns in this chapter, your Ruby extensions can work seamlessly across all major platforms while minimizing platform-specific code and maintenance burden.
Next Steps
- Visit Build Process to see local compilation details.
- Check out Testing Extensions for CI workflows across platforms.
- Use Debugging strategies when cross-compiling fails.
- Review Project Setup to organize multi-platform gems.
Testing Extensions
Testing is a critical part of developing Ruby extensions. This chapter covers strategies for testing your Rust code that interfaces with Ruby, from unit tests to integration tests and CI workflows.
Testing is particularly important for Ruby extensions because segmentation faults, memory leaks, and other low-level issues can crash the entire Ruby VM. Untested extensions can lead to hard-to-debug production crashes.
rb-sys-test-helpers Overview
The rb-sys-test-helpers
crate provides specialized utilities for testing Ruby extensions in Rust. It solves many of
the challenges associated with testing code that interacts with the Ruby VM:
- Automating Ruby VM initialization and teardown
- Managing thread safety for Ruby VM operations
- Handling Ruby exceptions in tests
- Providing GC stress testing to catch memory issues
- Offering conversion helpers for common Ruby types
For detailed API documentation, see the Test Helpers API Reference.
Unit Testing Rust Code
The Challenge of Testing Ruby Extensions
Testing Rust code that interacts with Ruby presents unique challenges:
- Ruby VM Initialization: The Ruby VM must be properly initialized before tests run.
- Thread Safety: Ruby's VM has thread-specific state that must be managed.
- Exception Handling: Ruby exceptions need to be properly caught and converted to Rust errors.
- Memory Management: Memory allocated by Ruby needs to be protected from garbage collection during tests.
rb-sys provides specialized tools to overcome these challenges, particularly the #[ruby_test]
macro which handles Ruby
VM initialization and thread management automatically.
Complete Test Setup Guide
Setting up proper testing for Ruby extensions requires several components working together. This guide provides a comprehensive setup that you can adapt to your project.
Required Dependencies
Your Cargo.toml
needs to be configured with the appropriate dependencies:
[package]
name = "my_extension"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
# Main dependencies
[dependencies]
magnus = "0.6" # For high-level Ruby API
rb-sys = "0.9" # Required for rb_sys_test_helpers to work
# Test dependencies
[dev-dependencies]
rb-sys-env = "0.1" # For Ruby environment detection
rb-sys-test-helpers = "0.2" # For Ruby VM test helpers
The key points:
- Include
rb-sys
as a regular dependency (not just a dev-dependency) - Both
rb-sys-env
andrb-sys-test-helpers
are needed for tests
Setting Up build.rs
Create a build.rs
file in your project root with the following content:
use std::error::Error; fn main() -> Result<(), Box<dyn Error>> { // This activates rb-sys-env for both normal builds and tests let _ = rb_sys_env::activate()?; // Any additional build configuration can go here Ok(()) }
The rb_sys_env::activate()
function:
- Sets up Cargo configuration based on the detected Ruby environment
- Exposes Ruby version information as feature flags (e.g.,
ruby_gte_3_0
,ruby_use_flonum
) - Ensures proper linking to the Ruby library
Importing Test Helpers
In your test module, import the necessary components:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use rb_sys_test_helpers::ruby_test; use magnus::{Ruby, Error}; // Your test functions go here... } }
The #[ruby_test] Macro
The #[ruby_test]
macro is the simplest and most reliable way to test Ruby extensions in Rust. It handles all the
complexities of VM initialization and thread management.
The simplest way to test Ruby extensions is with the #[ruby_test]
macro, which wraps your test functions to ensure
they run within a properly initialized Ruby VM:
The #[ruby_test]
macro:
- Ensures the Ruby VM is initialized once and only once
- Runs all tests on the same OS thread
- Catches and propagates Ruby exceptions as Rust errors
- Performs GC after each test to catch memory management issues
Click the eye icon () to view additional examples of the macro options and version-specific testing.
The #[ruby_test]
macro:
- Ensures the Ruby VM is initialized once and only once
- Runs all tests on the same OS thread
- Catches and propagates Ruby exceptions as Rust errors
- Performs GC after each test to catch memory management issues
Using Magnus with #[ruby_test]
Magnus provides a much more ergonomic Rust API for working with Ruby. Combined with the #[ruby_test]
macro, it makes
testing Ruby extensions much simpler and safer.
One of the great advantages of the #[ruby_test]
macro is that it works seamlessly with Magnus, providing a much more
ergonomic way to test Ruby integrations:
Magnus makes it much easier to interact with Ruby objects in a safe and idiomatic way. Using Magnus with the
#[ruby_test]
macro gives you the best of both worlds:
- Magnus's safe, high-level API
- The
#[ruby_test]
macro's robust Ruby VM management
Click the eye icon () to see examples of more complex Ruby class interactions.
Magnus makes it much easier to interact with Ruby objects in a safe and idiomatic way. Using Magnus with the
#[ruby_test]
macro gives you the best of both worlds:
- Magnus's safe, high-level API
- The
#[ruby_test]
macro's robust Ruby VM management
Here's another example showing how to work with Ruby classes and methods using Magnus:
#![allow(unused)] fn main() { use magnus::{class, eval, method, prelude::*, Module, RClass, Ruby}; use rb_sys_test_helpers::ruby_test; #[ruby_test] fn test_ruby_class_interaction() { let ruby = Ruby::get().unwrap(); // Define a Ruby class for testing let test_class = ruby.define_class("TestClass", ruby.class_object()).unwrap(); // Define a method on the class test_class.define_method("double", method!(|ruby, num: i64| -> i64 { num * 2 })).unwrap(); // Create an instance and call the method let result: i64 = eval!(ruby, "TestClass.new.double(21)").unwrap(); assert_eq!(result, 42); } }
Testing with GC Stress
To detect subtle memory management issues, you can enable GC stress testing:
#![allow(unused)] fn main() { #[ruby_test(gc_stress)] fn test_gc_interactions() { unsafe { // Create a Ruby string let s = rb_str_new_cstr("hello world\0".as_ptr() as _); // Get a pointer to the string's contents let s_ptr = RSTRING_PTR(s); // Protect s from garbage collection rb_gc_guard!(s); // Now we can safely use s_ptr, even though GC might run let t = rb_str_new_cstr("prefix: \0".as_ptr() as _); let result = rb_str_cat_cstr(t, s_ptr); // More code... } } }
With Magnus, the same test is more straightforward:
#![allow(unused)] fn main() { use magnus::{RString, Ruby}; use rb_sys_test_helpers::ruby_test; #[ruby_test(gc_stress)] fn test_gc_interactions_with_magnus() { let ruby = Ruby::get().unwrap(); // Create first string let s = RString::new(ruby, "hello world"); // Magnus handles GC protection automatically! // Create second string and concatenate let t = RString::new(ruby, "prefix: "); let result = t.concat(ruby, &s); assert_eq!(result.to_string().unwrap(), "prefix: hello world"); } }
The gc_stress
option forces Ruby's garbage collector to run frequently during the test, which helps expose bugs
related to:
- Objects not being properly protected from GC
- Dangling pointers
- Invalid memory access
Handling Ruby Exceptions
Ruby exceptions can be caught and converted to Rust errors using the protect
function:
#![allow(unused)] fn main() { #[ruby_test] fn test_exception_handling() { use rb_sys_test_helpers::protect; // This code will raise a Ruby exception let result = unsafe { protect(|| { rb_sys::rb_raise(rb_sys::rb_eRuntimeError, "Test error\0".as_ptr() as _); // This will never be reached "success" }) }; // Verify we got an error assert!(result.is_err()); // Check the error message let error = result.unwrap_err(); assert!(error.message().unwrap().contains("Test error")); } }
With Magnus, exception handling is more natural:
#![allow(unused)] fn main() { use magnus::{eval, Ruby, Error}; use rb_sys_test_helpers::ruby_test; #[ruby_test] fn test_exception_handling_with_magnus() { let ruby = Ruby::get().unwrap(); // Evaluate Ruby code that raises an exception let result: Result<String, Error> = eval!(ruby, "raise 'Test error'"); // Verify we got an error assert!(result.is_err()); // Magnus errors contain the Ruby exception let error = result.unwrap_err(); assert!(error.to_string().contains("Test error")); } }
Version-Specific Tests
rb-sys-env provides feature flags that allow you to write version-specific tests:
#![allow(unused)] fn main() { #[ruby_test] fn test_version_specific_features() { // This test will only run on Ruby 3.0 or higher #[cfg(ruby_gte_3_0)] { // Test Ruby 3.0+ specific features unsafe { // Example: using Ractor API which is only available in Ruby 3.0+ #[cfg(ruby_have_ruby_ractor_h)] let is_ractor_supported = rb_sys::rb_ractor_main_p() != 0; // ... } } // This block will only run on Ruby 2.7 #[cfg(all(ruby_gte_2_7, ruby_lt_3_0))] { // Test Ruby 2.7 specific features // ... } } }
With Magnus:
#![allow(unused)] fn main() { use magnus::{Ruby, eval}; use rb_sys_test_helpers::ruby_test; #[ruby_test] fn test_version_specific_features_with_magnus() { let ruby = Ruby::get().unwrap(); // This test will only run on Ruby 3.0 or higher #[cfg(ruby_gte_3_0)] { // Test Ruby 3.0+ specific features #[cfg(ruby_have_ruby_ractor_h)] let is_ractor_supported: bool = eval!(ruby, "defined?(Ractor) != nil").unwrap(); #[cfg(ruby_have_ruby_ractor_h)] assert!(is_ractor_supported); } } }
Available version flags include:
ruby_gte_X_Y
: Ruby version >= X.Yruby_lt_X_Y
: Ruby version < X.Yruby_eq_X_Y
: Ruby version == X.Yruby_have_FEATURE
: Specific Ruby API feature is available
Test Helpers and Macros
rb-sys-test-helpers includes several macros to simplify common testing patterns:
#![allow(unused)] fn main() { // Convert a Ruby string to a Rust String for testing #[ruby_test] fn test_with_helper_macros() { use rb_sys_test_helpers::rstring_to_string; unsafe { let rb_str = rb_utf8_str_new_cstr("hello world\0".as_ptr() as _); let rust_str = rstring_to_string!(rb_str); assert_eq!(rust_str, "hello world"); } } }
Manual Ruby VM Setup
For more complex test scenarios, you can manually initialize the Ruby VM:
#![allow(unused)] fn main() { use rb_sys_test_helpers::{with_ruby_vm, protect}; #[test] fn test_complex_scenario() { with_ruby_vm(|| { // Multiple operations that need a Ruby VM let result1 = unsafe { protect(|| { // First operation... 42 }) }; let result2 = unsafe { protect(|| { // Second operation... "success" }) }; assert_eq!(result1.unwrap(), 42); assert_eq!(result2.unwrap(), "success"); }).unwrap(); } }
With Magnus, the same approach but more ergonomically:
#![allow(unused)] fn main() { use magnus::{eval, Ruby}; use rb_sys_test_helpers::with_ruby_vm; #[test] fn test_complex_scenario_with_magnus() { with_ruby_vm(|| { let ruby = Ruby::get().unwrap(); // First operation let result1: i64 = eval!(ruby, "21 * 2").unwrap(); // Second operation let result2: String = eval!(ruby, "'suc' + 'cess'").unwrap(); assert_eq!(result1, 42); assert_eq!(result2, "success"); }).unwrap(); } }
Debugging Failed Tests
When your tests fail, debugging tools can help identify the root cause. LLDB is particularly useful for debugging memory issues, segmentation faults, and other low-level problems.
Using LLDB to Debug Tests
LLDB is a powerful debugger that works well with Rust and Ruby code. Here's how to use it with your tests:
-
First, compile your extension with debug symbols:
RUSTFLAGS="-g" bundle exec rake compile
-
Run your test with LLDB:
lldb -- ruby -Ilib -e "require 'my_extension'; require_relative 'test/test_my_extension.rb'"
-
At the LLDB prompt, set breakpoints in your Rust code:
(lldb) breakpoint set --name MutCalculator::divide
-
Run the program:
(lldb) run
-
When the breakpoint is hit, you can:
- Examine variables:
frame variable
- Print expressions:
p self
orp val
- Step through code:
next
(over) orstep
(into) - Continue execution:
continue
- Show backtrace:
bt
- Examine variables:
LLDB Commands for Ruby Extensions
Some LLDB commands that are particularly useful for Ruby extensions:
# To print a Ruby string VALUE
(lldb) p rb_string_value_cstr(&my_rb_string_val)
# To check if a VALUE is nil
(lldb) p RB_NIL_P(my_value)
# To get the Ruby class name of an object
(lldb) p rb_class2name(rb_class_of(my_value))
# To check Ruby exception information
(lldb) p rb_errinfo()
Debugging Memory Issues
For memory-related issues:
- Set a breakpoint around where objects are created
- Set a breakpoint where the crash occurs
- When hitting the first breakpoint, note memory addresses
- When hitting the second breakpoint, check if those addresses are still valid
# Example debugging session for memory issues
$ lldb -- ruby -Ilib -e "require 'my_extension'; MyExtension.test_method"
(lldb) breakpoint set --name MutPoint::new
(lldb) breakpoint set --name MutPoint::add_x
(lldb) run
# When first breakpoint hits
(lldb) frame variable
(lldb) p self
(lldb) continue
# When second breakpoint hits
(lldb) frame variable
(lldb) p self
Debugging RefCell Borrow Errors
For diagnosing BorrowMutError
panics:
-
Set a breakpoint right before the borrow operation:
(lldb) breakpoint set --file lib.rs --line 123
-
When it hits, check the status of the RefCell:
(lldb) p self.0
-
Step through the code and watch when borrows occur:
(lldb) next
Further Information
For more comprehensive debugging setup including VSCode integration and debugging the Ruby C API, see the Debugging & Troubleshooting chapter.
Common Testing Patterns and Anti-Patterns
When testing Ruby extensions, several patterns emerge that can help you write more effective tests, along with anti-patterns to avoid.
Pattern: Proper Method Invocation
#![allow(unused)] fn main() { // ✅ GOOD: Using associated function syntax for methods with Ruby/self parameters let result = MutCalculator::divide(&ruby, &calc, 6.0, 2.0); // ❌ BAD: This won't compile - can't call as instance method // let result = calc.divide(&ruby, 6.0, 2.0); }
Pattern: Complete RefCell Borrows
#![allow(unused)] fn main() { // ✅ GOOD: Complete the borrow before attempting to borrow mutably let current_x = self.0.borrow().x; // First borrow completes here if let Some(sum) = current_x.checked_add(val) { self.0.borrow_mut().x = sum; // Safe to borrow mutably now } // ❌ BAD: Will panic with "already borrowed: BorrowMutError" // if let Some(sum) = self.0.borrow().x.checked_add(val) { // self.0.borrow_mut().x = sum; // Error: still borrowed from the if condition // } }
Pattern: Ruby Error Checking
Testing error handling is crucial for Ruby extensions. Here's how to properly test different exception scenarios:
#![allow(unused)] fn main() { // ✅ GOOD: Verify specific Ruby exception types let result = MutCalculator::divide(&ruby, &calc, 6.0, 0.0); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_zero_div_error())); assert!(err.message().unwrap().contains("Division by zero")); // ❌ BAD: Just checking for any error without specific type // assert!(result.is_err()); }
Testing Different Ruby Exception Types
#![allow(unused)] fn main() { // Testing for ArgumentError fn test_argument_error() -> Result<(), Error> { let ruby = Ruby::get()?; let calc = Calculator::new(); // Function that raises ArgumentError on negative input let result = Calculator::sqrt(&ruby, &calc, -1.0); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_arg_error())); assert!(err.message().unwrap().contains("must be positive")); Ok(()) } // Testing for RangeError fn test_range_error() -> Result<(), Error> { let ruby = Ruby::get()?; let calc = Calculator::new(); // Function that raises RangeError on large values let result = Calculator::factorial(&ruby, &calc, 100); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_range_error())); Ok(()) } // Testing for TypeError fn test_type_error() -> Result<(), Error> { let ruby = Ruby::get()?; // Use eval to create a type error situation let result: Result<i64, Error> = ruby.eval("'string' + 5"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_type_error())); Ok(()) } }
Testing Ruby Exceptions Using eval
You can also test how Ruby exceptions are raised and handled using eval
:
#![allow(unused)] fn main() { #[ruby_test] fn test_ruby_exceptions_with_eval() -> Result<(), Error> { let ruby = Ruby::get()?; // Set up our extension let module = ruby.define_module("MyModule")?; let calc_class = module.define_class("Calculator", ruby.class_object())?; calc_class.define_singleton_method("new", function!(Calculator::new, 0))?; calc_class.define_method("divide", method!(Calculator::divide, 2))?; // Test division by zero from Ruby code let result: Result<f64, Error> = ruby.eval("MyModule::Calculator.new.divide(10, 0)"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_zero_div_error())); Ok(()) } }
Verifying Custom Exception Types
For custom exception classes:
#![allow(unused)] fn main() { #[ruby_test] fn test_custom_exception() -> Result<(), Error> { let ruby = Ruby::get()?; // Create a custom exception class let module = ruby.define_module("MyModule")?; let custom_error = module.define_class("CustomError", ruby.exception_standard_error())?; // Define a method that raises our custom error let obj = ruby.eval::<Value>("Object.new")?; obj.define_singleton_method(ruby, "raise_custom", function!(|ruby: &Ruby| -> Result<(), Error> { Err(Error::new( ruby.class_path_to_value("MyModule::CustomError"), "Custom error message" )) }, 0) )?; // Call the method and verify the exception let result: Result<(), Error> = ruby.eval("Object.new.raise_custom"); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, custom_error)); assert!(err.message().unwrap().contains("Custom error")); Ok(()) } }
Pattern: Proper Memory Management
#![allow(unused)] fn main() { // ✅ GOOD: Test with GC stress to catch memory issues #[ruby_test(gc_stress)] fn test_memory_management() { // Test code here will run with GC stress enabled } // ✅ GOOD: Ensure objects used in raw C API are protected unsafe { let rb_str = rb_utf8_str_new_cstr("hello\0".as_ptr() as _); let rb_str = rb_gc_guard!(rb_str); // Protected from GC } // ❌ BAD: Using raw pointers without protection // unsafe { // let rb_str = rb_utf8_str_new_cstr("hello\0".as_ptr() as _); // // rb_str could be collected here if GC runs // } }
Pattern: Version-Specific Testing
#![allow(unused)] fn main() { // ✅ GOOD: Conditional tests based on Ruby version #[ruby_test] fn test_features() { #[cfg(ruby_gte_3_0)] { // Test Ruby 3.0+ specific features } #[cfg(not(ruby_gte_3_0))] { // Test for older Ruby versions } } // ❌ BAD: Runtime checks for version // if ruby_version() >= (3, 0, 0) { // // Test Ruby 3.0+ specific features // } }
Testing Best Practices
Failing to follow these practices can result in segmentation faults, memory leaks, and other serious issues that may only appear in production environments with specific data or Ruby versions.
- Use
#[ruby_test]
for most tests: This macro handles Ruby VM setup automatically. - Consider Magnus for cleaner tests: Magnus offers a much more ergonomic API than raw rb-sys.
- Enable
gc_stress
for memory management tests: This helps catch GC-related bugs early. - Always protect raw Ruby pointers: Use
rb_gc_guard!
when you need to use raw pointers. - Catch exceptions properly: Don't let Ruby exceptions crash your tests.
- Use conditional compilation for version-specific tests: Leverage the version flags from rb-sys-env.
- Test edge cases: Nil values, empty strings, large numbers, etc.
- Use helper macros: Convert between Ruby and Rust types using provided helpers.
Code Example: Testing With Best Practices
This example illustrates proper handling of RefCell borrowing, Ruby exceptions, GC stress testing, and version-specific tests.
Example: Complete Test Module
Here's a complete end-to-end example based on the rusty_calculator extension. This includes the project structure, required files, and comprehensive test module:
Project Setup
First, ensure your project has the correct file structure:
my_extension/
├── Cargo.toml
├── build.rs
├── src/
│ └── lib.rs
Cargo.toml
[package]
name = "my_extension"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
magnus = "0.6"
rb-sys = "0.9"
[dev-dependencies]
rb-sys-env = "0.1"
rb-sys-test-helpers = "0.2"
build.rs
use std::error::Error; fn main() -> Result<(), Box<dyn Error>> { // Activate rb-sys-env to set up Ruby environment for both builds and tests let _ = rb_sys_env::activate()?; Ok(()) }
lib.rs
This example includes a calculator class with a method that can potentially raise a Ruby exception:
#![allow(unused)] fn main() { use std::cell::RefCell; use magnus::{function, method, prelude::*, wrap, Error, Ruby}; // Calculator struct with memory struct Calculator { memory: f64, } #[wrap(class = "MyExtension::Calculator")] struct MutCalculator(RefCell<Calculator>); impl MutCalculator { // Constructor fn new() -> Self { Self(RefCell::new(Calculator { memory: 0.0 })) } // Basic arithmetic that returns a Result which can generate Ruby exceptions fn divide(ruby: &Ruby, _rb_self: &Self, a: f64, b: f64) -> Result<f64, Error> { if b == 0.0 { return Err(Error::new( ruby.exception_zero_div_error(), "Division by zero" )); } Ok(a / b) } // Regular instance method fn add(&self, a: f64, b: f64) -> f64 { a + b } // Memory operations using RefCell fn store(&self, value: f64) -> f64 { self.0.borrow_mut().memory = value; value } fn recall(&self) -> f64 { self.0.borrow().memory } } // Module initialization #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyExtension")?; // Set up the Calculator class let calc_class = module.define_class("Calculator", ruby.class_object())?; calc_class.define_singleton_method("new", function!(MutCalculator::new, 0))?; calc_class.define_method("divide", method!(MutCalculator::divide, 2))?; calc_class.define_method("add", method!(MutCalculator::add, 2))?; calc_class.define_method("store", method!(MutCalculator::store, 1))?; calc_class.define_method("recall", method!(MutCalculator::recall, 0))?; Ok(()) } // Complete test module #[cfg(test)] mod tests { use super::*; use rb_sys_test_helpers::ruby_test; // Basic functionality test #[ruby_test] fn test_calculator_basic_operations() { let calc = MutCalculator::new(); // Test regular instance method assert_eq!(calc.add(2.0, 3.0), 5.0); // Test memory operations assert_eq!(calc.store(42.0), 42.0); assert_eq!(calc.recall(), 42.0); } // Test method that raises Ruby exceptions #[ruby_test] fn test_calculator_divide() { let ruby = Ruby::get().unwrap(); let calc = MutCalculator::new(); // Test normal division - note the function syntax for methods // that take ruby and rb_self parameters let result = MutCalculator::divide(&ruby, &calc, 10.0, 2.0); assert!(result.is_ok()); assert_eq!(result.unwrap(), 5.0); // Test division by zero let result = MutCalculator::divide(&ruby, &calc, 10.0, 0.0); assert!(result.is_err()); // Verify specific exception type let err = result.unwrap_err(); assert!(err.is_kind_of(ruby, ruby.exception_zero_div_error())); assert!(err.message().unwrap().contains("Division by zero")); } // Test with GC stress for memory issues #[ruby_test(gc_stress)] fn test_calculator_with_gc_stress() { let calc = MutCalculator::new(); // Store and recall with GC stress active for i in 0..100 { calc.store(i as f64); assert_eq!(calc.recall(), i as f64); } // No segfaults or panics means test passed } // Test for Ruby integration using eval #[ruby_test] fn test_ruby_integration() { let ruby = Ruby::get().unwrap(); // Define the calculator class - this simulates what init() does let module = ruby.define_module("MyExtension").unwrap(); let calc_class = module.define_class("Calculator", ruby.class_object()).unwrap(); calc_class.define_singleton_method("new", function!(MutCalculator::new, 0)).unwrap(); calc_class.define_method("add", method!(MutCalculator::add, 2)).unwrap(); // Call methods via Ruby's eval let result: f64 = ruby.eval("MyExtension::Calculator.new.add(2, 3)").unwrap(); assert_eq!(result, 5.0); } } }
This complete example demonstrates:
- Proper project setup with required dependencies
- A realistic implementation with potential error conditions
- Testing various method types (regular instance methods and methods with Ruby state)
- Testing Ruby exceptions with proper type checking
- Memory safety testing with GC stress
- Ruby integration testing via eval
You can adapt this template to your own extension, adding the specific functionality your project requires.
## Integration Testing Ruby API
<div class="tip">
Integration tests verify that your extension works correctly when called from Ruby code. Testing both in Rust and Ruby provides the most complete coverage.
</div>
Integration tests verify that your Ruby extension's API works correctly when called from Ruby code. These tests are typically written in Ruby and run using Ruby's test frameworks.
### Setting Up Ruby Tests
Most Ruby gems use Minitest or RSpec for testing. Here's how to set up integration tests with Minitest (which bundler creates by default):
```ruby,hidelines=#
# test/test_my_extension.rb
require "test_helper"
class TestMyExtension < Minitest::Test
def setup
# Set up test fixtures
@calculator = MyExtension::Calculator.new
end
def test_basic_addition
assert_equal 5, @calculator.add(2, 3)
end
def test_division_by_zero
error = assert_raises(ZeroDivisionError) do
@calculator.divide(10, 0)
end
assert_match /division by zero/i, error.message
end
def test_nil_handling
# Test that nil values are properly handled
assert_nil @calculator.process(nil)
end
# # Test memory management
# def test_gc_safety
# # Create many objects and force garbage collection
# 1000.times do |i|
# obj = MyExtension::Calculator.new
# obj.add(i, i)
#
# # Force garbage collection periodically
# GC.start if i % 100 == 0
# end
#
# # If we reach here without segfaults, the test passes
# assert true
# end
#
# # Test edge cases
# def test_edge_cases
# # Test with extreme values
# max = (2**60)
# assert_equal max * 2, @calculator.multiply(max, 2)
#
# # Test with different types
# assert_raises(TypeError) do
# @calculator.add("string", 1)
# end
# end
end
Click the eye icon () to see additional tests for memory management and edge cases.
Testing Error Handling
It's particularly important to test how your extension handles error conditions:
def test_error_propagation
# Test that Rust errors properly convert to Ruby exceptions
error = assert_raises(RangeError) do
@calculator.factorial(100) # Too large, should raise RangeError
end
assert_match /too large/i, error.message
end
def test_invalid_arguments
# Test type validation
error = assert_raises(TypeError) do
@calculator.add("string", 3) # Should raise TypeError
end
assert_match /expected numeric/i, error.message
end
Testing Memory Management
Test memory management by creating objects and forcing garbage collection:
def test_gc_safety
# Create many objects and force garbage collection
1000.times do |i|
obj = MyExtension::Point.new(i, i)
# Force garbage collection periodically
GC.start if i % 100 == 0
end
# If we reach here without segfaults or leaks, the test passes
assert true
end
def test_object_references
# Test that nested objects maintain correct references
parent = MyExtension::Node.new("parent")
child = MyExtension::Node.new("child")
# Create relationship
parent.add_child(child)
# Force garbage collection
GC.start
# Both objects should still be valid
assert_equal "parent", parent.name
assert_equal "child", parent.children.first.name
end
Common Testing Patterns
When testing Ruby extensions written in Rust, several patterns emerge that can help ensure correctness and stability.
Testing Type Conversions
Type conversions between Rust and Ruby are common sources of bugs:
#![allow(unused)] fn main() { #[ruby_test] fn test_type_conversions() { let ruby = Ruby::get().unwrap(); // Test Ruby to Rust conversions let rb_str = RString::new(ruby, "test"); let rb_int = Integer::from_i64(42); let rb_array = RArray::from_iter(ruby, vec![1, 2, 3]); // Convert to Rust types let rust_str: String = rb_str.to_string().unwrap(); let rust_int: i64 = rb_int.to_i64().unwrap(); let rust_vec: Vec<i64> = rb_array.to_vec().unwrap(); // Verify conversions assert_eq!(rust_str, "test"); assert_eq!(rust_int, 42); assert_eq!(rust_vec, vec![1, 2, 3]); // Test Rust to Ruby conversions let rust_str = "reverse"; let rb_str = RString::new(ruby, rust_str); assert_eq!(rb_str.to_string().unwrap(), rust_str); } }
Method Invocation Syntax in Tests
When testing Rust methods exposed to Ruby, it's important to understand the different invocation patterns based on the method's signature:
Regular Instance Methods
For methods that only take &self
and don't interact with the Ruby VM:
#![allow(unused)] fn main() { // Method definition fn count(&self) -> isize { self.0.borrow().count } // In tests - use instance method syntax #[ruby_test] fn test_count() { let counter = MutCounter::new(0); assert_eq!(counter.count(), 0); } }
Methods with Ruby State
For methods that require the Ruby interpreter (to raise exceptions or interact with Ruby objects):
#![allow(unused)] fn main() { // Method definition fn divide(ruby: &Ruby, _rb_self: &Self, a: f64, b: f64) -> Result<f64, Error> { if b == 0.0 { return Err(Error::new( ruby.exception_zero_div_error(), "Division by zero" )); } Ok(a / b) } // In tests - use associated function syntax with explicit self parameter #[ruby_test] fn test_divide() { let ruby = Ruby::get().unwrap(); let calc = MutCalculator::new(); // CORRECT: Associated function syntax with all parameters let result = MutCalculator::divide(&ruby, &calc, 6.0, 2.0); assert!(result.is_ok()); assert_eq!(result.unwrap(), 3.0); // INCORRECT: This will not compile // let result = calc.divide(&ruby, 6.0, 2.0); } }
The key difference is that when a method takes rb_self: &Self
as a parameter (as many methods do that interact with
Ruby), it's not a true instance method from Rust's perspective. In tests, you must call these using the associated
function syntax, passing in the Ruby interpreter and the self reference explicitly.
Testing RefCell Borrowing
For extensions that use RefCell
for interior mutability, test these patterns thoroughly:
#![allow(unused)] fn main() { #[ruby_test] fn test_refcell_borrowing() { let ruby = Ruby::get().unwrap(); let counter = MutCounter::new(0); // Test regular instance methods assert_eq!(counter.count(), 0); assert_eq!(counter.increment(), 1); assert_eq!(counter.increment(), 2); // Test methods that use checked operations with the Ruby VM // Note the use of associated function syntax here let result = MutCounter::add_checked(&ruby, &counter, 10); assert!(result.is_ok()); assert_eq!(result.unwrap(), 13); assert_eq!(counter.count(), 13); } }
GC Stress Testing
Testing with Ruby's garbage collector is essential to ensure your extension doesn't leak memory or access deallocated
objects. The #[ruby_test(gc_stress)]
option helps identify these issues early by running the garbage collector more
frequently.
Basic GC Stress Testing
#![allow(unused)] fn main() { #[ruby_test(gc_stress)] fn test_gc_integration() { let ruby = Ruby::get().unwrap(); // Create objects that should be properly managed for i in 0..100 { let obj = SomeObject::new(i); // obj goes out of scope here, should be collected } // Force garbage collection explicitly ruby.gc_start(); // No panics or segfaults means the test passes } }
Testing with TypedData and Mark Methods
For custom classes that hold Ruby object references, test the mark
method implementation:
#![allow(unused)] fn main() { use magnus::{gc::Marker, TypedData, DataTypeFunctions, Value}; // A struct that holds references to Ruby objects #[derive(TypedData)] #[magnus(class = "MyExtension::Container", free_immediately, mark)] struct Container { item: Value, metadata: Value, } impl DataTypeFunctions for Container { fn mark(&self, marker: &Marker) { marker.mark(self.item); marker.mark(self.metadata); } } impl Container { fn new(item: Value, metadata: Value) -> Self { Self { item, metadata } } fn item(&self) -> Value { self.item } } // Test with GC stress #[ruby_test(gc_stress)] fn test_container_mark_method() { let ruby = Ruby::get().unwrap(); // Create Ruby strings let item = RString::new(ruby, "Test Item"); let metadata = RString::new(ruby, "Item Description"); // Create our container let container = Container::new(item.as_value(), metadata.as_value()); // Force garbage collection ruby.gc_start(); // The items should still be accessible and not garbage collected let retrieved_item = container.item(); let item_str: String = RString::from_value(retrieved_item).unwrap().to_string().unwrap(); assert_eq!(item_str, "Test Item"); } }
Testing Object References After GC
This test ensures objects referenced by your extension aren't prematurely collected:
#![allow(unused)] fn main() { #[ruby_test(gc_stress)] fn test_object_references_survive_gc() { let ruby = Ruby::get().unwrap(); // Create a struct holding references to other objects #[derive(TypedData)] #[magnus(class = "Node", free_immediately, mark)] struct Node { value: Value, children: Vec<Value>, } impl DataTypeFunctions for Node { fn mark(&self, marker: &Marker) { marker.mark(self.value); for child in &self.children { marker.mark(*child); } } } impl Node { fn new(value: Value) -> Self { Self { value, children: Vec::new() } } fn add_child(&mut self, child: Value) { self.children.push(child); } fn child_values(&self, ruby: &Ruby) -> Result<Vec<String>, Error> { let mut result = Vec::new(); for child in &self.children { let str = RString::from_value(*child)?; result.push(str.to_string()?); } Ok(result) } } // Create the parent node let parent_value = RString::new(ruby, "Parent"); let mut parent = Node::new(parent_value.as_value()); // Add many child nodes for i in 0..20 { let child = RString::new(ruby, format!("Child {}", i)); parent.add_child(child.as_value()); } // Run garbage collection multiple times for _ in 0..5 { ruby.gc_start(); } // Verify all children are still accessible let child_values = parent.child_values(ruby).unwrap(); assert_eq!(child_values.len(), 20); assert_eq!(child_values[0], "Child 0"); assert_eq!(child_values[19], "Child 19"); } }
Testing Memory Safety with Raw Pointers
If your extension uses raw C API functions, test with gc_stress and use rb_gc_guard!
:
#![allow(unused)] fn main() { use rb_sys::*; #[ruby_test(gc_stress)] fn test_raw_pointer_safety() { unsafe { // Create Ruby values let rb_ary = rb_ary_new(); // IMPORTANT: Protect from GC let rb_ary = rb_gc_guard!(rb_ary); // Add items to the array for i in 0..10 { let rb_str = rb_utf8_str_new_cstr(format!("item {}\0", i).as_ptr() as _); // IMPORTANT: Protect each string from GC let rb_str = rb_gc_guard!(rb_str); rb_ary_push(rb_ary, rb_str); } // Force GC rb_gc(); // Array should still have 10 elements assert_eq!(rb_ary_len(rb_ary), 10); } } }
Test Helpers and Utilities
rb-sys-test-helpers provides various utilities to make testing easier.
Value Conversion Helpers
These macros help with common conversions when testing:
#![allow(unused)] fn main() { use rb_sys_test_helpers::{rstring_to_string, rarray_to_vec}; #[ruby_test] fn test_with_conversion_helpers() { unsafe { // Create Ruby objects let rb_str = rb_utf8_str_new_cstr("hello\0".as_ptr() as _); let rb_ary = rb_ary_new(); rb_ary_push(rb_ary, rb_utf8_str_new_cstr("one\0".as_ptr() as _)); rb_ary_push(rb_ary, rb_utf8_str_new_cstr("two\0".as_ptr() as _)); // Convert to Rust using helpers let rust_str = rstring_to_string!(rb_str); let rust_vec = rarray_to_vec!(rb_ary, String); // Verify conversions assert_eq!(rust_str, "hello"); assert_eq!(rust_vec, vec!["one".to_string(), "two".to_string()]); } } }
Exception Handling Helpers
The protect
function simplifies handling Ruby exceptions:
#![allow(unused)] fn main() { use rb_sys_test_helpers::protect; #[ruby_test] fn test_exception_handling() { // Try an operation that might raise an exception let result = unsafe { protect(|| { // Ruby operation that might raise rb_sys::rb_funcall( rb_sys::rb_cObject, rb_sys::rb_intern("nonexistent_method\0".as_ptr() as _), 0 ) }) }; // Verify we got an exception assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.message().unwrap().contains("undefined method")); } }
CI Testing Workflow
CI testing is essential for extensions that will be distributed as gems. Without it, you risk publishing binaries that crash on specific Ruby versions or platforms.
Setting up continuous integration (CI) testing is crucial for Ruby extension gems. This section covers best practices for testing your extensions in CI environments.
Basic GitHub Actions Setup
A simple GitHub Actions workflow for a Rust Ruby extension typically includes:
- Setting up Ruby and Rust environments
- Running compilation
- Executing tests
- Linting the code
Click the eye icon () to see a Windows-specific job configuration.
The oxidize-rb/actions repository provides specialized GitHub Actions for Ruby extensions written in Rust, making setup much simpler.
Memory Testing with ruby_memcheck
Memory leaks can be particularly difficult to detect in Ruby extensions. Tools like ruby_memcheck help catch these issues early.
The ruby_memcheck gem provides a powerful way to detect memory leaks in Ruby extensions. It uses Valgrind under the hood but filters out false positives that are common when running Valgrind on Ruby code.
To use ruby_memcheck, add it to your test workflow:
To run memory tests:
# Install valgrind first if needed
# sudo apt-get install valgrind # On Debian/Ubuntu
# Run the tests with memory checking
bundle exec rake test:valgrind
Click the eye icon () to see advanced configuration options for ruby_memcheck.
For more detailed instructions and configuration options, refer to the ruby_memcheck documentation.
Cross-Platform Testing with rb-sys-dock
For testing across different platforms, rb-sys-dock provides Docker images pre-configured for cross-platform compilation and testing of Rust Ruby extensions.
Best Practices for CI Testing
Without thorough CI testing across all supported platforms and Ruby versions, your extension may work perfectly in your development environment but crash for users with different setups.
- Test Matrix: Test against multiple Ruby versions, Rust versions, and platforms
- Memory Testing: Include memory leak detection with ruby_memcheck
- Linting: Validate code formatting and catch Rust warnings
- Cross-Platform: Test on all platforms you aim to support
- Documentation Verification: Test code examples in documentation
The oxidize-rb/actions repository provides ready-to-use GitHub Actions for:
- Setting up Ruby and Rust environments
- Building native gems
- Cross-compiling for multiple platforms
- Running tests and linting checks
Using these specialized actions will save you time and ensure your tests follow best practices.
Debugging & Troubleshooting
This chapter covers techniques for debugging Rust-based Ruby extensions, common error patterns, and approaches to solving the most frequent issues.
Overview
To debug Rust extensions, you can use either LLDB or GDB. First, you will need to compile with the dev
Cargo profile,
so debug symbols are available.
To do that you can run: RB_SYS_CARGO_PROFILE=dev rake compile
. Alternatively, you can add a helper Rake task to make
this easier:
# Rakefile
desc "Compile the extension with debug symbols"
task "compile:debug" do
ENV["RB_SYS_CARGO_PROFILE"] = "dev"
Rake::Task["compile"].invoke
end
💡 Tip: Join the Slack channel to ask questions and get help from the community!
Common Errors and Solutions
Compilation Errors
Missing Ruby Headers
Error:
fatal error: ruby.h: No such file or directory
#include <ruby.h>
^~~~~~~~
compilation terminated.
Solution:
- Ensure Ruby development headers are installed
- Check that
rb_sys_env::activate()
is being called in yourbuild.rs
- Verify that your Ruby installation is accessible to your build environment
Incompatible Ruby Version
Error:
error: failed to run custom build command for `rb-sys v0.9.78`
With details mentioning Ruby version compatibility issues.
Solution:
- Ensure your rb-sys version is compatible with your Ruby version
- Update rb-sys to the latest version
- Check your build environment's Ruby version with
ruby -v
Linking Errors
Error:
error: linking with `cc` failed: exit status: 1
... undefined reference to `rb_define_module` ...
Solution:
- Ensure proper linking configuration in
build.rs
- Make sure you've called
rb_sys_env::activate()
- Verify that your Ruby installation is correctly detected
Runtime Errors
Segmentation Faults
Segmentation faults typically occur when accessing memory improperly:
Common Causes:
- Accessing Ruby objects after they've been garbage collected
- Not protecting Ruby values from garbage collection during C API calls
- Incorrect use of raw pointers
Solutions:
- Use
TypedData
and implement themark
method to protect Ruby objects - Use
rb_gc_guard!
macro when working with raw C API - Prefer the higher-level Magnus API over raw rb-sys
Already Borrowed: BorrowMutError
When using RefCell
for interior mutability:
Error:
thread '<unnamed>' panicked at 'already borrowed: BorrowMutError', ...
Solution:
- Complete all immutable borrows before attempting mutable borrows
- Copy required data out of immutable borrows before borrowing mutably
- See the RefCell and Interior Mutability section in the Memory Management chapter
Method Argument Mismatch
Error:
ArgumentError: wrong number of arguments (given 2, expected 1)
Solution:
- Check method definitions in your Rust code
- Ensure
function!
andmethod!
macros have the correct arity - Verify Ruby method calls match the defined signatures
Type Conversion Failures
Error:
TypeError: no implicit conversion of Integer into String
Solution:
- Add proper type checking and conversions in Rust
- Use
try_convert
and handle conversion errors gracefully - Add explicit type annotations to clarify intent
Debugging Techniques
Using Backtraces
Ruby's built-in backtraces can help identify where problems originate:
begin
# Code that might raise an exception
MyExtension.problematic_method
rescue => e
puts e.message
puts e.backtrace
end
You can enhance backtraces with the backtrace
gem:
require 'backtrace'
Backtrace.enable_ruby_source_inspect!
begin
MyExtension.problematic_method
rescue => e
puts Backtrace.for(e)
end
VSCode + LLDB
The code-lldb extension for VSCode is a great way to debug Rust code. Here is an example configuration file:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug",
"preLaunchTask": {
"task": "compile:debug",
"type": "rake"
},
"program": "~/.asdf/installs/ruby/3.1.1/bin/ruby",
"args": ["-Ilib", "test/test_helper.rb"],
"cwd": "${workspaceFolder}",
"sourceLanguages": ["rust"]
}
]
}
Debugging the Ruby C API
With this basic setup, you can set breakpoints and interactively debug your Rust code. However, if Ruby is not built with debug symbols, any calls into the Ruby C API become a black box. Luckily, it's straight-forward to fix this.
Compiling Ruby with debug symbols and source code
Using chruby
or ruby-build
-
First, compile Ruby like so:
$ RUBY_CFLAGS="-Og -ggdb" ruby-build --keep 3.1.2 /opt/rubies/3.1.2-debug
-
Make sure your
.vscode/launch.json
file is configured to use/opt/rubies/3.1.2-debug/bin/ruby
.
Using rbenv
-
First, compile Ruby like so:
$ RUBY_CFLAGS="-Og -ggdb" rbenv install --keep 3.1.2
-
Make sure your
.vscode/launch.json
file is configured to use$RBENV_ROOT/versions/3.1.2/bin/ruby
.
LLDB from the Command Line
LLDB is an excellent tool for debugging Rust extensions from the command line:
-
Compile with debug symbols:
RUSTFLAGS="-g" bundle exec rake compile
-
Run Ruby with LLDB:
lldb -- ruby -I lib -e 'require "my_extension"; MyExtension.method_to_debug'
-
Set breakpoints and run:
(lldb) breakpoint set --name rb_my_method (lldb) run
-
Common LLDB commands:
bt
- Display backtraceframe variable
- Show local variablesp expression
- Evaluate expressionn
- Step overs
- Step intoc
- Continue
GDB for Linux
GDB offers similar capabilities to LLDB on Linux systems:
-
Compile with debug symbols:
RUSTFLAGS="-g" bundle exec rake compile
-
Run Ruby with GDB:
gdb --args ruby -I lib -e 'require "my_extension"; MyExtension.method_to_debug'
-
Set breakpoints and run:
(gdb) break rb_my_method (gdb) run
-
Common GDB commands:
bt
- Display backtraceinfo locals
- Show local variablesp expression
- Evaluate expressionn
- Step overs
- Step intoc
- Continue
Rust Debugging Statements
Strategic use of Rust's debug facilities can help identify issues:
#![allow(unused)] fn main() { // Debug prints only included in debug builds #[cfg(debug_assertions)] println!("Debug: counter value = {}", counter); // More structured logging use log::{debug, error, info}; fn some_function() -> Result<(), Error> { debug!("Entering some_function"); if let Err(e) = fallible_operation() { error!("Operation failed: {}", e); return Err(e.into()); } info!("Operation succeeded"); Ok(()) } }
To enable logging output, add a logger like env_logger
:
#![allow(unused)] fn main() { fn init(ruby: &Ruby) -> Result<(), Error> { env_logger::init(); // Rest of initialization... Ok(()) } }
And set the log level when running Ruby:
RUST_LOG=debug ruby -I lib -e 'require "my_extension"'
Memory Leak Detection
Using ruby_memcheck
The ruby_memcheck gem helps identify memory leaks in Ruby extensions by filtering out Ruby's internal memory management noise when running Valgrind.
-
Install dependencies:
gem install ruby_memcheck # On Debian/Ubuntu apt-get install valgrind
-
Set up in your Rakefile:
require 'ruby_memcheck' test_config = lambda do |t| t.libs << "test" t.test_files = FileList["test/**/*_test.rb"] end namespace :test do RubyMemcheck::TestTask.new(valgrind: :compile, &test_config) end
-
Run memory leak detection:
bundle exec rake test:valgrind
For more detailed instructions and configuration options, refer to the ruby_memcheck documentation.
Best Practices
- Add Meaningful Error Messages: Make your error messages descriptive and helpful
- Test Edge Cases: Thoroughly test edge cases like nil values, empty strings, etc.
- Maintain a Test Suite: Comprehensive tests catch issues early
- Use Memory Safety Features: Leverage Rust's safety features rather than bypassing them
- Provide Debugging Symbols: Always include debug symbol builds for better debugging
- Document Troubleshooting: Add a troubleshooting section to your extension's documentation
- Log Appropriately: Include contextual information in log messages
Next Steps
- Build your extension with
RB_SYS_CARGO_PROFILE=dev
and practice setting breakpoints. - Explore GDB as an alternative to LLDB for low-level debugging.
- See the Memory Management & Safety chapter for GC-related troubleshooting.
- If you're still stuck, join the Slack channel to ask questions and get help from the community!
Troubleshooting Guide
This chapter provides solutions to common issues encountered when developing Ruby extensions with rb-sys and magnus.
Getting Started Issues
Installation Problems
Missing libclang
Problem: Build fails with errors related to missing libclang:
error: failed to run custom build command for `bindgen v0.64.0`
...
could not find libclang: "couldn't find any valid shared libraries matching: ['libclang.so', ...]"
Solutions:
-
Add libclang to your Gemfile:
gem "libclang", "~> 14.0"
-
On Linux, install libclang through your package manager:
# Debian/Ubuntu apt-get install libclang-dev # Fedora/RHEL dnf install clang-devel
-
On macOS:
brew install llvm export LLVM_CONFIG=$(brew --prefix llvm)/bin/llvm-config
Ruby Headers Not Found
Problem: Build fails with error about missing Ruby headers:
error: failed to run custom build command for `rb-sys v0.9.78`
...
fatal error: ruby.h: No such file or directory
Solutions:
-
Ensure you have Ruby development headers installed:
# Debian/Ubuntu apt-get install ruby-dev # Fedora/RHEL dnf install ruby-devel
-
If using rbenv/rvm/asdf, make sure you've installed the development headers:
# For rbenv RUBY_CONFIGURE_OPTS=--enable-shared rbenv install 3.0.0 # For rvm rvm install 3.0.0 --with-openssl-dir=$(brew --prefix openssl)
-
Check your build.rs file includes:
#![allow(unused)] fn main() { let _ = rb_sys_env::activate()?; }
Compilation Issues
Cargo Build Errors
Version Compatibility
Problem: Build fails with version incompatibility errors:
error: failed to select a version for the requirement `rb-sys = "^0.9.80"`
Solution:
-
Check your magnus and rb-sys versions are compatible:
# Cargo.toml [dependencies] magnus = "0.7" # Check latest compatible version rb-sys = "0.9.80" # Check latest version
-
Update rb-sys in your Gemfile:
gem 'rb_sys', '~> 0.9.80'
Linking Issues
Problem: Build fails with undefined references:
error: linking with `cc` failed: exit status: 1
...
undefined reference to `rb_define_module`
Solutions:
-
Ensure your build.rs is correctly set up:
fn main() -> Result<(), Box<dyn std::error::Error>> { let _ = rb_sys_env::activate()?; Ok(()) }
-
Verify Ruby version compatibility with rb-sys version
-
Ensure you have Ruby development headers installed
Build Script Errors
Problem: Build script execution fails:
error: failed to run custom build command for `rb-sys v0.9.78`
Solutions:
-
Check permissions and environment variables:
# Set necessary environment variables export RUBY_ROOT=$(rbenv prefix) export PATH=$RUBY_ROOT/bin:$PATH
-
If using Docker, ensure the build environment has Ruby installed and configured
Runtime Issues
Ruby Object Management
Segmentation Faults
Problem: Ruby process crashes with a segmentation fault:
[BUG] Segmentation fault
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-linux]
Solutions:
-
Ensure Ruby objects are correctly protected from garbage collection:
#![allow(unused)] fn main() { // Using Magnus (preferred) let obj = RObject::new(ruby, ruby.class_object())?; // With raw rb-sys unsafe { let obj = rb_sys::rb_obj_alloc(rb_sys::rb_cObject); rb_sys::rb_gc_register_mark_object(obj); // Use obj... rb_sys::rb_gc_unregister_mark_object(obj); } }
-
Check all pointers are valid before dereferencing
-
Use TypedData with proper mark implementation
BorrowMutError Panics
Problem: Your extension panics with a "already borrowed" error when using RefCell:
thread '<unnamed>' panicked at 'already borrowed: BorrowMutError'
Solutions:
-
Fix borrow order - always complete immutable borrows before mutable borrows:
#![allow(unused)] fn main() { // WRONG - causes BorrowMutError if self.0.borrow().value > 10 { self.0.borrow_mut().value = 0; // Error: still borrowed from the if condition } // RIGHT - copy values then borrow mutably let current = self.0.borrow().value; // Complete this borrow if current > 10 { self.0.borrow_mut().value = 0; // Now safe to borrow mutably } }
-
Consider restructuring your data to avoid nested borrows
-
Use separate methods for reading and writing
Ruby/Rust Type Conversion Issues
Unexpected Nil Values
Problem: Your extension crashes when encountering nil:
TypeError: no implicit conversion of nil into String
Solutions:
-
Always check for nil before conversion:
#![allow(unused)] fn main() { fn process_string(val: Value) -> Result<String, Error> { if val.is_nil() { // Handle nil case return Ok("default".to_string()); } let string = RString::try_convert(val)?; Ok(string.to_string()?) } }
-
Use Option for conversions that might return nil:
#![allow(unused)] fn main() { let maybe_string: Option<String> = val.try_convert()?; match maybe_string { Some(s) => process_string(s), None => handle_nil_case(), } }
Type Errors
Problem: Function fails with type mismatch errors:
TypeError: wrong argument type Integer (expected String)
Solutions:
-
Add explicit type checking before conversion:
#![allow(unused)] fn main() { fn process_value(ruby: &Ruby, val: Value) -> Result<(), Error> { if !val.is_kind_of(ruby, ruby.class_object::<RString>()?) { return Err(Error::new( ruby.exception_type_error(), format!("Expected String, got {}", val.class().name()) )); } let string = RString::try_convert(val)?; // Process string... Ok(()) } }
-
Use try_convert with proper error handling:
#![allow(unused)] fn main() { match RString::try_convert(val) { Ok(string) => { // Process string... Ok(()) }, Err(_) => { Err(Error::new( ruby.exception_type_error(), "Expected String argument" )) } } }
Memory and Performance Issues
Memory Leaks
Problem: Your extension gradually consumes more memory over time.
Solutions:
-
Ensure Ruby objects are properly released when using raw rb-sys:
#![allow(unused)] fn main() { unsafe { // Register the object to protect it from GC rb_sys::rb_gc_register_mark_object(obj); // Use the object... // Unregister when done (important!) rb_sys::rb_gc_unregister_mark_object(obj); } }
-
Implement proper mark methods for TypedData:
#![allow(unused)] fn main() { #[derive(TypedData)] #[magnus(class = "MyClass", free_immediately, mark)] struct MyObject { references: Vec<Value>, } impl DataTypeFunctions for MyObject { fn mark(&self, marker: &Marker) { for reference in &self.references { marker.mark(*reference); } } } }
-
Use ruby_memcheck to detect leaks (see Debugging chapter)
Global VM Lock (GVL) Issues
Problem: CPU-intensive operations block the Ruby VM.
Solutions:
-
Release the GVL during CPU-intensive work:
#![allow(unused)] fn main() { use rb_sys::rb_thread_call_without_gvl; use std::{ffi::c_void, ptr::null_mut}; pub fn nogvl<F, R>(func: F) -> R where F: FnOnce() -> R, R: Send + 'static, { struct CallbackData<F, R> { func: Option<F>, result: Option<R>, } extern "C" fn callback<F, R>(data: *mut c_void) -> *mut c_void where F: FnOnce() -> R, R: Send + 'static, { let data = unsafe { &mut *(data as *mut CallbackData<F, R>) }; if let Some(func) = data.func.take() { data.result = Some(func()); } null_mut() } let mut data = CallbackData { func: Some(func), result: None, }; unsafe { rb_thread_call_without_gvl( Some(callback::<F, R>), &mut data as *mut _ as *mut c_void, None, null_mut(), ); } data.result.unwrap() } }
-
Only release the GVL for operations that don't interact with Ruby objects:
#![allow(unused)] fn main() { // Safe to run without GVL - pure computation let result = nogvl(|| { compute_intensive_function(input_data) }); // NOT safe to run without GVL - interacts with Ruby // let result = nogvl(|| { // ruby_object.some_method() // WRONG - will crash // }); }
Cross-Platform Issues
Ruby-Head Compatibility Issues
Problem: Gems don't work with ruby-head
or development versions of Ruby.
Solutions:
-
Always publish a source gem alongside platform-specific gems:
# In your release workflow, build both platform-specific and source gems # The source gem allows ruby-head users to compile against their exact version bundle exec rake build # For source gem bundle exec rake native # For platform-specific gems
-
For users, install the gem with compilation enabled:
gem install my_gem --platform=ruby
-
For gem maintainers, update your CI to test against ruby-head:
# .github/workflows/test.yml strategy: matrix: ruby: ["3.1", "3.2", "3.3", "head"]
Platform-Specific Build Problems
Windows Issues
Problem: Build fails on Windows with linking errors.
Solutions:
-
Ensure you have the correct toolchain installed:
rustup target add x86_64-pc-windows-msvc
-
Add platform-specific configuration in Cargo.toml:
[target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["everything"] }
-
Use conditional compilation for platform-specific code:
#![allow(unused)] fn main() { #[cfg(windows)] fn platform_specific() { // Windows-specific code } #[cfg(unix)] fn platform_specific() { // Unix-specific code } }
macOS Issues
Problem: Build fails on macOS with architecture or linking issues.
Solutions:
-
Specify architecture when needed:
RUBY_CONFIGURE_OPTS="--with-arch=x86_64,arm64" rbenv install 3.0.0
-
Fix linking for universal binaries:
#![allow(unused)] fn main() { // build.rs #[cfg(target_os = "macos")] { println!("cargo:rustc-link-arg=-arch"); println!("cargo:rustc-link-arg=arm64"); println!("cargo:rustc-link-arg=-arch"); println!("cargo:rustc-link-arg=x86_64"); } }
Cargo.toml Configuration Issues
Feature Flag Problems
Problem: Build fails because of conflicting or missing feature flags.
Solutions:
-
Check for feature flag issues in dependencies:
[dependencies] magnus = { version = "0.7", features = ["rb-sys"] } rb-sys = { version = "0.9.80", features = ["stable-api"] }
-
Use debug prints in build.rs to check feature detection:
fn main() { println!("cargo:warning=Ruby version: {}", rb_sys_env::ruby_major_version()); #[cfg(feature = "some-feature")] println!("cargo:warning=some-feature is enabled"); }
Ruby Integration Issues
Method Definition Problems
Problem: Ruby method definitions don't work as expected.
Solutions:
-
Check method arity and macro usage:
#![allow(unused)] fn main() { // For instance methods (that use &self) class.define_method("instance_method", method!(MyClass::instance_method, 1))?; // For class/module methods (no &self) class.define_singleton_method("class_method", function!(class_method, 1))?; }
-
Verify method signatures:
#![allow(unused)] fn main() { // Instance method fn instance_method(&self, arg: Value) -> Result<Value, Error> { // Method implementation... } // Class method fn class_method(arg: Value) -> Result<Value, Error> { // Method implementation... } // Method with ruby fn method_with_ruby(ruby: &Ruby, arg: Value) -> Result<Value, Error> { // Method implementation... } }
Module/Class Hierarchy Issues
Problem: Ruby modules or classes aren't defined correctly.
Solutions:
-
Check the correct nesting of defines:
#![allow(unused)] fn main() { // Define a module and a nested class let module = ruby.define_module("MyModule")?; let class = module.define_class("MyClass", ruby.class_object())?; // Define a nested module let nested = module.define_module("Nested")?; }
-
Verify class inheritance:
#![allow(unused)] fn main() { // Get the correct superclass let superclass = ruby.class_object::<RObject>()?; // Define a class with the superclass let class = ruby.define_class("MyClass", superclass)?; }
Debugging Ruby Exceptions
Custom Exception Handling
Problem: Ruby exceptions aren't properly caught or raised.
Solutions:
-
Define and use custom exception classes:
#![allow(unused)] fn main() { fn init(ruby: &Ruby) -> Result<(), Error> { let module = ruby.define_module("MyModule")?; // Define custom exceptions let std_error = ruby.exception_standard_error(); let custom_error = module.define_class("CustomError", std_error)?; Ok(()) } fn raise_custom_error(ruby: &Ruby) -> Result<(), Error> { Err(Error::new( ruby.class_path_to_value("MyModule::CustomError"), "Something went wrong" )) } }
-
Catch specific exception types:
#![allow(unused)] fn main() { fn handle_exceptions(ruby: &Ruby, val: Value) -> Result<Value, Error> { let result = val.funcall(ruby, "may_raise", ()); match result { Ok(v) => Ok(v), Err(e) if e.is_kind_of(ruby, ruby.exception_zero_div_error()) => { // Handle division by zero Ok(ruby.integer_from_i64(0)) }, Err(e) => Err(e), // Re-raise other exceptions } } }
Additional Resources
- Official Documentation: rb-sys and magnus
- Examples: Check the examples directory in rb-sys
- Community Support: Join the Slack channel
- Further Reading: See the Debugging chapter for more detailed debugging techniques
rb-sys Crate Features
The rb-sys
crate provides battle-tested Rust bindings for the Ruby C API. It uses the
rust-bindgen
crate to generate bindings from the ruby.h
header.
Usage Notice
This is a very low-level library. If you are looking to write a gem in Rust, you should probably use the
Magnus crate with the rb-sys-interop
feature, which provides a higher-level,
more ergonomic API.
If you need raw/unsafe bindings to libruby, then this crate is for you!
Writing a Ruby Gem
Ruby gems require boilerplate to be defined to be usable from Ruby. rb-sys
makes this process painless by doing the
work for you. Simply enable the gem
feature:
[dependencies]
rb-sys = "0.9"
Under the hood, this ensures the crate does not link libruby (unless on Windows) and defines a ruby_abi_version
function for Ruby 3.2+.
Embedding libruby in Your Rust App
IMPORTANT: If you are authoring a Ruby gem, you do not need to enable this feature.
If you need to link libruby (i.e., you are initializing a Ruby VM in your Rust code), you can enable the link-ruby
feature:
[dependencies]
rb-sys = { version = "0.9", features = ["link-ruby"] }
Static libruby
You can also force static linking of libruby:
[dependencies]
rb-sys = { version = "0.9", features = ["ruby-static"] }
Alternatively, you can set the RUBY_STATIC=true
environment variable.
Available Features
The rb-sys
crate provides several features that can be enabled in your Cargo.toml
:
Feature | Description |
---|---|
global-allocator | Report Rust memory allocations to the Ruby GC (recommended) |
ruby-static | Link the static version of libruby |
link-ruby | Link libruby (typically used for embedding, not for extensions) |
bindgen-rbimpls | Include the Ruby impl types in bindings |
bindgen-deprecated-types | Include deprecated Ruby methods in bindings |
gem | Set up the crate for use in a Ruby gem (default feature) |
stable-api | Use the stable API (C level) if available for your Ruby version |
Example Cargo.toml
[dependencies]
rb-sys = { version = "0.9", features = ["global-allocator", "stable-api"] }
Ruby Version Compatibility
rb-sys
is compatible with Ruby 2.6 and later. The crate detects the Ruby version at compile time and adapts the
bindings accordingly.
For Ruby 3.2 and later, rb-sys
provides a ruby_abi_version
function that is required for native extensions.
Integration with Magnus
If you're building a Ruby extension, it's recommended to use the Magnus crate on
top of rb-sys
. Magnus provides a high-level, safe API for interacting with Ruby:
[dependencies]
magnus = { version = "0.7", features = ["rb-sys"] }
rb-sys = "0.9"
rb_sys Gem Configuration
The rb_sys
gem makes it easy to build native Ruby extensions in Rust. It interoperates with existing Ruby native
extension toolchains (i.e., rake-compiler
) to make testing, building, and cross-compilation of gems easy.
RbSys::ExtensionTask
This gem provides a RbSys::ExtensionTask
class that can be used to build a Ruby extension in Rust. It's a thin wrapper
around Rake::ExtensionTask
that provides sane defaults for building Rust extensions.
# Rakefile
require "rb_sys/extensiontask"
GEMSPEC = Gem::Specification.load("my_gem.gemspec")
RbSys::ExtensionTask.new("my-crate-name", GEMSPEC) do |ext|
ext.lib_dir = "lib/my_gem"
# If you want to use `rb-sys-dock` for cross-compilation:
ext.cross_compile = true
end
create_rust_makefile
The gem provides a simple helper to build a Ruby-compatible Makefile for your Rust extension:
# ext/rust_reverse/extconf.rb
# We need to require mkmf *first* since `rake-compiler` injects code here for cross compilation
require "mkmf"
require "rb_sys/mkmf"
create_rust_makefile("rust_reverse") do |r|
# Create debug builds in dev. Make sure that release gems are compiled with
# `RB_SYS_CARGO_PROFILE=release` (optional)
r.profile = ENV.fetch("RB_SYS_CARGO_PROFILE", :dev).to_sym
# Can be overridden with `RB_SYS_CARGO_FEATURES` env var (optional)
r.features = ["test-feature"]
# You can add whatever env vars you want to the env hash (optional)
r.env = {"FOO" => "BAR"}
# If your Cargo.toml is in a different directory, you can specify it here (optional)
r.ext_dir = "."
# Extra flags to pass to the $RUSTFLAGS environment variable (optional)
r.extra_rustflags = ["--cfg=some_nested_config_var_for_crate"]
# Force a rust toolchain to be installed via rustup (optional)
# You can also set the env var `RB_SYS_FORCE_INSTALL_RUST_TOOLCHAIN=true`
r.force_install_rust_toolchain = "stable"
# Clean up the target/ dir after `gem install` to reduce bloat (optional)
r.clean_after_install = false # default: true if invoked by rubygems
# Auto-install Rust toolchain if not present on "gem install" (optional)
r.auto_install_rust_toolchain = false # default: true if invoked by rubygems
end
Environment Variables
The rb_sys
gem respects several environment variables that can modify its behavior:
Environment Variable | Description |
---|---|
RB_SYS_CARGO_PROFILE | Set the Cargo profile (i.e., release or dev ) |
RB_SYS_CARGO_FEATURES | Comma-separated list of Cargo features to enable |
RB_SYS_FORCE_INSTALL_RUST_TOOLCHAIN | Force installation of a Rust toolchain |
RUBY_STATIC | Force static linking of libruby if set to true |
LIBCLANG_PATH | Path to libclang if it can't be found automatically |
Tips and Tricks
-
When using
rake-compiler
to build your gem, you can use theRB_SYS_CARGO_PROFILE
environment variable to set the Cargo profile (i.e.,release
ordev
). -
You can pass Cargo arguments to
rake-compiler
like so:rake compile -- --verbose
-
It's possible to force an installation of a Rust toolchain by setting the
RB_SYS_FORCE_INSTALL_RUST_TOOLCHAIN
environment variable. This will install rustup and cargo in the build directory, so the end user does not have to have Rust pre-installed. Ideally, this should be a last resort, as it's better to already have the toolchain installed on your system.
Troubleshooting
Libclang Issues
If you see an error like this:
thread 'main' panicked at 'Unable to find libclang: "couldn't find any valid shared libraries matching: \['libclang.so', 'libclang-*.so', 'libclang.so.*', 'libclang-*.so.*'\], set the `LIBCLANG_PATH` environment variable to a path where one of these files can be found (invalid: \[\])"'
This means that bindgen is having issues finding a usable version of libclang. An easy way to fix this is to install the
libclang
gem, which will install a pre-built version of libclang for you.
rb_sys
will automatically detect this gem and use it.
# Gemfile
gem "libclang", "~> 14.0.6"
rb-sys-test-helpers
The rb-sys-test-helpers
crate provides utilities for testing Ruby extensions from Rust. It makes it easy to run tests
with a valid Ruby VM.
Usage
Add this to your Cargo.toml
:
[dev-dependencies]
rb-sys-env = { version = "0.1" }
rb-sys-test-helpers = { version = "0.2" }
Then, in your crate's build.rs
:
pub fn main() -> Result<(), Box<dyn std::error::Error>> { let _ = rb_sys_env::activate()?; Ok(()) }
Then, you can use the ruby_test
attribute macro in your tests:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use rb_sys_test_helpers::ruby_test; use rb_sys::{rb_num2fix, rb_int2big, FIXNUM_P}; #[ruby_test] fn test_something() { // Your test code here will have a valid Ruby VM (hint: this works with // the `magnus` crate, too!) // // ... let int = unsafe { rb_num2fix(1) }; let big = unsafe { rb_int2big(9999999) }; assert!(FIXNUM_P(int)); assert!(!FIXNUM_P(big)); } } }
How It Works
The ruby_test
macro sets up a Ruby VM before running your test and tears it down afterward. This allows you to
interact with Ruby from your Rust code during tests without having to set up the VM yourself.
The test helpers are compatible with both rb-sys
for low-level C API access and magnus
for higher-level Ruby
interactions.
Common Testing Patterns
Testing Value Conversions
#![allow(unused)] fn main() { #[ruby_test] fn test_value_conversion() { use rb_sys::{rb_cObject, rb_funcall, rb_str_new_cstr, rb_iv_set}; use std::ffi::CString; unsafe { let obj = rb_cObject; let name = CString::new("test").unwrap(); let value = rb_str_new_cstr(name.as_ptr()); rb_iv_set(obj, b"@name\0".as_ptr() as *const _, value); let result = rb_funcall(obj, b"instance_variable_get\0".as_ptr() as *const _, 1, rb_str_new_cstr(b"@name\0".as_ptr() as *const _)); assert_eq!(value, result); } } }
Testing with Magnus
#![allow(unused)] fn main() { #[ruby_test] fn test_with_magnus() { use magnus::{Ruby, RString, Value}; let ruby = unsafe { Ruby::get().unwrap() }; let string = RString::new(ruby, "Hello, world!").unwrap(); assert_eq!(string.to_string().unwrap(), "Hello, world!"); } }
Testing Multiple Ruby Versions
To test against multiple Ruby versions, you can use environment variables and CI configuration:
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"]
steps:
- uses: actions/checkout@v4
- uses: oxidize-rb/actions/setup-ruby-and-rust@v1
with:
ruby-version: ${{ matrix.ruby }}
- run: cargo test
Your tests will run against each Ruby version in the matrix, helping you ensure compatibility.
Integration with Other Test Frameworks
The ruby_test
attribute works with common Rust test frameworks like proptest
and quickcheck
:
#![allow(unused)] fn main() { #[ruby_test] fn test_with_proptest() { use proptest::prelude::*; proptest!(|(s in "[a-zA-Z0-9]*")| { let ruby = unsafe { Ruby::get().unwrap() }; let ruby_string = RString::new(ruby, &s).unwrap(); assert_eq!(ruby_string.to_string().unwrap(), s); }); } }
Community and Support
The rb-sys project is maintained by the Oxidize Ruby community, a group of developers passionate about integrating Rust and Ruby. We're committed to helping you succeed with your Rust-powered Ruby extensions.
Getting Help
If you encounter issues or have questions about using rb-sys, there are several ways to get help:
Join Our Slack Community
The Oxidize Ruby community has an active Slack workspace where you can ask questions, share your projects, and get help from community members and maintainers.
- Join the Oxidize Ruby Slack
- Post your question in the
#general
channel
GitHub Issues
If you think you've found a bug or want to request a new feature, please open an issue on GitHub:
Real-World Examples
Looking at real-world examples can be a great way to learn how to use rb-sys effectively:
- oxi-test - The canonical example of how to use rb-sys
- wasmtime-rb - WebAssembly runtime for Ruby
- lz4-ruby - LZ4 compression library
- blake3-ruby - BLAKE3 cryptographic hash function
Contributing to rb-sys
We welcome contributions from the community! Whether it's improving documentation, fixing bugs, or adding new features, your contributions are valuable.
See the Contributing Guide for detailed information about setting up a development environment and making contributions.
Resources
Documentation
- Ruby on Rust Book - This comprehensive guide
- rb-sys API Docs - Rust API documentation
- Magnus Documentation - Higher-level Ruby/Rust bindings built on rb-sys
Tools
- rb-sys-dock - Docker-based cross-compilation tooling
- GitHub Actions - CI actions for testing and cross-compiling
- VSCode Debugging Guide - How to set up debugging for Rust extensions
Blog Posts and Talks
- Ruby on Rust - Intro - Introduction to Ruby and Rust integration
- Cross-compiling Ruby C Extensions - How to compile for multiple platforms