omq-backend-rust 0.1.7 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b22c5a39d466ddff0ea811bfadff93fe58e64c3ea08bb213f61419e5efd13cc6
4
- data.tar.gz: '09ef0d2306516c3809a957f61e234360d9f2bbe34b0389cf874c8d5cdc87abc8'
3
+ metadata.gz: ec3e72f8903532375903dac4637b6f4ebc1c9b09a0f2d1d1475420797464e800
4
+ data.tar.gz: d4cb76d8140f6f10d3b4eda7f9f720a7901407dcf0ea45da3e7a48989a137ca4
5
5
  SHA512:
6
- metadata.gz: bae7c0181f01e714d3fbbb11546bb84dc6642d77661734e5d5eaa5467af153a5437b0a9af1ff0d1b8f894609928d7952a83bcb492ef299e327253acdc6a1f85d
7
- data.tar.gz: b787de3962b49bf2c784596f8fd82901e0323e74270915ba051359eb73a46022389726cea92b1a6364c7780eaf296a457d8355d90507503a5690f4de4de8410b
6
+ metadata.gz: 5921c0fcc3fd18908cbc142ae481aa4e61d8005ab68f2ebc61fd316f65089f8d6453acddb76e073a2c06e0a47581900878a47d5c0dc2b7899bb2bd815a500fec
7
+ data.tar.gz: 7401afef781cc1000b175dd1e28f89241e2002abfc0a415d8bedd1d5a341398dce599574a6ced626f17a8c94ab3add861699f254e4f32a2d8f439322add19272
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-08-08
6
+
7
+ ### Added
8
+
9
+ - Added TruffleRuby support for the Rust backend.
10
+ - Added a shared fallback file descriptor watcher for Rubies without a
11
+ native `Fiber.scheduler`.
12
+ - Added TruffleRuby CI coverage for the Rust backend.
13
+
14
+ ### Changed
15
+
16
+ - Updated to `omq-tokio` 0.21.1 and `omq-proto` 0.25.1.
17
+ - Replaced Magnus with a small `rb-sys` helper layer that uses public
18
+ Ruby C API calls.
19
+
20
+ ### Fixed
21
+
22
+ - Raised Ruby exceptions through public C API entry points for
23
+ TruffleRuby compatibility.
24
+ - Rejected invalid negative numeric options before they can overflow Rust
25
+ sizes or panic on invalid durations.
26
+
5
27
  ## [0.1.7] - 2026-08-02
6
28
 
7
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "omq_backend_rust"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  license = "ISC"
6
6
  authors = ["Patrik Wenger <paddor@gmail.com>"]
@@ -18,15 +18,14 @@ lz4 = ["omq-tokio/lz4"]
18
18
  zstd = ["omq-tokio/zstd"]
19
19
 
20
20
  [dependencies]
21
- omq-proto = { version = "=0.25.0", default-features = false }
22
- omq-tokio = { version = "=0.21.0", default-features = false }
21
+ omq-proto = { version = "=0.25.1", default-features = false }
22
+ omq-tokio = { version = "=0.21.1", default-features = false }
23
23
  tokio = { version = "1.52.0", features = ["rt", "rt-multi-thread", "time", "sync", "io-util", "net"] }
24
24
  yring = { version = "0.3.11", features = ["async"] }
25
25
 
26
26
  bytes = "1.12.0"
27
27
  flume = { version = "0.12", default-features = false, features = ["async"] }
28
28
  futures = { version = "0.3", default-features = false, features = ["std", "async-await"] }
29
- magnus = "0.8"
30
29
  rb-sys = "0.9"
31
30
  libc = "0.2"
32
31
 
@@ -0,0 +1,23 @@
1
+ use std::process::Command;
2
+
3
+ fn main() {
4
+ println!("cargo:rerun-if-env-changed=RUBY");
5
+ println!("cargo:rustc-check-cfg=cfg(ruby_engine, values(\"mri\", \"truffleruby\"))");
6
+
7
+ let ruby = std::env::var("RUBY").unwrap_or_else(|_| "ruby".to_string());
8
+ let output = Command::new(&ruby)
9
+ .arg("-rrbconfig")
10
+ .arg("-e")
11
+ .arg("print RbConfig::CONFIG.fetch('ruby_install_name')")
12
+ .output()
13
+ .unwrap_or_else(|err| panic!("failed to run {ruby}: {err}"));
14
+
15
+ if !output.status.success() {
16
+ panic!("failed to query Ruby engine with {ruby}");
17
+ }
18
+
19
+ match String::from_utf8_lossy(&output.stdout).trim() {
20
+ "truffleruby" => println!("cargo:rustc-cfg=ruby_engine=\"truffleruby\""),
21
+ _ => println!("cargo:rustc-cfg=ruby_engine=\"mri\""),
22
+ }
23
+ }
@@ -1,15 +1,16 @@
1
- use magnus::{Error, Ruby};
2
1
  use omq_proto::error::Error as OmqError;
