diff --git a/rwf-ruby/README.md b/rwf-ruby/README.md new file mode 100644 index 00000000..64f6fe50 --- /dev/null +++ b/rwf-ruby/README.md @@ -0,0 +1,6 @@ +# Rwf ruby + +`rwf-ruby` contains Rust bindings for running Ruby applications built on top of [Rack](https://github.com/rack/rack). While there exists other projects that bind Ruby to Rust in a generic way, +running arbitrary Ruby code inside Rust requires wrapping the `ruby_exec_node` directly. + +This project is experimental, and needs additional testing to ensure production stability. The bindings are written in C, see [src/bindings.c](src/bindings.c). diff --git a/rwf-ruby/build.rs b/rwf-ruby/build.rs index 97edd9f4..6f940ef9 100644 --- a/rwf-ruby/build.rs +++ b/rwf-ruby/build.rs @@ -3,7 +3,6 @@ use std::process::Command; fn main() { println!("cargo:rerun-if-changed=src/bindings.c"); println!("cargo:rerun-if-changed=src/bindings.h"); // Bindings are generated manually because bindgen goes overboard with ruby.h - // println!("cargo:rustc-link-arg=-l/opt/homebrew/Cellar/ruby/3.3.4/lib/libruby.dylib"); let output = Command::new("ruby") .arg("headers.rb") @@ -18,7 +17,7 @@ fn main() { build.flag(flag); } - // Github actions workaround + // Github actions workaround. I don't remember if this works or not. match Command::new("find") .arg("/opt/hostedtoolcache/Ruby") .arg("-name") diff --git a/rwf-ruby/src/Makefile b/rwf-ruby/src/Makefile deleted file mode 100644 index 802ca45b..00000000 --- a/rwf-ruby/src/Makefile +++ /dev/null @@ -1,2 +0,0 @@ -all: - gcc bindings.c main.c -o test_bindings -lruby -I/usr/include/ruby-3.3.0/ -I/usr/include/ruby-3.3.0/x86_64-linux -g diff --git a/rwf-ruby/src/bindings.c b/rwf-ruby/src/bindings.c index d4fc5ad6..147e2e54 100644 --- a/rwf-ruby/src/bindings.c +++ b/rwf-ruby/src/bindings.c @@ -187,6 +187,9 @@ RackResponse rwf_rack_response_new(VALUE value) { return response; } +/* + * Convert bytes to a StringIO wrapped into a Rack InputWrapper expected by Rails. +*/ static VALUE rwf_request_body(const char *body) { VALUE rb_str = rb_str_new_cstr(body); VALUE str_io = rwf_get_class("StringIO"); @@ -263,6 +266,11 @@ void rwf_clear_error_state() { rb_set_errinfo(Qnil); } +/* + * Print and clear an exception. + * Used for debugging. We don't really expect Rack to throw an exception; that would + * mean there is a bug in Rails exception handling. +*/ int rwf_print_error() { VALUE error = rb_errinfo(); diff --git a/rwf-ruby/src/lib.rs b/rwf-ruby/src/lib.rs index dc9364fe..2e555740 100644 --- a/rwf-ruby/src/lib.rs +++ b/rwf-ruby/src/lib.rs @@ -1,3 +1,4 @@ +//! Rust wrapper over the C bindings to Ruby. use libc::uintptr_t; use once_cell::sync::OnceCell; use std::ffi::{c_char, c_int, CStr, CString}; @@ -11,17 +12,35 @@ use tracing::info; // Make sure the Ruby VM is initialized only once. static RUBY_INIT: OnceCell = OnceCell::new(); +/// Response generated by a Rack application. +/// +/// The `VALUE` returned by Ruby is kept to ensure +/// the garbage collector doesn't run while we're processing this response. #[repr(C)] #[derive(Debug, Clone)] pub struct RackResponse { + /// Ruby object reference. pub value: uintptr_t, + + /// Response code, e.g. `200`. pub code: c_int, + + /// Number of HTTP headers in the response. pub num_headers: c_int, + + /// Header key/value pairs. pub headers: *mut KeyValue, + + /// Response body as bytes. pub body: *mut c_char, + + /// 1 if this is a file, 0 if its bytes. pub is_file: c_int, } +/// Header key/value pair. +/// +/// Memory is allocated and de-allocated in C. Rust is just borrowing it. #[repr(C)] #[derive(Debug)] pub struct KeyValue { @@ -29,17 +48,24 @@ pub struct KeyValue { value: *const c_char, } +/// Rack request, converted from an Rwf request. #[repr(C)] #[derive(Debug)] pub struct RackRequest { + // ENV object. env: *const KeyValue, + // Number of entries in ENV. length: c_int, + // Request body as bytes. body: *const c_char, } impl RackRequest { + /// Send a request to Rack and get a response. + /// + /// `env` must follow the Rack spec and contain HTTP headers, and other request metadata. + /// `body` contains the request body as bytes. pub fn send(env: HashMap, body: &[u8]) -> Result { - // let mut c_strings = vec![]; let mut keys = vec![]; let (mut k, mut v) = (vec![], vec![]); @@ -82,6 +108,9 @@ impl RackRequest { } /// RackResponse with values allocated in Rust memory space. +/// +/// Upon receiving a response from Rack, we copy data into Rust +/// and release C-allocated memory so the Ruby garbage collector can run. #[derive(Debug)] pub struct RackResponseOwned { code: u16, @@ -208,6 +237,7 @@ extern "C" { ) -> c_int; } +/// Errors returned from Ruby. #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Ruby VM did not start")] @@ -220,11 +250,13 @@ pub enum Error { App, } +/// Wrapper around Ruby's `VALUE`. #[derive(Debug)] pub struct Value { ptr: uintptr_t, } +/// Ruby object data types. #[derive(Debug, PartialEq)] #[repr(C)] pub enum Type { @@ -262,6 +294,8 @@ pub enum Type { } impl Value { + /// Convert `VALUE` to a Rust String. If `VALUE` is not a string, + /// an empty string is returned. pub fn to_string(&self) -> String { if self.ty() == Type::RString { unsafe { @@ -273,6 +307,8 @@ impl Value { } } + /// Get `VALUE` data type. + /// TODO: this function isn't fully implemented. pub fn ty(&self) -> Type { let ty = unsafe { rwf_rb_type(self.ptr) }; match ty { @@ -281,6 +317,7 @@ impl Value { } } + /// Get the raw `VALUE` pointer. pub fn raw_ptr(&self) -> uintptr_t { self.ptr } @@ -292,9 +329,13 @@ impl From for Value { } } +/// Wrapper around the Ruby VM. pub struct Ruby; impl Ruby { + /// Initialize the Ruby VM. + /// + /// Safe to call multiple times. The VM is initialized only once. pub fn init() -> Result<(), Error> { RUBY_INIT.get_or_try_init(move || Ruby::new())?; @@ -353,12 +394,14 @@ impl Ruby { } } + /// Disable the garbage collector. pub fn gc_disable() { unsafe { rb_gc_disable(); } } + /// Enable the garbage collector. pub fn gc_enable() { unsafe { rb_gc_enable(); @@ -377,6 +420,7 @@ impl Drop for Ruby { #[cfg(test)] mod test { use super::*; + use std::env::var; #[test] fn test_rack_response() { @@ -399,6 +443,13 @@ mod test { #[test] fn test_load_rails() { + #[cfg(target_os = "linux")] + if var("GEM_HOME").is_err() { + panic!( + "GEM_HOME isn't set. This test will most likely fail to load Ruby deps and crash." + ); + } + Ruby::load_app(&Path::new("tests/todo/config/environment.rb")).unwrap(); let response = Ruby::eval("Rails.application.call({})").unwrap(); let response = RackResponse::new(&response); diff --git a/rwf-ruby/src/main.c b/rwf-ruby/src/main.c deleted file mode 100644 index 0e5eae1d..00000000 --- a/rwf-ruby/src/main.c +++ /dev/null @@ -1,12 +0,0 @@ -#include "bindings.h" -#include - -int main() { - int state; - - rwf_init_ruby(); - rwf_load_app("/home/lev/code/rwf/rwf-ruby/tests/todo/config/environment.rb"); - - VALUE response = rb_eval_string_protect("Rails.application.call({})", &state); - RackResponse res = rwf_rack_response_new(response); -}