tonic-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ //! GVL bridge: releasing the GVL while blocked, re-acquiring it to run
2
+ //! Ruby handlers, and the Ruby-visible stream objects.
3
+ //!
4
+ //! Every blocking wait goes through [`without_gvl`], which uses
5
+ //! `rb_nogvl(..., RB_NOGVL_INTR_FAIL)`. Unlike `rb_thread_call_without_gvl`
6
+ //! this never raises (longjmps) through Rust frames: if the thread is
7
+ //! interrupted we return [`Wait::Interrupted`], the caller runs
8
+ //! `thread_check_ints` (which may run a trap handler or raise a proper
9
+ //! Ruby exception through Magnus), and then retries.
10
+
11
+ use std::ffi::c_void;
12
+ use std::future::Future;
13
+ use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
14
+ use std::sync::Arc;
15
+ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
+ use std::task::{Context, Poll, Wake, Waker};
17
+
18
+ use magnus::{Error, RString, Ruby, Value, prelude::*, typed_data::DataTypeFunctions, value::Opaque};
19
+ use parking_lot::Mutex;
20
+ use rb_sys::{RB_NOGVL_INTR_FAIL, rb_nogvl, rb_thread_call_with_gvl};
21
+ use tokio::sync::{Notify, mpsc};
22
+
23
+ // ─── Blocking primitives ──────────────────────────────────────────
24
+
25
+ /// Cancellation token tripped by Ruby's unblocking function when the
26
+ /// waiting thread is interrupted (signal, `Thread#raise`, `Thread#kill`).
27
+ #[derive(Default)]
28
+ pub struct Cancel {
29
+ flag: AtomicBool,
30
+ notify: Notify,
31
+ }
32
+
33
+ impl Cancel {
34
+ pub fn cancel(&self) {
35
+ self.flag.store(true, Ordering::SeqCst);
36
+ // notify_one stores a permit if nobody is waiting yet, so a
37
+ // cancel that races ahead of `cancelled()` is never lost.
38
+ self.notify.notify_one();
39
+ }
40
+
41
+ pub fn is_cancelled(&self) -> bool {
42
+ self.flag.load(Ordering::SeqCst)
43
+ }
44
+
45
+ pub async fn cancelled(&self) {
46
+ while !self.is_cancelled() {
47
+ self.notify.notified().await;
48
+ }
49
+ }
50
+ }
51
+
52
+ unsafe extern "C" fn cancel_ubf(data: *mut c_void) {
53
+ let cancel = unsafe { &*(data as *const Cancel) };
54
+ cancel.cancel();
55
+ }
56
+
57
+ /// Run `f` with the GVL released. Returns `None` without running `f` if
58
+ /// an interrupt was already pending. `f` must return promptly once
59
+ /// `cancel` is tripped.
60
+ pub fn without_gvl<F, R>(cancel: &Cancel, f: F) -> Option<R>
61
+ where
62
+ F: FnOnce() -> R,
63
+ {
64
+ struct Call<F, R> {
65
+ f: Option<F>,
66
+ result: Option<std::thread::Result<R>>,
67
+ }
68
+
69
+ unsafe extern "C" fn trampoline<F: FnOnce() -> R, R>(data: *mut c_void) -> *mut c_void {
70
+ let call = unsafe { &mut *(data as *mut Call<F, R>) };
71
+ if let Some(f) = call.f.take() {
72
+ call.result = Some(catch_unwind(AssertUnwindSafe(f)));
73
+ }
74
+ std::ptr::null_mut()
75
+ }
76
+
77
+ let mut call = Call {
78
+ f: Some(f),
79
+ result: None,
80
+ };
81
+ unsafe {
82
+ rb_nogvl(
83
+ Some(trampoline::<F, R>),
84
+ &mut call as *mut Call<F, R> as *mut c_void,
85
+ Some(cancel_ubf),
86
+ cancel as *const Cancel as *mut c_void,
87
+ RB_NOGVL_INTR_FAIL as i32,
88
+ );
89
+ }
90
+ match call.result {
91
+ Some(Ok(value)) => Some(value),
92
+ Some(Err(panic)) => resume_unwind(panic),
93
+ None => None,
94
+ }
95
+ }
96
+
97
+ /// Minimal single-future executor that parks the current (non-tokio)
98
+ /// thread. tokio's channel and `Notify` futures are runtime-agnostic.
99
+ pub fn block_on<F: Future>(fut: F) -> F::Output {
100
+ struct ThreadWaker(std::thread::Thread);
101
+ impl Wake for ThreadWaker {
102
+ fn wake(self: Arc<Self>) {
103
+ self.0.unpark();
104
+ }
105
+ fn wake_by_ref(self: &Arc<Self>) {
106
+ self.0.unpark();
107
+ }
108
+ }
109
+
110
+ let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
111
+ let mut cx = Context::from_waker(&waker);
112
+ let mut fut = std::pin::pin!(fut);
113
+ loop {
114
+ if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
115
+ return value;
116
+ }
117
+ std::thread::park();
118
+ }
119
+ }
120
+
121
+ pub enum Wait<T> {
122
+ Ready(T),
123
+ Closed,
124
+ Interrupted,
125
+ }
126
+
127
+ /// Receive from a tokio channel, releasing the GVL only if we must block.
128
+ pub fn recv_blocking<T>(rx: &mut mpsc::Receiver<T>) -> Wait<T> {
129
+ match rx.try_recv() {
130
+ Ok(value) => return Wait::Ready(value),
131
+ Err(mpsc::error::TryRecvError::Disconnected) => return Wait::Closed,
132
+ Err(mpsc::error::TryRecvError::Empty) => {}
133
+ }
134
+ let cancel = Cancel::default();
135
+ let received = without_gvl(&cancel, || {
136
+ block_on(async {
137
+ tokio::select! {
138
+ biased;
139
+ value = rx.recv() => Some(value),
140
+ _ = cancel.cancelled() => None,
141
+ }
142
+ })
143
+ });
144
+ match received {
145
+ Some(Some(Some(value))) => Wait::Ready(value),
146
+ Some(Some(None)) => Wait::Closed,
147
+ _ => Wait::Interrupted,
148
+ }
149
+ }
150
+
151
+ /// Send into a tokio channel, releasing the GVL only if we must block
152
+ /// (channel full — the client is consuming slowly). On `Interrupted`
153
+ /// the message is handed back for a retry.
154
+ pub fn send_blocking<T>(tx: &mpsc::Sender<T>, msg: T) -> Result<(), Option<T>> {
155
+ let msg = match tx.try_send(msg) {
156
+ Ok(()) => return Ok(()),
157
+ Err(mpsc::error::TrySendError::Closed(_)) => return Err(None),
158
+ Err(mpsc::error::TrySendError::Full(msg)) => msg,
159
+ };
160
+ let cancel = Cancel::default();
161
+ let mut pending = Some(msg);
162
+ let sent = without_gvl(&cancel, || {
163
+ block_on(async {
164
+ let permit = tokio::select! {
165
+ biased;
166
+ permit = tx.reserve() => permit.ok(),
167
+ _ = cancel.cancelled() => return None,
168
+ };
169
+ Some(permit.map(|p| p.send(pending.take().expect("message present"))))
170
+ })
171
+ });
172
+ match sent {
173
+ Some(Some(Some(()))) => Ok(()),
174
+ Some(Some(None)) => Err(None),
175
+ _ => Err(pending),
176
+ }
177
+ }
178
+
179
+ /// `send_blocking`, retrying after Ruby has processed any interrupt.
180
+ /// Returns `Ok(false)` if the receiver is gone.
181
+ pub fn send_with_interrupts<T>(ruby: &Ruby, tx: &mpsc::Sender<T>, mut msg: T) -> Result<bool, Error> {
182
+ loop {
183
+ match send_blocking(tx, msg) {
184
+ Ok(()) => return Ok(true),
185
+ Err(None) => return Ok(false),
186
+ Err(Some(back)) => {
187
+ ruby.thread_check_ints()?;
188
+ msg = back;
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ // ─── Response stream ──────────────────────────────────────────────
195
+
196
+ /// Messages from the Ruby handler to the tokio response body.
197
+ pub enum StreamMessage {
198
+ /// One encoded protobuf response message.
199
+ Data(Vec<u8>),
200
+ /// End of stream: `None` = OK, `Some((code, message))` = error.
201
+ End(Option<(u32, String)>),
202
+ }
203
+
204
+ /// `TonicRails::Native::StreamSender` — pushes encoded response messages
205
+ /// to the client with backpressure.
206
+ #[derive(magnus::TypedData)]
207
+ #[magnus(class = "TonicRails::Native::StreamSender", free_immediately)]
208
+ pub struct StreamSender {
209
+ tx: mpsc::Sender<StreamMessage>,
210
+ }
211
+
212
+ impl StreamSender {
213
+ /// Raises `IOError` if the client has gone away.
214
+ pub fn push(ruby: &Ruby, this: &Self, data: RString) -> Result<(), Error> {
215
+ let bytes = unsafe { data.as_slice() }.to_vec();
216
+ if send_with_interrupts(ruby, &this.tx, StreamMessage::Data(bytes))? {
217
+ Ok(())
218
+ } else {
219
+ Err(Error::new(
220
+ ruby.exception_io_error(),
221
+ "response stream closed by peer",
222
+ ))
223
+ }
224
+ }
225
+ }
226
+
227
+ impl DataTypeFunctions for StreamSender {}
228
+
229
+ // ─── Request stream ───────────────────────────────────────────────
230
+
231
+ /// `TonicRails::Native::RequestReader` — yields raw request messages.
232
+ #[derive(magnus::TypedData)]
233
+ #[magnus(class = "TonicRails::Native::RequestReader", free_immediately)]
234
+ pub struct RequestReader {
235
+ /// Shared with the dispatcher, which closes it once the handler
236
+ /// returns so the body-reading task never outlives the call.
237
+ rx: Arc<Mutex<Option<mpsc::Receiver<Vec<u8>>>>>,
238
+ error: Arc<RequestError>,
239
+ }
240
+
241
+ /// Set by the body-reading task when the request stream is invalid, so
242
+ /// the reader can tell an error-closed channel from a clean end of stream.
243
+ #[derive(Default)]
244
+ pub struct RequestError {
245
+ code: AtomicU32,
246
+ message: Mutex<&'static str>,
247
+ }
248
+
249
+ impl RequestError {
250
+ pub fn set(&self, code: tonic::Code, message: &'static str) {
251
+ *self.message.lock() = message;
252
+ self.code.store(code as u32, Ordering::Release);
253
+ }
254
+
255
+ fn get(&self) -> Option<(u32, &'static str)> {
256
+ match self.code.load(Ordering::Acquire) {
257
+ 0 => None,
258
+ code => Some((code, *self.message.lock())),
259
+ }
260
+ }
261
+ }
262
+
263
+ impl RequestReader {
264
+ fn new(rx: Arc<Mutex<Option<mpsc::Receiver<Vec<u8>>>>>, error: Arc<RequestError>) -> Self {
265
+ Self { rx, error }
266
+ }
267
+
268
+ /// Next raw message, or nil at end of stream. Check `#error` after nil.
269
+ pub fn next(ruby: &Ruby, this: &Self) -> Result<Option<RString>, Error> {
270
+ // Taken out of the mutex while blocked so a concurrent caller
271
+ // (misuse) sees end-of-stream rather than deadlocking.
272
+ let Some(mut rx) = this.rx.lock().take() else {
273
+ return Ok(None);
274
+ };
275
+ loop {
276
+ match recv_blocking(&mut rx) {
277
+ Wait::Ready(bytes) => {
278
+ this.rx.lock().replace(rx);
279
+ return Ok(Some(ruby.str_from_slice(&bytes)));
280
+ }
281
+ Wait::Closed => return Ok(None),
282
+ Wait::Interrupted => {
283
+ if let Err(e) = ruby.thread_check_ints() {
284
+ this.rx.lock().replace(rx);
285
+ return Err(e);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ /// `[code, message]` if the request stream ended with an error.
293
+ pub fn error(&self) -> Option<(u32, &'static str)> {
294
+ self.error.get()
295
+ }
296
+ }
297
+
298
+ impl DataTypeFunctions for RequestReader {}
299
+
300
+ // ─── Dispatch into Ruby ───────────────────────────────────────────
301
+
302
+ /// One RPC handed from tokio to a Ruby dispatch thread.
303
+ pub struct DispatchContext {
304
+ pub callback: Opaque<Value>,
305
+ pub path: String,
306
+ pub metadata: Vec<(String, String)>,
307
+ pub timeout_ms: Option<u128>,
308
+ pub response_tx: mpsc::Sender<StreamMessage>,
309
+ pub request_rx: mpsc::Receiver<Vec<u8>>,
310
+ pub request_error: Arc<RequestError>,
311
+ }
312
+
313
+ /// Bounds on how long one thread keeps the GVL to drain a busy queue.
314
+ /// The time bound keeps CPU-heavy handlers from hurting tail latency:
315
+ /// only cheap requests get batched.
316
+ const MAX_BATCH: usize = 16;
317
+ const MAX_BATCH_TIME: std::time::Duration = std::time::Duration::from_micros(500);
318
+
319
+ /// Re-acquire the GVL and run the Ruby handler for `first`, then keep
320
+ /// the GVL to run requests already waiting in `queue` (within
321
+ /// `MAX_BATCH` / `MAX_BATCH_TIME`). Each GVL handoff costs a thread wake-up, so draining a
322
+ /// busy queue under one acquisition raises throughput. Handlers that
323
+ /// block on I/O still release the GVL internally, so this does not
324
+ /// serialise I/O-bound work. Called from a thread that has released
325
+ /// the GVL.
326
+ pub fn dispatch(first: Box<DispatchContext>, queue: &crossbeam_channel::Receiver<Box<DispatchContext>>) {
327
+ struct Batch<'a> {
328
+ first: Option<Box<DispatchContext>>,
329
+ queue: &'a crossbeam_channel::Receiver<Box<DispatchContext>>,
330
+ }
331
+
332
+ unsafe extern "C" fn with_gvl(data: *mut c_void) -> *mut c_void {
333
+ let batch = unsafe { &mut *(data as *mut Batch<'_>) };
334
+ let start = std::time::Instant::now();
335
+ let mut next = batch.first.take();
336
+ let mut handled = 0;
337
+ while let Some(ctx) = next {
338
+ run_guarded(*ctx);
339
+ handled += 1;
340
+ next = if handled < MAX_BATCH && start.elapsed() < MAX_BATCH_TIME {
341
+ batch.queue.try_recv().ok()
342
+ } else {
343
+ None
344
+ };
345
+ }
346
+ std::ptr::null_mut()
347
+ }
348
+
349
+ let mut batch = Batch {
350
+ first: Some(first),
351
+ queue,
352
+ };
353
+ unsafe {
354
+ rb_thread_call_with_gvl(Some(with_gvl), &mut batch as *mut Batch<'_> as *mut c_void);
355
+ }
356
+ }
357
+
358
+ fn run_guarded(ctx: DispatchContext) {
359
+ let response_tx = ctx.response_tx.clone();
360
+ if let Err(panic) = catch_unwind(AssertUnwindSafe(|| run_handler(ctx))) {
361
+ let msg = panic
362
+ .downcast_ref::<String>()
363
+ .map(String::as_str)
364
+ .or_else(|| panic.downcast_ref::<&str>().copied())
365
+ .unwrap_or("unknown panic");
366
+ eprintln!("tonic-rails: panic in dispatch: {msg}");
367
+ let _ = response_tx.try_send(StreamMessage::End(Some((
368
+ tonic::Code::Internal as u32,
369
+ "internal error".into(),
370
+ ))));
371
+ }
372
+ }
373
+
374
+ fn run_handler(ctx: DispatchContext) {
375
+ let ruby = Ruby::get().expect("dispatch runs on a Ruby thread with the GVL");
376
+ let DispatchContext {
377
+ callback,
378
+ path,
379
+ metadata,
380
+ timeout_ms,
381
+ response_tx,
382
+ request_rx,
383
+ request_error,
384
+ } = ctx;
385
+
386
+ let end = match call_handler(
387
+ &ruby,
388
+ callback,
389
+ &path,
390
+ metadata,
391
+ timeout_ms,
392
+ &response_tx,
393
+ request_rx,
394
+ request_error,
395
+ ) {
396
+ Ok(()) => StreamMessage::End(None),
397
+ Err(e) => StreamMessage::End(Some(error_status(&e))),
398
+ };
399
+ // Ignore failures: the client may already be gone.
400
+ let _ = send_with_interrupts(&ruby, &response_tx, end);
401
+ }
402
+
403
+ #[allow(clippy::too_many_arguments)]
404
+ fn call_handler(
405
+ ruby: &Ruby,
406
+ callback: Opaque<Value>,
407
+ path: &str,
408
+ metadata: Vec<(String, String)>,
409
+ timeout_ms: Option<u128>,
410
+ response_tx: &mpsc::Sender<StreamMessage>,
411
+ request_rx: mpsc::Receiver<Vec<u8>>,
412
+ request_error: Arc<RequestError>,
413
+ ) -> Result<(), Error> {
414
+ let md = ruby.hash_new_capa(metadata.len());
415
+ for (k, v) in metadata {
416
+ md.aset(ruby.str_new(&k), ruby.str_new(&v))?;
417
+ }
418
+ let request_rx = Arc::new(Mutex::new(Some(request_rx)));
419
+ let call = ruby.hash_new_capa(3);
420
+ call.aset(
421
+ ruby.to_symbol("reader"),
422
+ RequestReader::new(request_rx.clone(), request_error),
423
+ )?;
424
+ call.aset(
425
+ ruby.to_symbol("sender"),
426
+ StreamSender {
427
+ tx: response_tx.clone(),
428
+ },
429
+ )?;
430
+ if let Some(ms) = timeout_ms {
431
+ call.aset(ruby.to_symbol("timeout_ms"), ms as u64)?;
432
+ }
433
+
434
+ let callback = ruby.get_inner(callback);
435
+ let result = callback.funcall::<_, _, Value>("call", (ruby.str_new(path), md, call));
436
+ // Unread request messages are no longer wanted: closing the channel
437
+ // ends the body-reading task now instead of whenever Ruby's GC frees
438
+ // the reader.
439
+ drop(request_rx.lock().take());
440
+ let result = result?;
441
+
442
+ // A String return is a unary response; anything else means the
443
+ // handler already streamed its messages through the sender.
444
+ if let Some(bytes) = RString::from_value(result) {
445
+ let data = unsafe { bytes.as_slice() }.to_vec();
446
+ send_with_interrupts(ruby, response_tx, StreamMessage::Data(data))?;
447
+ }
448
+ Ok(())
449
+ }
450
+
451
+ /// Map a Ruby exception to a gRPC status. Only exceptions carrying an
452
+ /// Integer `@code` (i.e. `Tonic::Rails::RpcError`) expose their message;
453
+ /// anything else becomes a generic INTERNAL so details never leak.
454
+ fn error_status(e: &Error) -> (u32, String) {
455
+ if let Some(exc) = e.value() {
456
+ let code = exc
457
+ .funcall::<_, _, Option<i64>>("instance_variable_get", ("@code",))
458
+ .ok()
459
+ .flatten();
460
+ if let Some(code) = code.filter(|c| (1..=16).contains(c)) {
461
+ let message = exc.funcall::<_, _, String>("message", ()).unwrap_or_default();
462
+ return (code as u32, message);
463
+ }
464
+ let description = exc
465
+ .funcall::<_, _, String>("inspect", ())
466
+ .unwrap_or_else(|_| "unknown exception".into());
467
+ eprintln!(
468
+ "tonic-rails: unhandled exception in dispatch: {}",
469
+ crate::framing::sanitize_for_log(&description)
470
+ );
471
+ }
472
+ (tonic::Code::Internal as u32, "internal error".into())
473
+ }
@@ -0,0 +1,90 @@
1
+ //! tonic-rails native extension.
2
+ //!
3
+ //! Serves gRPC with tonic and dispatches each RPC to a Ruby callback.
4
+ //!
5
+ //! ```text
6
+ //! tokio runtime (no GVL) Ruby dispatch threads
7
+ //! ────────────────────── ─────────────────────
8
+ //! tonic transport: HTTP/2, TLS, serve_worker (GVL released
9
+ //! keepalive, health, reflection while idle)
10
+ //! │ ▲
11
+ //! DispatchService (fallback route) │ crossbeam queue
12
+ //! validate → queue DispatchContext ───────────┘
13
+ //! spawn body reader ── request msgs ──▶ RequestReader#next
14
+ //! ResponseBody ◀────── response msgs ── StreamSender#push / return
15
+ //! ```
16
+ //!
17
+ //! Message bytes pass through untouched: Ruby's google-protobuf does all
18
+ //! (de)serialization, so no service definitions are compiled in here.
19
+ //!
20
+ //! Modules:
21
+ //! - [`framing`] — gRPC wire format (frames, gzip, timeouts, trailers)
22
+ //! - [`body`] — response bodies and request stats
23
+ //! - [`dispatch`] — the fallback tower service that queues RPCs for Ruby
24
+ //! - [`gvl`] — GVL release/acquire and the Ruby stream objects
25
+ //! - [`net`] — listener and connection cap
26
+ //! - [`server`] — `TonicRails::Native::Server`
27
+
28
+ mod body;
29
+ mod dispatch;
30
+ mod framing;
31
+ mod gvl;
32
+ mod net;
33
+ mod server;
34
+
35
+ use magnus::{Error, Ruby, function, method, prelude::*};
36
+
37
+ use gvl::{RequestReader, StreamSender};
38
+ use server::NativeServer;
39
+
40
+ #[magnus::init]
41
+ fn init(ruby: &Ruby) -> Result<(), Error> {
42
+ let native = ruby.define_module("TonicRails")?.define_module("Native")?;
43
+
44
+ let class = native.define_class("Server", ruby.class_object())?;
45
+ class.define_singleton_method("new", function!(NativeServer::new, 2))?;
46
+ class.define_method("on_request", method!(NativeServer::on_request, 1))?;
47
+ class.define_method("set_tls", method!(NativeServer::set_tls, 2))?;
48
+ class.define_method("set_tls_client_ca", method!(NativeServer::set_tls_client_ca, 1))?;
49
+ class.define_method(
50
+ "set_max_connections",
51
+ method!(NativeServer::set_max_connections, 1),
52
+ )?;
53
+ class.define_method(
54
+ "set_concurrency_limit_per_connection",
55
+ method!(NativeServer::set_concurrency_limit_per_connection, 1),
56
+ )?;
57
+ class.define_method("set_worker_threads", method!(NativeServer::set_worker_threads, 1))?;
58
+ class.define_method("set_keepalive", method!(NativeServer::set_keepalive, 2))?;
59
+ class.define_method(
60
+ "set_dispatch_queue_size",
61
+ method!(NativeServer::set_dispatch_queue_size, 1),
62
+ )?;
63
+ class.define_method("set_log_requests", method!(NativeServer::set_log_requests, 1))?;
64
+ class.define_method("set_compression", method!(NativeServer::set_compression, 1))?;
65
+ class.define_method("set_reflection", method!(NativeServer::set_reflection, 1))?;
66
+ class.define_method("set_routes", method!(NativeServer::set_routes, 1))?;
67
+ class.define_method(
68
+ "set_reflection_descriptors",
69
+ method!(NativeServer::set_reflection_descriptors, 1),
70
+ )?;
71
+ class.define_method("start_server", method!(NativeServer::start_server, 0))?;
72
+ class.define_method("serve_worker", method!(NativeServer::serve_worker, 0))?;
73
+ class.define_method("begin_shutdown", method!(NativeServer::begin_shutdown, 0))?;
74
+ class.define_method("wait_for_drain", method!(NativeServer::wait_for_drain, 1))?;
75
+ class.define_method("stop_workers", method!(NativeServer::stop_workers, 0))?;
76
+ class.define_method("stop_server", method!(NativeServer::stop_server, 0))?;
77
+ class.define_method("running?", method!(NativeServer::is_running, 0))?;
78
+ class.define_method("stats", method!(NativeServer::stats, 0))?;
79
+
80
+ let sender = native.define_class("StreamSender", ruby.class_object())?;
81
+ sender.undef_default_alloc_func();
82
+ sender.define_method("push", method!(StreamSender::push, 1))?;
83
+
84
+ let reader = native.define_class("RequestReader", ruby.class_object())?;
85
+ reader.undef_default_alloc_func();
86
+ reader.define_method("next", method!(RequestReader::next, 0))?;
87
+ reader.define_method("error", method!(RequestReader::error, 0))?;
88
+
89
+ Ok(())
90
+ }