3
2
 
4
- pub fn map_err(ruby: &Ruby, e: OmqError) -> Error {
3
+ use crate::rb::RubyErr;
4
+
5
+ pub fn map_err(e: OmqError) -> RubyErr {
5
6
  match e {
6
- OmqError::Closed => Error::new(ruby.exception_io_error(), "socket closed"),
7
- OmqError::Timeout => Error::new(ruby.exception_runtime_error(), "operation timed out"),
8
- OmqError::Unroutable => Error::new(ruby.exception_runtime_error(), "no route to peer"),
9
- OmqError::InvalidEndpoint(msg) => Error::new(ruby.exception_arg_error(), msg),
10
- OmqError::Protocol(msg) => Error::new(ruby.exception_runtime_error(), msg),
11
- OmqError::Io(e) => Error::new(ruby.exception_runtime_error(), e.to_string()),
12
- OmqError::HandshakeFailed(msg) => Error::new(ruby.exception_runtime_error(), msg),
13
- _ => Error::new(ruby.exception_runtime_error(), format!("{e}")),
7
+ OmqError::Closed => RubyErr::io("socket closed"),
8
+ OmqError::Timeout => RubyErr::runtime("operation timed out"),
9
+ OmqError::Unroutable => RubyErr::runtime("no route to peer"),
10
+ OmqError::InvalidEndpoint(msg) => RubyErr::arg(msg),
11
+ OmqError::Protocol(msg) => RubyErr::runtime(msg),
12
+ OmqError::Io(e) => RubyErr::runtime(e.to_string()),
13
+ OmqError::HandshakeFailed(msg) => RubyErr::runtime(msg),
14
+ _ => RubyErr::runtime(format!("{e}")),
14
15
  }
15
16
  }
@@ -1,26 +1,51 @@
1
1
  mod error;
2
2
  mod notify;
3
3
  mod options;
4
+ mod rb;
4
5
  mod runtime;
5
6
  mod socket;
6
7
 
7
- use magnus::{Error, Ruby, function, prelude::*};
8
+ use rb_sys::VALUE;
8
9
 
9
- fn set_io_threads(n: usize) {
10
+ use crate::rb::{RbResult, RubyErr};
11
+
12
+ fn set_io_threads_impl(n: VALUE) -> RbResult<VALUE> {
13
+ let n = rb::value_to_i64(n)?;
14
+ if n < 0 {
15
+ return Err(RubyErr::arg("io_threads must be non-negative"));
16
+ }
17
+ let n = usize::try_from(n).map_err(|_| RubyErr::arg("io_threads too large"))?;
10
18
  socket::set_io_threads(n);
19
+ Ok(rb::qnil())
20
+ }
21
+
22
+ unsafe extern "C" fn set_io_threads(_module: VALUE, n: VALUE) -> VALUE {
23
+ rb::wrap(|| set_io_threads_impl(n))
24
+ }
25
+
26
+ #[unsafe(no_mangle)]
27
+ /// # Safety
28
+ ///
29
+ /// Ruby calls this once while loading the native extension.
30
+ pub unsafe extern "C" fn Init_omq_backend_rust() {
31
+ rb::wrap_init(init);
11
32
  }
12
33
 
13
- #[magnus::init]
14
- fn init(ruby: &Ruby) -> Result<(), Error> {
15
- unsafe { rb_sys::rb_ext_ractor_safe(true) };
34
+ fn init() -> RbResult<()> {
35
+ #[cfg(ruby_engine = "mri")]
36
+ unsafe {
37
+ rb_sys::rb_ext_ractor_safe(true);
38
+ }
16
39
 
17
- let omq = ruby.define_module("OMQ")?;
18
- let rust = omq.define_module("Rust")?;
19
- let native = rust.define_module("Native")?;
40
+ let omq = unsafe { rb::define_module(c"OMQ")? };
41
+ let rust = unsafe { rb::define_module_under(omq, c"Rust")? };
42
+ let native = unsafe { rb::define_module_under(rust, c"Native")? };
20
43
 
21
- native.define_module_function("io_threads=", function!(set_io_threads, 1))?;
44
+ unsafe {
45
+ rb::define_module_function_1(native, c"io_threads=", set_io_threads)?;
46
+ }
22
47
 
23
- socket::register(ruby)?;
48
+ socket::register(native)?;
24
49
 
25
50
  Ok(())
26
51
  }
@@ -1,129 +1,124 @@
1
1
  use std::time::Duration;
2
2
 
3
3
  use bytes::Bytes;
4
- use magnus::{Error, Ruby, TryConvert, r_hash::RHash, value::ReprValue};
5
4
 
6
- pub fn build_options(ruby: &Ruby, hash: RHash) -> Result<omq_tokio::Options, Error> {
5
+ use rb_sys::VALUE;
6
+
7
+ use crate::rb::{self, RbResult, RubyErr};
8
+
9
+ pub fn build_options(hash: VALUE) -> RbResult<omq_tokio::Options> {
10
+ rb::check_hash(hash)?;
11
+
7
12
  let mut opts = omq_tokio::Options::default();
8
13
 
9
- if let Some(v) = get_opt::<i64>(ruby, hash, "send_hwm")? {
14
+ if let Some(v) = get_opt_i64(hash, "send_hwm")? {
10
15
  opts.send_hwm = v.max(0) as u32;
11
16
  }
12
- if let Some(v) = get_opt::<i64>(ruby, hash, "recv_hwm")? {
17
+ if let Some(v) = get_opt_i64(hash, "recv_hwm")? {
13
18
  opts.recv_hwm = v.max(0) as u32;
14
19
  }
15
- if let Some(v) = get_opt::<f64>(ruby, hash, "linger")? {
16
- opts.linger = if v.is_infinite() {
20
+ if let Some(v) = get_opt_f64(hash, "linger")? {
21
+ opts.linger = if v.is_infinite() && v.is_sign_positive() {
17
22
  None
18
23
  } else {
19
- Some(Duration::from_secs_f64(v))
24
+ Some(duration_from_seconds("linger", v)?)
20
25
  };
21
26
  }
22
- if let Some(v) = get_opt_bytes(ruby, hash, "identity")? {
23
- if !v.is_empty() {
24
- opts.identity = Bytes::from(v);
25
- }
27
+ if let Some(v) = get_opt_bytes(hash, "identity")?
28
+ && !v.is_empty()
29
+ {
30
+ opts.identity = Bytes::from(v);
26
31
  }
27
- if let Some(v) = get_opt::<bool>(ruby, hash, "router_mandatory")? {
32
+ if let Some(v) = get_opt_bool(hash, "router_mandatory")? {
28
33
  opts.router_mandatory = v;
29
34
  }
30
- if let Some(v) = get_opt::<bool>(ruby, hash, "conflate")? {
35
+ if let Some(v) = get_opt_bool(hash, "conflate")? {
31
36
  opts.conflate = v;
32
37
  }
33
- if let Some(v) = get_opt_duration(ruby, hash, "heartbeat_interval")? {
38
+ if let Some(v) = get_opt_duration(hash, "heartbeat_interval")? {
34
39
  opts.heartbeat_interval = Some(v);
35
40
  }
36
- if let Some(v) = get_opt_duration(ruby, hash, "heartbeat_ttl")? {
41
+ if let Some(v) = get_opt_duration(hash, "heartbeat_ttl")? {
37
42
  opts.heartbeat_ttl = Some(v);
38
43
  }
39
- if let Some(v) = get_opt_duration(ruby, hash, "heartbeat_timeout")? {
44
+ if let Some(v) = get_opt_duration(hash, "heartbeat_timeout")? {
40
45
  opts.heartbeat_timeout = Some(v);
41
46
  }
42
- if let Some(v) = get_opt::<i64>(ruby, hash, "max_message_size")? {
43
- opts.max_message_size = Some(v as usize);
47
+ if let Some(v) = get_opt_usize(hash, "max_message_size")? {
48
+ opts.max_message_size = Some(v);
44
49
  }
45
- if let Some(v) = get_opt::<i64>(ruby, hash, "sndbuf")? {
46
- opts.send_buffer_size = Some(v as usize);
50
+ if let Some(v) = get_opt_usize(hash, "sndbuf")? {
51
+ opts.send_buffer_size = Some(v);
47
52
  }
48
- if let Some(v) = get_opt::<i64>(ruby, hash, "rcvbuf")? {
49
- opts.recv_buffer_size = Some(v as usize);
53
+ if let Some(v) = get_opt_usize(hash, "rcvbuf")? {
54
+ opts.recv_buffer_size = Some(v);
50
55
  }
51
- if let Some(v) = get_opt_bytes(ruby, hash, "compression_dict")? {
52
- if !v.is_empty() {
53
- opts.compression_dict = Some(Bytes::from(v));
54
- }
56
+ if let Some(v) = get_opt_bytes(hash, "compression_dict")?
57
+ && !v.is_empty()
58
+ {
59
+ opts.compression_dict = Some(Bytes::from(v));
55
60
  }
56
- if let Some(v) = get_opt::<bool>(ruby, hash, "compression_auto_train")? {
61
+ if let Some(v) = get_opt_bool(hash, "compression_auto_train")? {
57
62
  opts.compression_auto_train = v;
58
63
  }
59
- if let Some(v) = get_opt::<i64>(ruby, hash, "compression_threshold")? {
60
- opts.compression_threshold = Some(v as usize);
64
+ if let Some(v) = get_opt_usize(hash, "compression_threshold")? {
65
+ opts.compression_threshold = Some(v);
61
66
  }
62
- if let Some(v) = get_opt::<i64>(ruby, hash, "compression_level")? {
67
+ if let Some(v) = get_opt_i64(hash, "compression_level")? {
63
68
  opts.compression_level = Some(v as i32);
64
69
  }
65
- if let Some(v) = get_opt::<i64>(ruby, hash, "compression_dict_capacity")? {
66
- opts.compression_dict_capacity = Some(v as usize);
70
+ if let Some(v) = get_opt_usize(hash, "compression_dict_capacity")? {
71
+ opts.compression_dict_capacity = Some(v);
67
72
  }
68
- if let Some(v) = get_opt::<i64>(ruby, hash, "max_recv_dict_size")? {
69
- opts.max_recv_dict_size = Some(v as usize);
73
+ if let Some(v) = get_opt_usize(hash, "max_recv_dict_size")? {
74
+ opts.max_recv_dict_size = Some(v);
70
75
  }
71
- if let Some(v) = get_opt::<i64>(ruby, hash, "compression_offload_threshold")? {
76
+ if let Some(v) = get_opt_i64(hash, "compression_offload_threshold")? {
72
77
  opts.compression_offload_threshold = if v < 0 { None } else { Some(v as usize) };
73
78
  }
74
- if let Some(v) = get_opt::<String>(ruby, hash, "on_mute")? {
79
+ if let Some(v) = get_opt_string(hash, "on_mute")? {
75
80
  opts.on_mute = match v.as_str() {
76
81
  "drop_newest" | "drop" => omq_tokio::OnMute::DropNewest,
77
82
  _ => omq_tokio::OnMute::Block,
78
83
  };
79
84
  }
80
85
 
81
- if let Some(v) = get_opt::<f64>(ruby, hash, "reconnect_interval")? {
82
- opts.reconnect = omq_proto::options::ReconnectPolicy::Fixed(Duration::from_secs_f64(v));
83
- }
84
- if let Some(min) = get_opt::<f64>(ruby, hash, "reconnect_interval_min")? {
85
- let max = get_opt::<f64>(ruby, hash, "reconnect_interval_max")?.unwrap_or(min * 16.0);
86
+ if let Some(v) = get_opt_f64(hash, "reconnect_interval")? {
87
+ opts.reconnect = omq_proto::options::ReconnectPolicy::Fixed(duration_from_seconds(
88
+ "reconnect_interval",
89
+ v,
90
+ )?);
91
+ }
92
+ if let Some(min) = get_opt_f64(hash, "reconnect_interval_min")? {
93
+ let max = get_opt_f64(hash, "reconnect_interval_max")?.unwrap_or(min * 16.0);
86
94
  opts.reconnect = omq_proto::options::ReconnectPolicy::Exponential {
87
- min: Duration::from_secs_f64(min),
88
- max: Duration::from_secs_f64(max),
95
+ min: duration_from_seconds("reconnect_interval min", min)?,
96
+ max: duration_from_seconds("reconnect_interval max", max)?,
89
97
  };
90
98
  }
91
99
 
92
- if let Some(mech_type) = get_opt::<String>(ruby, hash, "mechanism_type")? {
93
- apply_mechanism(ruby, hash, &mech_type, &mut opts)?;
100
+ if let Some(mech_type) = get_opt_string(hash, "mechanism_type")? {
101
+ apply_mechanism(hash, &mech_type, &mut opts)?;
94
102
  }
95
103
 
96
104
  Ok(opts)
97
105
  }
98
106
 
99
- fn apply_mechanism(
100
- ruby: &Ruby,
101
- hash: RHash,
102
- mech_type: &str,
103
- opts: &mut omq_tokio::Options,
104
- ) -> Result<(), Error> {
107
+ fn apply_mechanism(hash: VALUE, mech_type: &str, opts: &mut omq_tokio::Options) -> RbResult<()> {
105
108
  match mech_type {
106
109
  "null" => {}
107
110
 
108
111
  #[cfg(feature = "curve")]
109
112
  "curve" => {
110
- let is_server = get_opt::<bool>(ruby, hash, "mechanism_server")?.unwrap_or(false);
111
- let pub_key = get_opt_bytes(ruby, hash, "mechanism_public_key")?;
112
- let sec_key = get_opt_bytes(ruby, hash, "mechanism_secret_key")?;
113
+ let is_server = get_opt_bool(hash, "mechanism_server")?.unwrap_or(false);
114
+ let pub_key = get_opt_bytes(hash, "mechanism_public_key")?;
115
+ let sec_key = get_opt_bytes(hash, "mechanism_secret_key")?;
113
116
 
114
117
  if is_server {
115
118
  if let (Some(pk), Some(sk)) = (pub_key, sec_key) {
116
119
  let keypair = omq_proto::CurveKeypair {
117
- public: omq_proto::CurvePublicKey::from_bytes(to_32(
118
- ruby,
119
- &pk,
120
- "public key",
121
- )?),
122
- secret: omq_proto::CurveSecretKey::from_bytes(to_32(
123
- ruby,
124
- &sk,
125
- "secret key",
126
- )?),
120
+ public: omq_proto::CurvePublicKey::from_bytes(to_32(&pk, "public key")?),
121
+ secret: omq_proto::CurveSecretKey::from_bytes(to_32(&sk, "secret key")?),
127
122
  };
128
123
  opts.mechanism = omq_proto::MechanismSetup::CurveServer {
129
124
  our_keypair: keypair,
@@ -131,24 +126,15 @@ fn apply_mechanism(
131
126
  };
132
127
  }
133
128
  } else {
134
- let srv_key = get_opt_bytes(ruby, hash, "mechanism_server_key")?;
129
+ let srv_key = get_opt_bytes(hash, "mechanism_server_key")?;
135
130
  if let (Some(pk), Some(sk), Some(svk)) = (pub_key, sec_key, srv_key) {
136
131
  let keypair = omq_proto::CurveKeypair {
137
- public: omq_proto::CurvePublicKey::from_bytes(to_32(
138
- ruby,
139
- &pk,
140
- "public key",
141
- )?),
142
- secret: omq_proto::CurveSecretKey::from_bytes(to_32(
143
- ruby,
144
- &sk,
145
- "secret key",
146
- )?),
132
+ public: omq_proto::CurvePublicKey::from_bytes(to_32(&pk, "public key")?),
133
+ secret: omq_proto::CurveSecretKey::from_bytes(to_32(&sk, "secret key")?),
147
134
  };
148
135
  opts.mechanism = omq_proto::MechanismSetup::CurveClient {
149
136
  our_keypair: keypair,
150
137
  server_public: omq_proto::CurvePublicKey::from_bytes(to_32(
151
- ruby,
152
138
  &svk,
153
139
  "server key",
154
140
  )?),
@@ -162,46 +148,73 @@ fn apply_mechanism(
162
148
  Ok(())
163
149
  }
164
150
 
165
- fn to_32(ruby: &Ruby, bytes: &[u8], label: &str) -> Result<[u8; 32], Error> {
151
+ fn to_32(bytes: &[u8], label: &str) -> RbResult<[u8; 32]> {
166
152
  bytes.try_into().map_err(|_| {
167
- Error::new(
168
- ruby.exception_arg_error(),
169
- format!("{label} must be exactly 32 bytes, got {}", bytes.len()),
170
- )
153
+ RubyErr::arg(format!(
154
+ "{label} must be exactly 32 bytes, got {}",
155
+ bytes.len()
156
+ ))
171
157
  })
172
158
  }
173
159
 
174
- fn get_opt_bytes(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<Vec<u8>>, Error> {
175
- let k = ruby.str_new(key);
176
- match hash.get(k) {
177
- Some(v) => {
178
- if v.is_nil() {
179
- return Ok(None);
180
- }
181
- let s = magnus::r_string::RString::try_convert(v)?;
182
- Ok(Some(unsafe { s.as_slice() }.to_vec()))
183
- }
160
+ fn get_opt_bytes(hash: VALUE, key: &str) -> RbResult<Option<Vec<u8>>> {
161
+ match rb::hash_get(hash, key)? {
162
+ Some(v) if v == rb::qnil() => Ok(None),
163
+ Some(v) => Ok(Some(rb::value_to_bytes(v)?)),
184
164
  None => Ok(None),
185
165
  }
186
166
  }
187
167
 
188
- fn get_opt<T: TryConvert>(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<T>, Error> {
189
- let k = ruby.str_new(key);
190
- match hash.get(k) {
191
- Some(v) => {
192
- if v.is_nil() {
193
- Ok(None)
194
- } else {
195
- Ok(Some(T::try_convert(v)?))
196
- }
197
- }
168
+ fn get_opt_string(hash: VALUE, key: &str) -> RbResult<Option<String>> {
169
+ match rb::hash_get(hash, key)? {
170
+ Some(v) if v == rb::qnil() => Ok(None),
171
+ Some(v) => Ok(Some(rb::value_to_string(v)?)),
172
+ None => Ok(None),
173
+ }
174
+ }
175
+
176
+ fn get_opt_i64(hash: VALUE, key: &str) -> RbResult<Option<i64>> {
177
+ match rb::hash_get(hash, key)? {
178
+ Some(v) if v == rb::qnil() => Ok(None),
179
+ Some(v) => Ok(Some(rb::value_to_i64(v)?)),
180
+ None => Ok(None),
181
+ }
182
+ }
183
+
184
+ fn get_opt_f64(hash: VALUE, key: &str) -> RbResult<Option<f64>> {
185
+ match rb::hash_get(hash, key)? {
186
+ Some(v) if v == rb::qnil() => Ok(None),
187
+ Some(v) => Ok(Some(rb::value_to_f64(v)?)),
188
+ None => Ok(None),
189
+ }
190
+ }
191
+
192
+ fn get_opt_usize(hash: VALUE, key: &str) -> RbResult<Option<usize>> {
193
+ let Some(v) = get_opt_i64(hash, key)? else {
194
+ return Ok(None);
195
+ };
196
+
197
+ usize::try_from(v)
198
+ .map(Some)
199
+ .map_err(|_| RubyErr::arg(format!("{key} must be non-negative")))
200
+ }
201
+
202
+ fn get_opt_bool(hash: VALUE, key: &str) -> RbResult<Option<bool>> {
203
+ match rb::hash_get(hash, key)? {
204
+ Some(v) if v == rb::qnil() => Ok(None),
205
+ Some(v) => Ok(Some(rb::value_to_bool(v)?)),
198
206
  None => Ok(None),
199
207
  }
200
208
  }
201
209
 
202
- fn get_opt_duration(ruby: &Ruby, hash: RHash, key: &str) -> Result<Option<Duration>, Error> {
203
- match get_opt::<f64>(ruby, hash, key)? {
204
- Some(v) => Ok(Some(Duration::from_secs_f64(v))),
210
+ fn get_opt_duration(hash: VALUE, key: &str) -> RbResult<Option<Duration>> {
211
+ match get_opt_f64(hash, key)? {
212
+ Some(v) => Ok(Some(duration_from_seconds(key, v)?)),
205
213
  None => Ok(None),
206
214
  }
207
215
  }
216
+
217
+ fn duration_from_seconds(label: &str, value: f64) -> RbResult<Duration> {
218
+ Duration::try_from_secs_f64(value)
219
+ .map_err(|_| RubyErr::arg(format!("{label} must be finite and non-negative")))
220
+ }