prosody 0.1.1
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 +7 -0
- data/.cargo/config.toml +2 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.standard.yml +9 -0
- data/.taplo.toml +6 -0
- data/ARCHITECTURE.md +591 -0
- data/CHANGELOG.md +92 -0
- data/Cargo.lock +3513 -0
- data/Cargo.toml +77 -0
- data/LICENSE +21 -0
- data/Makefile +36 -0
- data/README.md +946 -0
- data/Rakefile +26 -0
- data/ext/prosody/Cargo.toml +38 -0
- data/ext/prosody/extconf.rb +6 -0
- data/ext/prosody/src/admin.rs +171 -0
- data/ext/prosody/src/bridge/callback.rs +60 -0
- data/ext/prosody/src/bridge/mod.rs +332 -0
- data/ext/prosody/src/client/config.rs +819 -0
- data/ext/prosody/src/client/mod.rs +379 -0
- data/ext/prosody/src/gvl.rs +149 -0
- data/ext/prosody/src/handler/context.rs +436 -0
- data/ext/prosody/src/handler/message.rs +144 -0
- data/ext/prosody/src/handler/mod.rs +338 -0
- data/ext/prosody/src/handler/trigger.rs +93 -0
- data/ext/prosody/src/lib.rs +82 -0
- data/ext/prosody/src/logging.rs +353 -0
- data/ext/prosody/src/scheduler/cancellation.rs +67 -0
- data/ext/prosody/src/scheduler/handle.rs +50 -0
- data/ext/prosody/src/scheduler/mod.rs +169 -0
- data/ext/prosody/src/scheduler/processor.rs +166 -0
- data/ext/prosody/src/scheduler/result.rs +197 -0
- data/ext/prosody/src/tracing_util.rs +56 -0
- data/ext/prosody/src/util.rs +219 -0
- data/lib/prosody/configuration.rb +333 -0
- data/lib/prosody/handler.rb +177 -0
- data/lib/prosody/native_stubs.rb +417 -0
- data/lib/prosody/processor.rb +321 -0
- data/lib/prosody/sentry.rb +36 -0
- data/lib/prosody/version.rb +10 -0
- data/lib/prosody.rb +42 -0
- data/release-please-config.json +10 -0
- data/sig/configuration.rbs +252 -0
- data/sig/handler.rbs +79 -0
- data/sig/processor.rbs +100 -0
- data/sig/prosody.rbs +171 -0
- data/sig/version.rbs +9 -0
- metadata +193 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
//! # Client Module
|
|
2
|
+
//!
|
|
3
|
+
//! Provides the Ruby interface to the Prosody messaging system. This module
|
|
4
|
+
//! defines the `Client` class that allows Ruby applications to send messages
|
|
5
|
+
//! and process events from Kafka topics using the Prosody library.
|
|
6
|
+
//!
|
|
7
|
+
//! The client supports:
|
|
8
|
+
//! - Sending messages to Kafka topics
|
|
9
|
+
//! - Subscribing to topics with Ruby handler objects
|
|
10
|
+
//! - OpenTelemetry context propagation for distributed tracing
|
|
11
|
+
//! - Different operation modes (`Pipeline`, `LowLatency`, `BestEffort`)
|
|
12
|
+
|
|
13
|
+
use crate::bridge::Bridge;
|
|
14
|
+
use crate::client::config::NativeConfiguration;
|
|
15
|
+
use crate::handler::RubyHandler;
|
|
16
|
+
use crate::tracing_util::extract_opentelemetry_context;
|
|
17
|
+
use crate::util::ensure_runtime_context;
|
|
18
|
+
use crate::{BRIDGE, ROOT_MOD, id};
|
|
19
|
+
use magnus::value::ReprValue;
|
|
20
|
+
use magnus::{Error, Module, Object, RClass, Ruby, StaticSymbol, Value, function, method};
|
|
21
|
+
use opentelemetry::propagation::TextMapCompositePropagator;
|
|
22
|
+
use prosody::high_level::ConsumerBuilders;
|
|
23
|
+
use prosody::high_level::HighLevelClient;
|
|
24
|
+
use prosody::high_level::mode::Mode;
|
|
25
|
+
use prosody::high_level::state::ConsumerState;
|
|
26
|
+
use prosody::propagator::new_propagator;
|
|
27
|
+
use serde_magnus::deserialize;
|
|
28
|
+
use std::sync::Arc;
|
|
29
|
+
use tracing::{Span, debug, info_span};
|
|
30
|
+
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
|
31
|
+
|
|
32
|
+
/// Configuration types and conversion between Ruby and Rust representations
|
|
33
|
+
mod config;
|
|
34
|
+
|
|
35
|
+
/// A Ruby-compatible wrapper around the Prosody high-level client.
|
|
36
|
+
///
|
|
37
|
+
/// This struct bridges Ruby applications with the Prosody messaging system,
|
|
38
|
+
/// providing methods for sending messages to Kafka topics and subscribing to
|
|
39
|
+
/// events with Ruby handlers.
|
|
40
|
+
#[derive(Debug)]
|
|
41
|
+
#[magnus::wrap(class = "Prosody::Client")]
|
|
42
|
+
pub struct Client {
|
|
43
|
+
/// The underlying Prosody client
|
|
44
|
+
inner: Arc<HighLevelClient<RubyHandler>>,
|
|
45
|
+
/// Bridge for communicating between Rust and Ruby
|
|
46
|
+
bridge: Bridge,
|
|
47
|
+
/// OpenTelemetry propagator for distributed tracing
|
|
48
|
+
propagator: TextMapCompositePropagator,
|
|
49
|
+
/// PID at construction time, used to detect post-fork usage
|
|
50
|
+
pid: u32,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl Client {
|
|
54
|
+
/// Creates a new Prosody client with the given configuration.
|
|
55
|
+
///
|
|
56
|
+
/// # Arguments
|
|
57
|
+
///
|
|
58
|
+
/// * `ruby` - The Ruby VM context
|
|
59
|
+
/// * `config` - A Ruby Configuration object or hash containing client
|
|
60
|
+
/// configuration options
|
|
61
|
+
///
|
|
62
|
+
/// # Errors
|
|
63
|
+
///
|
|
64
|
+
/// Returns an error if:
|
|
65
|
+
/// - The OpenTelemetry API gem cannot be loaded
|
|
66
|
+
/// - The configuration mode is invalid
|
|
67
|
+
/// - The client cannot be initialized with the given configuration
|
|
68
|
+
/// - The bridge is not initialized
|
|
69
|
+
fn new(ruby: &Ruby, config: Value) -> Result<Self, Error> {
|
|
70
|
+
ruby.require("opentelemetry-api")?;
|
|
71
|
+
|
|
72
|
+
let _guard = ensure_runtime_context(ruby);
|
|
73
|
+
|
|
74
|
+
// Check if config is already a Configuration object, if not create one
|
|
75
|
+
let config_class: RClass = ruby
|
|
76
|
+
.get_inner(&ROOT_MOD)
|
|
77
|
+
.const_get(id!(ruby, "Configuration"))?;
|
|
78
|
+
let config_obj = if config.is_kind_of(config_class) {
|
|
79
|
+
config
|
|
80
|
+
} else {
|
|
81
|
+
config_class.funcall(id!(ruby, "new"), (config,))?
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let config_hash: Value = config_obj.funcall(id!(ruby, "to_hash"), ())?;
|
|
85
|
+
let native_config = NativeConfiguration::from_value(ruby, config_hash)?;
|
|
86
|
+
let config_ref = &native_config;
|
|
87
|
+
|
|
88
|
+
let mode: Mode = config_ref
|
|
89
|
+
.try_into()
|
|
90
|
+
.map_err(|error: String| Error::new(ruby.exception_arg_error(), error))?;
|
|
91
|
+
|
|
92
|
+
let consumer_builders: ConsumerBuilders = config_ref
|
|
93
|
+
.try_into()
|
|
94
|
+
.map_err(|error: String| Error::new(ruby.exception_arg_error(), error))?;
|
|
95
|
+
|
|
96
|
+
let client = HighLevelClient::new(
|
|
97
|
+
mode,
|
|
98
|
+
&mut config_ref.into(),
|
|
99
|
+
&consumer_builders,
|
|
100
|
+
&config_ref.into(),
|
|
101
|
+
)
|
|
102
|
+
.map_err(|error| Error::new(ruby.exception_runtime_error(), error.to_string()))?;
|
|
103
|
+
|
|
104
|
+
let bridge = BRIDGE
|
|
105
|
+
.get()
|
|
106
|
+
.ok_or(Error::new(
|
|
107
|
+
ruby.exception_runtime_error(),
|
|
108
|
+
"Bridge not initialized",
|
|
109
|
+
))?
|
|
110
|
+
.clone();
|
|
111
|
+
|
|
112
|
+
Ok(Self {
|
|
113
|
+
inner: Arc::new(client),
|
|
114
|
+
bridge: bridge.clone(),
|
|
115
|
+
propagator: new_propagator(),
|
|
116
|
+
pid: std::process::id(),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fn check_fork(ruby: &Ruby, this: &Self) -> Result<(), Error> {
|
|
121
|
+
if std::process::id() != this.pid {
|
|
122
|
+
return Err(Error::new(
|
|
123
|
+
ruby.exception_runtime_error(),
|
|
124
|
+
"Prosody::Client cannot be used after fork. Create a new client in the child \
|
|
125
|
+
process.",
|
|
126
|
+
));
|
|
127
|
+
}
|
|
128
|
+
Ok(())
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Returns the current state of the consumer.
|
|
132
|
+
///
|
|
133
|
+
/// The consumer can be in one of three states:
|
|
134
|
+
/// - `:unconfigured` - The consumer has not been configured yet
|
|
135
|
+
/// - `:configured` - The consumer is configured but not running
|
|
136
|
+
/// - `:running` - The consumer is actively consuming messages
|
|
137
|
+
///
|
|
138
|
+
/// # Arguments
|
|
139
|
+
///
|
|
140
|
+
/// * `ruby` - The Ruby VM context
|
|
141
|
+
/// * `this` - The client instance
|
|
142
|
+
///
|
|
143
|
+
/// # Returns
|
|
144
|
+
///
|
|
145
|
+
/// A Ruby symbol representing the current consumer state.
|
|
146
|
+
///
|
|
147
|
+
/// # Errors
|
|
148
|
+
///
|
|
149
|
+
/// Raises `RuntimeError` if the consumer configuration failed during
|
|
150
|
+
/// build, with the full error message from the underlying
|
|
151
|
+
/// `ModeConfigurationError`.
|
|
152
|
+
pub fn consumer_state(ruby: &Ruby, this: &Self) -> Result<StaticSymbol, Error> {
|
|
153
|
+
Self::check_fork(ruby, this)?;
|
|
154
|
+
let inner = this.inner.clone();
|
|
155
|
+
let state: Result<&'static str, String> = this.bridge.wait_for(
|
|
156
|
+
ruby,
|
|
157
|
+
async move {
|
|
158
|
+
let view = inner.consumer_state().await;
|
|
159
|
+
match &*view {
|
|
160
|
+
ConsumerState::Unconfigured => Ok("unconfigured"),
|
|
161
|
+
ConsumerState::ConfigurationFailed(err) => {
|
|
162
|
+
Err(format!("consumer configuration failed: {err:#}"))
|
|
163
|
+
}
|
|
164
|
+
ConsumerState::Configured(_) => Ok("configured"),
|
|
165
|
+
ConsumerState::Running { .. } => Ok("running"),
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
Span::current(),
|
|
169
|
+
)?;
|
|
170
|
+
|
|
171
|
+
let state = state.map_err(|msg| Error::new(ruby.exception_runtime_error(), msg))?;
|
|
172
|
+
|
|
173
|
+
Ok(ruby.sym_new(state))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Sends a message to the specified Kafka topic.
|
|
177
|
+
///
|
|
178
|
+
/// # Arguments
|
|
179
|
+
///
|
|
180
|
+
/// * `ruby` - The Ruby VM context
|
|
181
|
+
/// * `this` - The client instance
|
|
182
|
+
/// * `topic` - The destination topic name
|
|
183
|
+
/// * `key` - The message key for partitioning
|
|
184
|
+
/// * `payload` - The message payload (will be serialized)
|
|
185
|
+
///
|
|
186
|
+
/// # Errors
|
|
187
|
+
///
|
|
188
|
+
/// Returns an error if:
|
|
189
|
+
/// - The payload cannot be deserialized
|
|
190
|
+
/// - OpenTelemetry context extraction fails
|
|
191
|
+
/// - The message cannot be sent to Kafka
|
|
192
|
+
fn send(
|
|
193
|
+
ruby: &Ruby,
|
|
194
|
+
this: &Self,
|
|
195
|
+
topic: String,
|
|
196
|
+
key: String,
|
|
197
|
+
payload: Value,
|
|
198
|
+
) -> Result<(), Error> {
|
|
199
|
+
Self::check_fork(ruby, this)?;
|
|
200
|
+
let _guard = ensure_runtime_context(ruby);
|
|
201
|
+
let client = this.inner.clone();
|
|
202
|
+
let value = deserialize(ruby, payload)?;
|
|
203
|
+
let context = extract_opentelemetry_context(ruby, &this.propagator)?;
|
|
204
|
+
|
|
205
|
+
// Create span for tracing and set parent context
|
|
206
|
+
let span = info_span!("ruby-send", %topic, %key);
|
|
207
|
+
if let Err(err) = span.set_parent(context) {
|
|
208
|
+
debug!("failed to set parent span: {err:#}");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Wait for the async send operation to complete
|
|
212
|
+
this.bridge
|
|
213
|
+
.wait_for(
|
|
214
|
+
ruby,
|
|
215
|
+
async move { client.send(topic.as_str().into(), &key, &value).await },
|
|
216
|
+
span,
|
|
217
|
+
)?
|
|
218
|
+
.map_err(|error| Error::new(ruby.exception_runtime_error(), format!("{error:#}")))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Subscribes to events using the provided Ruby handler.
|
|
222
|
+
///
|
|
223
|
+
/// The handler must implement an `on_message(context, message)` method
|
|
224
|
+
/// that will be called for each received message.
|
|
225
|
+
///
|
|
226
|
+
/// # Arguments
|
|
227
|
+
///
|
|
228
|
+
/// * `ruby` - The Ruby VM context
|
|
229
|
+
/// * `this` - The client instance
|
|
230
|
+
/// * `handler` - A Ruby object that will handle incoming messages
|
|
231
|
+
///
|
|
232
|
+
/// # Errors
|
|
233
|
+
///
|
|
234
|
+
/// Returns an error if:
|
|
235
|
+
/// - The handler cannot be wrapped
|
|
236
|
+
/// - The client cannot subscribe with the handler
|
|
237
|
+
fn subscribe(ruby: &Ruby, this: &Self, handler: Value) -> Result<(), Error> {
|
|
238
|
+
Self::check_fork(ruby, this)?;
|
|
239
|
+
let _guard = ensure_runtime_context(ruby);
|
|
240
|
+
let wrapper = RubyHandler::new(this.bridge.clone(), ruby, handler)?;
|
|
241
|
+
let inner = this.inner.clone();
|
|
242
|
+
|
|
243
|
+
this.bridge
|
|
244
|
+
.wait_for(
|
|
245
|
+
ruby,
|
|
246
|
+
async move { inner.subscribe(wrapper).await },
|
|
247
|
+
Span::current(),
|
|
248
|
+
)?
|
|
249
|
+
.map_err(|error| Error::new(ruby.exception_runtime_error(), error.to_string()))?;
|
|
250
|
+
|
|
251
|
+
Ok(())
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Returns the number of Kafka partitions currently assigned to this
|
|
255
|
+
/// consumer.
|
|
256
|
+
///
|
|
257
|
+
/// This method can be used to monitor the consumer's workload and ensure
|
|
258
|
+
/// proper load balancing across multiple consumer instances.
|
|
259
|
+
///
|
|
260
|
+
/// # Arguments
|
|
261
|
+
///
|
|
262
|
+
/// * `self` - The client instance
|
|
263
|
+
///
|
|
264
|
+
/// # Returns
|
|
265
|
+
///
|
|
266
|
+
/// The number of assigned partitions as a u32.
|
|
267
|
+
pub fn assigned_partitions(ruby: &Ruby, this: &Self) -> Result<u32, Error> {
|
|
268
|
+
Self::check_fork(ruby, this)?;
|
|
269
|
+
let inner = this.inner.clone();
|
|
270
|
+
this.bridge.wait_for(
|
|
271
|
+
ruby,
|
|
272
|
+
async move { inner.assigned_partition_count().await },
|
|
273
|
+
Span::current(),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Checks if the consumer is stalled.
|
|
278
|
+
///
|
|
279
|
+
/// A stalled consumer is one that has stopped processing messages due to
|
|
280
|
+
/// errors or reaching processing limits. This can be used to detect
|
|
281
|
+
/// unhealthy consumers that need attention.
|
|
282
|
+
///
|
|
283
|
+
/// # Arguments
|
|
284
|
+
///
|
|
285
|
+
/// * `self` - The client instance
|
|
286
|
+
///
|
|
287
|
+
/// # Returns
|
|
288
|
+
///
|
|
289
|
+
/// `true` if the consumer is stalled, `false` otherwise.
|
|
290
|
+
pub fn is_stalled(ruby: &Ruby, this: &Self) -> Result<bool, Error> {
|
|
291
|
+
Self::check_fork(ruby, this)?;
|
|
292
|
+
let inner = this.inner.clone();
|
|
293
|
+
this.bridge.wait_for(
|
|
294
|
+
ruby,
|
|
295
|
+
async move { inner.is_stalled().await },
|
|
296
|
+
Span::current(),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// Unsubscribes from all topics, stopping message processing.
|
|
301
|
+
///
|
|
302
|
+
/// This method gracefully shuts down the consumer, completing any in-flight
|
|
303
|
+
/// messages before stopping.
|
|
304
|
+
///
|
|
305
|
+
/// # Arguments
|
|
306
|
+
///
|
|
307
|
+
/// * `ruby` - The Ruby VM context
|
|
308
|
+
/// * `this` - The client instance
|
|
309
|
+
///
|
|
310
|
+
/// # Errors
|
|
311
|
+
///
|
|
312
|
+
/// Returns an error if the unsubscribe operation fails.
|
|
313
|
+
fn unsubscribe(ruby: &Ruby, this: &Self) -> Result<(), Error> {
|
|
314
|
+
Self::check_fork(ruby, this)?;
|
|
315
|
+
let _guard = ensure_runtime_context(ruby);
|
|
316
|
+
let client = this.inner.clone();
|
|
317
|
+
|
|
318
|
+
this.bridge
|
|
319
|
+
.wait_for(
|
|
320
|
+
ruby,
|
|
321
|
+
async move { client.unsubscribe().await },
|
|
322
|
+
Span::current(),
|
|
323
|
+
)?
|
|
324
|
+
.map_err(|error| Error::new(ruby.exception_runtime_error(), format!("{error:#}")))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Returns the configured source system identifier.
|
|
328
|
+
///
|
|
329
|
+
/// The source system is used to identify the originating service or
|
|
330
|
+
/// component in produced messages, enabling loop detection.
|
|
331
|
+
///
|
|
332
|
+
/// # Arguments
|
|
333
|
+
///
|
|
334
|
+
/// * `this` - The client instance
|
|
335
|
+
///
|
|
336
|
+
/// # Returns
|
|
337
|
+
///
|
|
338
|
+
/// The source system identifier.
|
|
339
|
+
fn source_system(this: &Self) -> &str {
|
|
340
|
+
this.inner.source_system()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/// Initializes the client module in Ruby.
|
|
345
|
+
///
|
|
346
|
+
/// Defines the `Prosody::Client` class and its methods, making the client
|
|
347
|
+
/// functionality available to Ruby code.
|
|
348
|
+
///
|
|
349
|
+
/// # Arguments
|
|
350
|
+
///
|
|
351
|
+
/// * `ruby` - The Ruby VM context
|
|
352
|
+
///
|
|
353
|
+
/// # Errors
|
|
354
|
+
///
|
|
355
|
+
/// Returns an error if Ruby class or method definition fails.
|
|
356
|
+
pub fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
357
|
+
let module = ruby.get_inner(&ROOT_MOD);
|
|
358
|
+
let class = module.define_class(id!(ruby, "Client"), ruby.class_object())?;
|
|
359
|
+
|
|
360
|
+
class.define_singleton_method("new", function!(Client::new, 1))?;
|
|
361
|
+
class.define_method(
|
|
362
|
+
id!(ruby, "consumer_state"),
|
|
363
|
+
method!(Client::consumer_state, 0),
|
|
364
|
+
)?;
|
|
365
|
+
class.define_method(id!(ruby, "send_message"), method!(Client::send, 3))?;
|
|
366
|
+
class.define_method(id!(ruby, "subscribe"), method!(Client::subscribe, 1))?;
|
|
367
|
+
class.define_method(
|
|
368
|
+
id!(ruby, "assigned_partitions"),
|
|
369
|
+
method!(Client::assigned_partitions, 0),
|
|
370
|
+
)?;
|
|
371
|
+
class.define_method(id!(ruby, "is_stalled?"), method!(Client::is_stalled, 0))?;
|
|
372
|
+
class.define_method(id!(ruby, "unsubscribe"), method!(Client::unsubscribe, 0))?;
|
|
373
|
+
class.define_method(
|
|
374
|
+
id!(ruby, "source_system"),
|
|
375
|
+
method!(Client::source_system, 0),
|
|
376
|
+
)?;
|
|
377
|
+
|
|
378
|
+
Ok(())
|
|
379
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
use rb_sys::rb_thread_call_without_gvl;
|
|
2
|
+
use std::ffi::c_void;
|
|
3
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
4
|
+
use thiserror::Error;
|
|
5
|
+
use tracing::error;
|
|
6
|
+
|
|
7
|
+
/// Executes the given closure `func` without holding Ruby’s Global VM Lock
|
|
8
|
+
/// (GVL), allowing long-running or blocking Rust code to run without blocking
|
|
9
|
+
/// Ruby’s threads.
|
|
10
|
+
///
|
|
11
|
+
/// # Arguments
|
|
12
|
+
///
|
|
13
|
+
/// - `func`: A closure that performs the work without the GVL and returns a
|
|
14
|
+
/// value of type `R`. Called **exactly once** by Ruby.
|
|
15
|
+
/// - `unblock`: A closure that Ruby will call to "unblock" the thread if needed
|
|
16
|
+
/// while `func` is running. Ruby **may** call this multiple times, depending
|
|
17
|
+
/// on signals, etc.
|
|
18
|
+
///
|
|
19
|
+
/// # Returns
|
|
20
|
+
///
|
|
21
|
+
/// The result of `func`.
|
|
22
|
+
///
|
|
23
|
+
/// # Panics
|
|
24
|
+
///
|
|
25
|
+
/// - If either `func` or `unblock` panics, that panic is caught and the process
|
|
26
|
+
/// is aborted. This is necessary to prevent unwinding across the C FFI
|
|
27
|
+
/// boundary, which is undefined behavior.
|
|
28
|
+
///
|
|
29
|
+
/// # Overview
|
|
30
|
+
///
|
|
31
|
+
/// 1. We box both closures (`func` and `unblock`) and convert them into raw
|
|
32
|
+
/// pointers via `Box::into_raw`.
|
|
33
|
+
/// 2. We define two `unsafe extern "C"` callbacks (`anon_func` and
|
|
34
|
+
/// `anon_unblock`), which:
|
|
35
|
+
/// - `anon_func` (for `func`) *consumes* its Box exactly once via
|
|
36
|
+
/// `Box::from_raw`, then calls it inside `catch_unwind`.
|
|
37
|
+
/// - `anon_unblock` (for `unblock`) does **not** consume its Box. Instead,
|
|
38
|
+
/// it just uses a pointer to call `unblock` each time Ruby needs it.
|
|
39
|
+
/// Because Ruby may call it multiple times, we only do `Box::from_raw` on
|
|
40
|
+
/// this pointer **after** `rb_thread_call_without_gvl` returns.
|
|
41
|
+
/// 3. We wrap calls in `catch_unwind` + `abort()` so that no panic can cross
|
|
42
|
+
/// the C boundary.
|
|
43
|
+
/// 4. Once `rb_thread_call_without_gvl` returns, we do a single
|
|
44
|
+
/// `Box::from_raw(unblock_ptr)`, ensuring no memory leaks. We also retrieve
|
|
45
|
+
/// the result from `anon_func` via `Box::from_raw` exactly once.
|
|
46
|
+
///
|
|
47
|
+
/// # Safety
|
|
48
|
+
///
|
|
49
|
+
/// - We must ensure each pointer from `Box::into_raw` is only reconstructed
|
|
50
|
+
/// exactly once via `Box::from_raw`.
|
|
51
|
+
/// - `anon_unblock` does not free the pointer on each call, thus avoiding
|
|
52
|
+
/// double-free if Ruby calls it multiple times.
|
|
53
|
+
/// - `catch_unwind` and `abort()` ensure that we never unwind through C code.
|
|
54
|
+
///
|
|
55
|
+
/// Adapted from: <https://github.com/temporalio/sdk-ruby/blob/main/temporalio/ext/src/util.rs>,
|
|
56
|
+
/// plus various examples in the Rust/Ruby ecosystem.
|
|
57
|
+
#[allow(unsafe_code)]
|
|
58
|
+
pub(crate) fn without_gvl<F, R, U>(func: F, unblock: U) -> Result<R, GvlError>
|
|
59
|
+
where
|
|
60
|
+
F: FnOnce() -> R,
|
|
61
|
+
U: FnMut() + Send,
|
|
62
|
+
{
|
|
63
|
+
// SAFETY: This is an FFI callback, called exactly once by Ruby. We:
|
|
64
|
+
// - Reconstruct the Box<F> from `data`.
|
|
65
|
+
// - Catch any panic to avoid unwinding across the C boundary.
|
|
66
|
+
// - Return a pointer to a boxed result or abort on panic.
|
|
67
|
+
unsafe extern "C" fn anon_func<F, R>(data: *mut c_void) -> *mut c_void
|
|
68
|
+
where
|
|
69
|
+
F: FnOnce() -> R,
|
|
70
|
+
{
|
|
71
|
+
// SAFETY: `data` is guaranteed to come from `Box::into_raw(Box<F>)`.
|
|
72
|
+
// We reconstruct exactly once, transferring ownership back into Rust.
|
|
73
|
+
let func: Box<F> = unsafe { Box::from_raw(data.cast()) };
|
|
74
|
+
|
|
75
|
+
// Catch unwind so we don't panic across FFI.
|
|
76
|
+
let result = catch_unwind(AssertUnwindSafe(|| (*func)())).map_err(|_| GvlError::Panicked);
|
|
77
|
+
|
|
78
|
+
// SAFETY: We box the result, returning a raw pointer. The caller
|
|
79
|
+
// will Box::from_raw it exactly once after `rb_thread_call_without_gvl`.
|
|
80
|
+
Box::into_raw(Box::new(result)).cast::<c_void>()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SAFETY: Another FFI callback. Ruby may call this multiple times, so we do
|
|
84
|
+
// NOT call `Box::from_raw` here. Instead, we just dereference the pointer
|
|
85
|
+
// each time. We also catch any panic and abort to avoid unwinding across FFI.
|
|
86
|
+
unsafe extern "C" fn anon_unblock<U>(data: *mut c_void)
|
|
87
|
+
where
|
|
88
|
+
U: FnMut() + Send,
|
|
89
|
+
{
|
|
90
|
+
// We take a pointer to `U`; we do NOT consume the Box here.
|
|
91
|
+
// Note that `&mut *ptr` is not automatically UnwindSafe, so we wrap the call
|
|
92
|
+
// in `AssertUnwindSafe`.
|
|
93
|
+
let closure_ptr = data.cast::<U>();
|
|
94
|
+
|
|
95
|
+
if catch_unwind(AssertUnwindSafe(|| {
|
|
96
|
+
// SAFETY: closure_ptr was allocated via `Box::into_raw`. We only
|
|
97
|
+
// borrow it here, not freeing it. That’s safe for multiple calls.
|
|
98
|
+
unsafe { (*closure_ptr)() };
|
|
99
|
+
}))
|
|
100
|
+
.is_err()
|
|
101
|
+
{
|
|
102
|
+
error!("panicked while attempting to unblock a function running outside of the GVL");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Box up both closures. We'll consume `func` exactly once in `anon_func`,
|
|
107
|
+
// and we'll only free `unblock` after `rb_thread_call_without_gvl` returns.
|
|
108
|
+
let boxed_func = Box::new(func);
|
|
109
|
+
let boxed_unblock = Box::new(unblock);
|
|
110
|
+
|
|
111
|
+
// Convert to raw pointers for FFI.
|
|
112
|
+
let func_ptr = Box::into_raw(boxed_func);
|
|
113
|
+
let unblock_ptr = Box::into_raw(boxed_unblock);
|
|
114
|
+
|
|
115
|
+
// SAFETY: Passing valid function pointers and data pointers to
|
|
116
|
+
// `rb_thread_call_without_gvl`. Ruby will:
|
|
117
|
+
// - Call `anon_func` once, passing `func_ptr`.
|
|
118
|
+
// - Potentially call `anon_unblock` multiple times with `unblock_ptr`.
|
|
119
|
+
// After `rb_thread_call_without_gvl` returns, we are guaranteed that Ruby
|
|
120
|
+
// won't call `anon_unblock` again, so we can safely free the unblock closure.
|
|
121
|
+
let raw_result = unsafe {
|
|
122
|
+
rb_thread_call_without_gvl(
|
|
123
|
+
Some(anon_func::<F, R>),
|
|
124
|
+
func_ptr.cast(),
|
|
125
|
+
Some(anon_unblock::<U>),
|
|
126
|
+
unblock_ptr.cast(),
|
|
127
|
+
)
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// SAFETY: This pointer came from `anon_func`, which did
|
|
131
|
+
// `Box::into_raw(Box::new(val))`. We reconstruct it here exactly once to
|
|
132
|
+
// get the result.
|
|
133
|
+
let result = unsafe { *Box::from_raw(raw_result.cast()) };
|
|
134
|
+
|
|
135
|
+
// SAFETY: Now that `rb_thread_call_without_gvl` has returned, Ruby will no
|
|
136
|
+
// longer call our `anon_unblock` callback. Thus, it's safe to free the
|
|
137
|
+
// `unblock` closure exactly once here.
|
|
138
|
+
unsafe {
|
|
139
|
+
let _ = Box::from_raw(unblock_ptr);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
result
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[derive(Debug, Error)]
|
|
146
|
+
pub enum GvlError {
|
|
147
|
+
#[error("panicked while attempting to call function running outside of the GVL")]
|
|
148
|
+
Panicked,
|
|
149
|
+
}
|