tonic-rails 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +44 -1
- data/Cargo.lock +69 -1
- data/README.md +265 -0
- data/ext/tonic_rails_native/Cargo.toml +7 -1
- data/ext/tonic_rails_native/src/body.rs +1 -40
- data/ext/tonic_rails_native/src/client.rs +665 -0
- data/ext/tonic_rails_native/src/dispatch.rs +10 -3
- data/ext/tonic_rails_native/src/gvl.rs +22 -7
- data/ext/tonic_rails_native/src/lib.rs +14 -0
- data/lib/tonic/rails/base.rb +4 -1
- data/lib/tonic/rails/client/retry_policy.rb +73 -0
- data/lib/tonic/rails/client.rb +460 -0
- data/lib/tonic/rails/instrumentation.rb +21 -5
- data/lib/tonic/rails/rpc_error.rb +61 -0
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +1 -0
- metadata +4 -1
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
//! `TonicRails::Native::Channel`: outgoing gRPC calls over a tonic
|
|
2
|
+
//! [`Channel`], with message bytes passed through untouched. Unary calls
|
|
3
|
+
//! return their result; server-streaming calls return a
|
|
4
|
+
//! `TonicRails::Native::ResponseStream` to read messages from.
|
|
5
|
+
//!
|
|
6
|
+
//! Fork safety: each process gets its own tokio runtime and its own
|
|
7
|
+
//! connection, created on first use and keyed by pid. After `fork` the
|
|
8
|
+
//! child sees a different pid and builds fresh ones. It never touches
|
|
9
|
+
//! what it inherited: the runtime's threads don't exist in the child, so
|
|
10
|
+
//! dropping the runtime (or a channel whose tasks ran on it) could hang
|
|
11
|
+
//! or wake the parent's event loop. Those are leaked instead.
|
|
12
|
+
//!
|
|
13
|
+
//! The system's root certificates are loaded once and kept, so children
|
|
14
|
+
//! inherit them. On macOS loading them goes through Security.framework,
|
|
15
|
+
//! which aborts in the child of a multi-threaded process.
|
|
16
|
+
|
|
17
|
+
use std::future::Future;
|
|
18
|
+
use std::io;
|
|
19
|
+
use std::sync::{Arc, OnceLock};
|
|
20
|
+
use std::time::Duration;
|
|
21
|
+
|
|
22
|
+
use base64::Engine;
|
|
23
|
+
use bytes::{Buf, BufMut, Bytes};
|
|
24
|
+
use http::uri::PathAndQuery;
|
|
25
|
+
use http::{HeaderMap, HeaderName, HeaderValue};
|
|
26
|
+
use magnus::{Error, RArray, RHash, RString, Ruby, r_hash::ForEach};
|
|
27
|
+
use parking_lot::Mutex;
|
|
28
|
+
use tokio::runtime::{Builder, Handle, Runtime};
|
|
29
|
+
use tokio::sync::mpsc;
|
|
30
|
+
use tokio::task::JoinHandle;
|
|
31
|
+
use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder, Streaming};
|
|
32
|
+
use tonic::metadata::MetadataMap;
|
|
33
|
+
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
|
|
34
|
+
use tonic::{Code, Request, Status};
|
|
35
|
+
|
|
36
|
+
use crate::gvl::{Cancel, Wait, block_on, recv_blocking, without_gvl};
|
|
37
|
+
|
|
38
|
+
// ─── Per-process runtime ──────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
struct ProcessRuntime {
|
|
41
|
+
pid: u32,
|
|
42
|
+
runtime: Runtime,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static RUNTIME: Mutex<Option<ProcessRuntime>> = Mutex::new(None);
|
|
46
|
+
|
|
47
|
+
/// I/O for outgoing calls is light: two threads serve every client in
|
|
48
|
+
/// the process.
|
|
49
|
+
const RUNTIME_THREADS: usize = 2;
|
|
50
|
+
|
|
51
|
+
/// The current process's runtime, created on first use.
|
|
52
|
+
fn runtime() -> io::Result<Handle> {
|
|
53
|
+
let pid = std::process::id();
|
|
54
|
+
if let Some(current) = RUNTIME.lock().as_ref().filter(|r| r.pid == pid) {
|
|
55
|
+
return Ok(current.runtime.handle().clone());
|
|
56
|
+
}
|
|
57
|
+
// Built outside the lock: if another thread forked while this one
|
|
58
|
+
// held it, the child's copy of the lock would never be released.
|
|
59
|
+
let runtime = Builder::new_multi_thread()
|
|
60
|
+
.worker_threads(RUNTIME_THREADS)
|
|
61
|
+
.thread_name("tonic-rails-client")
|
|
62
|
+
.enable_all()
|
|
63
|
+
.build()?;
|
|
64
|
+
let mut slot = RUNTIME.lock();
|
|
65
|
+
if let Some(current) = slot.as_ref().filter(|r| r.pid == pid) {
|
|
66
|
+
// Another thread got there first.
|
|
67
|
+
runtime.shutdown_background();
|
|
68
|
+
return Ok(current.runtime.handle().clone());
|
|
69
|
+
}
|
|
70
|
+
let handle = runtime.handle().clone();
|
|
71
|
+
if let Some(inherited) = slot.replace(ProcessRuntime { pid, runtime }) {
|
|
72
|
+
std::mem::forget(inherited);
|
|
73
|
+
}
|
|
74
|
+
Ok(handle)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Raw-bytes codec ──────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/// Passes encoded protobuf messages through: Ruby encodes requests and
|
|
80
|
+
/// decodes responses.
|
|
81
|
+
#[derive(Clone, Copy, Default)]
|
|
82
|
+
struct RawCodec;
|
|
83
|
+
|
|
84
|
+
impl Codec for RawCodec {
|
|
85
|
+
type Encode = Bytes;
|
|
86
|
+
type Decode = Bytes;
|
|
87
|
+
type Encoder = RawCodec;
|
|
88
|
+
type Decoder = RawCodec;
|
|
89
|
+
|
|
90
|
+
fn encoder(&mut self) -> Self::Encoder {
|
|
91
|
+
*self
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn decoder(&mut self) -> Self::Decoder {
|
|
95
|
+
*self
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
impl Encoder for RawCodec {
|
|
100
|
+
type Item = Bytes;
|
|
101
|
+
type Error = Status;
|
|
102
|
+
|
|
103
|
+
fn encode(&mut self, item: Bytes, dst: &mut EncodeBuf<'_>) -> Result<(), Status> {
|
|
104
|
+
dst.put(item);
|
|
105
|
+
Ok(())
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
impl Decoder for RawCodec {
|
|
110
|
+
type Item = Bytes;
|
|
111
|
+
type Error = Status;
|
|
112
|
+
|
|
113
|
+
fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Bytes>, Status> {
|
|
114
|
+
Ok(Some(src.copy_to_bytes(src.remaining())))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Calls ────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/// How a call ended. `code` 0 means OK and `response` is set.
|
|
121
|
+
struct Outcome {
|
|
122
|
+
code: Code,
|
|
123
|
+
message: String,
|
|
124
|
+
/// The failure happened here (a broken connection, a timeout), not
|
|
125
|
+
/// on the server.
|
|
126
|
+
local: bool,
|
|
127
|
+
/// Response headers arrived, so the server has started on the call
|
|
128
|
+
/// and retrying it could repeat its work.
|
|
129
|
+
committed: bool,
|
|
130
|
+
details: Bytes,
|
|
131
|
+
response: Option<Bytes>,
|
|
132
|
+
headers: HeaderMap,
|
|
133
|
+
trailers: HeaderMap,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
impl Outcome {
|
|
137
|
+
fn ok(response: Option<Bytes>, headers: HeaderMap, trailers: HeaderMap) -> Self {
|
|
138
|
+
Self {
|
|
139
|
+
code: Code::Ok,
|
|
140
|
+
message: String::new(),
|
|
141
|
+
local: false,
|
|
142
|
+
committed: true,
|
|
143
|
+
details: Bytes::new(),
|
|
144
|
+
response,
|
|
145
|
+
headers,
|
|
146
|
+
trailers,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fn error(code: Code, message: impl Into<String>) -> Self {
|
|
151
|
+
Self {
|
|
152
|
+
local: true,
|
|
153
|
+
..Self::status(Status::new(code, message), None)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// `headers` are the response headers, if they arrived before the
|
|
158
|
+
/// status.
|
|
159
|
+
fn status(status: Status, headers: Option<HeaderMap>) -> Self {
|
|
160
|
+
// A status with a source is a local transport failure, not one the
|
|
161
|
+
// server sent. The source holds the cause (e.g. "Connection
|
|
162
|
+
// refused"), and tonic reports broken connections as UNKNOWN or
|
|
163
|
+
// CANCELLED where gRPC clients report UNAVAILABLE.
|
|
164
|
+
let (code, message, local) = match std::error::Error::source(&status) {
|
|
165
|
+
Some(source) => (
|
|
166
|
+
match status.code() {
|
|
167
|
+
Code::Unknown | Code::Cancelled => Code::Unavailable,
|
|
168
|
+
code => code,
|
|
169
|
+
},
|
|
170
|
+
append_causes(status.message().to_owned(), Some(source)),
|
|
171
|
+
true,
|
|
172
|
+
),
|
|
173
|
+
None => (status.code(), status.message().to_owned(), false),
|
|
174
|
+
};
|
|
175
|
+
Self {
|
|
176
|
+
code,
|
|
177
|
+
message,
|
|
178
|
+
local,
|
|
179
|
+
committed: headers.is_some(),
|
|
180
|
+
details: Bytes::copy_from_slice(status.details()),
|
|
181
|
+
response: None,
|
|
182
|
+
headers: headers.unwrap_or_default(),
|
|
183
|
+
trailers: status.metadata().clone().into_headers(),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// `[code, message, response, headers, trailers, details, committed]`,
|
|
188
|
+
/// with metadata as `[name, value]` pairs in wire form (`-bin` values
|
|
189
|
+
/// base64-encoded).
|
|
190
|
+
fn into_ruby(self, ruby: &Ruby) -> Result<RArray, Error> {
|
|
191
|
+
let result = ruby.ary_new_capa(7);
|
|
192
|
+
result.push(self.code as u32)?;
|
|
193
|
+
result.push(ruby.str_new(&self.message))?;
|
|
194
|
+
result.push(self.response.map(|bytes| ruby.str_from_slice(&bytes)))?;
|
|
195
|
+
result.push(metadata_pairs(ruby, &self.headers)?)?;
|
|
196
|
+
result.push(metadata_pairs(ruby, &self.trailers)?)?;
|
|
197
|
+
result.push((!self.details.is_empty()).then(|| ruby.str_from_slice(&self.details)))?;
|
|
198
|
+
result.push(self.committed)?;
|
|
199
|
+
Ok(result)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fn metadata_pairs(ruby: &Ruby, headers: &HeaderMap) -> Result<RArray, Error> {
|
|
204
|
+
let pairs = ruby.ary_new_capa(headers.len());
|
|
205
|
+
for (name, value) in headers {
|
|
206
|
+
let value = String::from_utf8_lossy(value.as_bytes());
|
|
207
|
+
pairs.push((ruby.str_new(name.as_str()), ruby.str_new(&value)))?;
|
|
208
|
+
}
|
|
209
|
+
Ok(pairs)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Bound a call by `timeout`, if there is one. For a stream, that
|
|
213
|
+
/// covers every message, including time spent waiting on a slow reader.
|
|
214
|
+
async fn with_timeout(call: impl Future<Output = Outcome>, timeout: Option<Duration>) -> Outcome {
|
|
215
|
+
let Some(timeout) = timeout else {
|
|
216
|
+
return call.await;
|
|
217
|
+
};
|
|
218
|
+
let started = tokio::time::Instant::now();
|
|
219
|
+
let outcome = tokio::time::timeout(timeout, call).await;
|
|
220
|
+
match outcome {
|
|
221
|
+
// tonic's channel enforces the grpc-timeout header too, but ends
|
|
222
|
+
// the call with its own status.
|
|
223
|
+
Ok(outcome) if !(outcome.local && started.elapsed() >= timeout) => outcome,
|
|
224
|
+
_ => Outcome::error(Code::DeadlineExceeded, "deadline exceeded"),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// What every call needs: the method, one request message and its metadata.
|
|
229
|
+
struct CallArgs {
|
|
230
|
+
channel: Channel,
|
|
231
|
+
path: PathAndQuery,
|
|
232
|
+
request: Bytes,
|
|
233
|
+
metadata: HeaderMap,
|
|
234
|
+
timeout: Option<Duration>,
|
|
235
|
+
max_message_size: usize,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Send the request and wait for the response headers.
|
|
239
|
+
async fn start(args: CallArgs) -> Result<(HeaderMap, Streaming<Bytes>), Box<Outcome>> {
|
|
240
|
+
let mut grpc = tonic::client::Grpc::new(args.channel)
|
|
241
|
+
.max_decoding_message_size(args.max_message_size)
|
|
242
|
+
.max_encoding_message_size(args.max_message_size);
|
|
243
|
+
if let Err(e) = grpc.ready().await {
|
|
244
|
+
let message = format!("channel not ready: {e}");
|
|
245
|
+
return Err(Box::new(Outcome::error(Code::Unavailable, message)));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The streaming call keeps response headers and trailers apart, which
|
|
249
|
+
// tonic's own unary call merges.
|
|
250
|
+
let mut request = Request::from_parts(
|
|
251
|
+
MetadataMap::from_headers(args.metadata),
|
|
252
|
+
Default::default(),
|
|
253
|
+
tokio_stream::once(args.request),
|
|
254
|
+
);
|
|
255
|
+
if let Some(timeout) = args.timeout {
|
|
256
|
+
request.set_timeout(timeout);
|
|
257
|
+
}
|
|
258
|
+
match grpc.streaming(request, args.path, RawCodec).await {
|
|
259
|
+
Ok(response) => {
|
|
260
|
+
let (headers, stream, _) = response.into_parts();
|
|
261
|
+
Ok((headers.into_headers(), stream))
|
|
262
|
+
}
|
|
263
|
+
// Includes trailers-only responses, whose status comes in the
|
|
264
|
+
// headers. They don't commit the call: gRPC retries treat a server
|
|
265
|
+
// that answers with only a status as not having started on it.
|
|
266
|
+
Err(status) => Err(Box::new(Outcome::status(status, None))),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// The trailers that end a successful call.
|
|
271
|
+
async fn finish(stream: &mut Streaming<Bytes>, headers: &HeaderMap) -> Result<HeaderMap, Box<Outcome>> {
|
|
272
|
+
match stream.trailers().await {
|
|
273
|
+
Ok(trailers) => Ok(trailers.map(MetadataMap::into_headers).unwrap_or_default()),
|
|
274
|
+
Err(status) => Err(Box::new(Outcome::status(status, Some(headers.clone())))),
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async fn unary(args: CallArgs) -> Outcome {
|
|
279
|
+
let (headers, mut stream) = match start(args).await {
|
|
280
|
+
Ok(started) => started,
|
|
281
|
+
Err(outcome) => return *outcome,
|
|
282
|
+
};
|
|
283
|
+
let message = match stream.message().await {
|
|
284
|
+
Ok(message) => message,
|
|
285
|
+
Err(status) => return Outcome::status(status, Some(headers)),
|
|
286
|
+
};
|
|
287
|
+
let trailers = match finish(&mut stream, &headers).await {
|
|
288
|
+
Ok(trailers) => trailers,
|
|
289
|
+
Err(outcome) => return *outcome,
|
|
290
|
+
};
|
|
291
|
+
match message {
|
|
292
|
+
Some(message) => Outcome::ok(Some(message), headers, trailers),
|
|
293
|
+
None => Outcome::error(Code::Internal, "server sent no response message"),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Forward each response message to `tx` as it arrives. A full `tx`
|
|
298
|
+
/// stops reading from the connection, so HTTP/2 flow control holds the
|
|
299
|
+
/// server back.
|
|
300
|
+
async fn server_stream(args: CallArgs, tx: mpsc::Sender<Bytes>) -> Outcome {
|
|
301
|
+
let (headers, mut stream) = match start(args).await {
|
|
302
|
+
Ok(started) => started,
|
|
303
|
+
Err(outcome) => return *outcome,
|
|
304
|
+
};
|
|
305
|
+
loop {
|
|
306
|
+
match stream.message().await {
|
|
307
|
+
Ok(Some(message)) => {
|
|
308
|
+
if tx.send(message).await.is_err() {
|
|
309
|
+
return Outcome::error(Code::Cancelled, "stream closed by the client");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
Ok(None) => break,
|
|
313
|
+
Err(status) => return Outcome::status(status, Some(headers)),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
match finish(&mut stream, &headers).await {
|
|
317
|
+
Ok(trailers) => Outcome::ok(None, headers, trailers),
|
|
318
|
+
Err(outcome) => *outcome,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Aborts the task if the waiting Ruby thread gives up on it, which
|
|
323
|
+
/// resets the HTTP/2 stream so the server sees the call cancelled.
|
|
324
|
+
struct AbortOnDrop<T>(JoinHandle<T>);
|
|
325
|
+
|
|
326
|
+
impl<T> Drop for AbortOnDrop<T> {
|
|
327
|
+
fn drop(&mut self) {
|
|
328
|
+
self.0.abort();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// Wait for `task` without the GVL. Interrupts (`Thread#raise`,
|
|
333
|
+
/// `Timeout`, signals) are handled by Ruby; if one raises, the task is
|
|
334
|
+
/// aborted.
|
|
335
|
+
fn wait<T: Send + 'static>(ruby: &Ruby, mut task: AbortOnDrop<T>) -> Result<T, Error> {
|
|
336
|
+
loop {
|
|
337
|
+
let cancel = Cancel::default();
|
|
338
|
+
let done = without_gvl(&cancel, || {
|
|
339
|
+
block_on(async {
|
|
340
|
+
tokio::select! {
|
|
341
|
+
biased;
|
|
342
|
+
result = &mut task.0 => Some(result),
|
|
343
|
+
_ = cancel.cancelled() => None,
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
});
|
|
347
|
+
match done {
|
|
348
|
+
Some(Some(Ok(value))) => return Ok(value),
|
|
349
|
+
Some(Some(Err(e))) => {
|
|
350
|
+
return Err(Error::new(
|
|
351
|
+
ruby.exception_runtime_error(),
|
|
352
|
+
format!("tonic-rails client call failed: {e}"),
|
|
353
|
+
));
|
|
354
|
+
}
|
|
355
|
+
_ => ruby.thread_check_ints()?,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Ruby class ───────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/// `TonicRails::Native::Channel` — one HTTP/2 connection (per process) to
|
|
363
|
+
/// a gRPC server, shared by every thread.
|
|
364
|
+
#[derive(magnus::TypedData)]
|
|
365
|
+
#[magnus(class = "TonicRails::Native::Channel", free_immediately)]
|
|
366
|
+
pub struct NativeChannel {
|
|
367
|
+
endpoint: Endpoint,
|
|
368
|
+
max_message_size: usize,
|
|
369
|
+
/// The connection for the process that created it.
|
|
370
|
+
channel: Mutex<Option<(u32, Channel)>>,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
impl magnus::typed_data::DataTypeFunctions for NativeChannel {}
|
|
374
|
+
|
|
375
|
+
impl Drop for NativeChannel {
|
|
376
|
+
fn drop(&mut self) {
|
|
377
|
+
if let Some((pid, channel)) = self.channel.get_mut().take()
|
|
378
|
+
&& pid != std::process::id()
|
|
379
|
+
{
|
|
380
|
+
// Its tasks ran on the parent's runtime: see the module docs.
|
|
381
|
+
std::mem::forget(channel);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// `[ca_pem, cert_pem, key_pem, server_name]`, each optional.
|
|
387
|
+
type TlsOptions = (Option<String>, Option<String>, Option<String>, Option<String>);
|
|
388
|
+
|
|
389
|
+
impl NativeChannel {
|
|
390
|
+
/// `uri` is `http://host:port` or `https://host:port`. With `tls`
|
|
391
|
+
/// and no CA, the system's root certificates are trusted.
|
|
392
|
+
pub fn new(
|
|
393
|
+
ruby: &Ruby,
|
|
394
|
+
uri: String,
|
|
395
|
+
max_message_size: usize,
|
|
396
|
+
connect_timeout_ms: u64,
|
|
397
|
+
tls: Option<TlsOptions>,
|
|
398
|
+
) -> Result<Self, Error> {
|
|
399
|
+
let argument_error = |message: String| Error::new(ruby.exception_arg_error(), message);
|
|
400
|
+
let mut endpoint = Endpoint::from_shared(uri.clone())
|
|
401
|
+
.map_err(|e| argument_error(format!("invalid gRPC address {uri}: {e}")))?
|
|
402
|
+
.connect_timeout(Duration::from_millis(connect_timeout_ms))
|
|
403
|
+
.tcp_nodelay(true)
|
|
404
|
+
.user_agent(concat!("tonic-rails/", env!("CARGO_PKG_VERSION")))
|
|
405
|
+
.map_err(|e| argument_error(e.to_string()))?;
|
|
406
|
+
if let Some((ca, cert, key, server_name)) = tls {
|
|
407
|
+
let mut config = ClientTlsConfig::new();
|
|
408
|
+
config = match ca {
|
|
409
|
+
Some(ca) => config.ca_certificate(Certificate::from_pem(ca)),
|
|
410
|
+
None => config.ca_certificate(Certificate::from_pem(system_roots().map_err(argument_error)?)),
|
|
411
|
+
};
|
|
412
|
+
if let (Some(cert), Some(key)) = (cert, key) {
|
|
413
|
+
config = config.identity(Identity::from_pem(cert, key));
|
|
414
|
+
}
|
|
415
|
+
if let Some(server_name) = server_name {
|
|
416
|
+
config = config.domain_name(server_name);
|
|
417
|
+
}
|
|
418
|
+
endpoint = endpoint
|
|
419
|
+
.tls_config(config)
|
|
420
|
+
.map_err(|e| argument_error(format!("invalid TLS configuration: {}", source_chain(&e))))?;
|
|
421
|
+
}
|
|
422
|
+
Ok(Self {
|
|
423
|
+
endpoint,
|
|
424
|
+
max_message_size,
|
|
425
|
+
channel: Mutex::new(None),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/// This process's connection, created (lazily connecting) on first use.
|
|
430
|
+
fn channel(&self, runtime: &Handle) -> Channel {
|
|
431
|
+
let pid = std::process::id();
|
|
432
|
+
let mut slot = self.channel.lock();
|
|
433
|
+
if let Some((owner, channel)) = slot.as_ref()
|
|
434
|
+
&& *owner == pid
|
|
435
|
+
{
|
|
436
|
+
return channel.clone();
|
|
437
|
+
}
|
|
438
|
+
let _entered = runtime.enter();
|
|
439
|
+
let channel = self.endpoint.connect_lazy();
|
|
440
|
+
if let Some((_, inherited)) = slot.replace((pid, channel.clone())) {
|
|
441
|
+
std::mem::forget(inherited);
|
|
442
|
+
}
|
|
443
|
+
channel
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Everything a call needs, from its Ruby arguments.
|
|
447
|
+
fn call_args(
|
|
448
|
+
ruby: &Ruby,
|
|
449
|
+
this: &Self,
|
|
450
|
+
path: String,
|
|
451
|
+
request: RString,
|
|
452
|
+
metadata: RHash,
|
|
453
|
+
timeout_ms: Option<u64>,
|
|
454
|
+
) -> Result<(Handle, CallArgs), Error> {
|
|
455
|
+
let path = PathAndQuery::try_from(path).map_err(|e| {
|
|
456
|
+
Error::new(
|
|
457
|
+
ruby.exception_arg_error(),
|
|
458
|
+
format!("invalid gRPC method path: {e}"),
|
|
459
|
+
)
|
|
460
|
+
})?;
|
|
461
|
+
let metadata = header_map(ruby, metadata)?;
|
|
462
|
+
let request = Bytes::copy_from_slice(unsafe { request.as_slice() });
|
|
463
|
+
let runtime = runtime().map_err(|e| {
|
|
464
|
+
Error::new(
|
|
465
|
+
ruby.exception_runtime_error(),
|
|
466
|
+
format!("tonic-rails client: can't start a tokio runtime: {e}"),
|
|
467
|
+
)
|
|
468
|
+
})?;
|
|
469
|
+
let args = CallArgs {
|
|
470
|
+
channel: this.channel(&runtime),
|
|
471
|
+
path,
|
|
472
|
+
request,
|
|
473
|
+
metadata,
|
|
474
|
+
timeout: timeout_ms.map(Duration::from_millis),
|
|
475
|
+
max_message_size: this.max_message_size,
|
|
476
|
+
};
|
|
477
|
+
Ok((runtime, args))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/// Make a unary call. Returns the array described in
|
|
481
|
+
/// [`Outcome::into_ruby`]; errors come back as a status, not raised.
|
|
482
|
+
pub fn unary(
|
|
483
|
+
ruby: &Ruby,
|
|
484
|
+
this: &Self,
|
|
485
|
+
path: String,
|
|
486
|
+
request: RString,
|
|
487
|
+
metadata: RHash,
|
|
488
|
+
timeout_ms: Option<u64>,
|
|
489
|
+
) -> Result<RArray, Error> {
|
|
490
|
+
let (runtime, args) = Self::call_args(ruby, this, path, request, metadata, timeout_ms)?;
|
|
491
|
+
let timeout = args.timeout;
|
|
492
|
+
let task = AbortOnDrop(runtime.spawn(with_timeout(unary(args), timeout)));
|
|
493
|
+
wait(ruby, task)?.into_ruby(ruby)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Start a server-streaming call. It runs in the background, a few
|
|
497
|
+
/// messages ahead of the reader.
|
|
498
|
+
pub fn server_streaming(
|
|
499
|
+
ruby: &Ruby,
|
|
500
|
+
this: &Self,
|
|
501
|
+
path: String,
|
|
502
|
+
request: RString,
|
|
503
|
+
metadata: RHash,
|
|
504
|
+
timeout_ms: Option<u64>,
|
|
505
|
+
) -> Result<ResponseStream, Error> {
|
|
506
|
+
let (runtime, args) = Self::call_args(ruby, this, path, request, metadata, timeout_ms)?;
|
|
507
|
+
let timeout = args.timeout;
|
|
508
|
+
let (tx, rx) = mpsc::channel(STREAM_BUFFER);
|
|
509
|
+
let outcome = Arc::new(Mutex::new(None));
|
|
510
|
+
let slot = outcome.clone();
|
|
511
|
+
let task = runtime.spawn(async move {
|
|
512
|
+
let result = with_timeout(server_stream(args, tx.clone()), timeout).await;
|
|
513
|
+
// Set before `tx` is dropped, so the reader finds it at the end
|
|
514
|
+
// of the stream.
|
|
515
|
+
*slot.lock() = Some(result);
|
|
516
|
+
drop(tx);
|
|
517
|
+
});
|
|
518
|
+
Ok(ResponseStream {
|
|
519
|
+
pid: std::process::id(),
|
|
520
|
+
rx: Mutex::new(Some(rx)),
|
|
521
|
+
outcome,
|
|
522
|
+
task: Mutex::new(Some(AbortOnDrop(task))),
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── Response stream ──────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
/// Messages read ahead of a slow reader. Past this, HTTP/2 flow control
|
|
530
|
+
/// holds the server back.
|
|
531
|
+
const STREAM_BUFFER: usize = 8;
|
|
532
|
+
|
|
533
|
+
/// `TonicRails::Native::ResponseStream` — the messages of a
|
|
534
|
+
/// server-streaming call, then its status.
|
|
535
|
+
#[derive(magnus::TypedData)]
|
|
536
|
+
#[magnus(class = "TonicRails::Native::ResponseStream", free_immediately)]
|
|
537
|
+
pub struct ResponseStream {
|
|
538
|
+
/// The process that started the call.
|
|
539
|
+
pid: u32,
|
|
540
|
+
rx: Mutex<Option<mpsc::Receiver<Bytes>>>,
|
|
541
|
+
outcome: Arc<Mutex<Option<Outcome>>>,
|
|
542
|
+
task: Mutex<Option<AbortOnDrop<()>>>,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
impl magnus::typed_data::DataTypeFunctions for ResponseStream {}
|
|
546
|
+
|
|
547
|
+
impl ResponseStream {
|
|
548
|
+
/// Next raw message, or nil once the stream has ended (see `#result`).
|
|
549
|
+
pub fn next(ruby: &Ruby, this: &Self) -> Result<Option<RString>, Error> {
|
|
550
|
+
// Taken out of the mutex while blocked so `#close` from another
|
|
551
|
+
// thread doesn't wait on it.
|
|
552
|
+
let Some(mut rx) = this.rx.lock().take() else {
|
|
553
|
+
return Ok(None);
|
|
554
|
+
};
|
|
555
|
+
loop {
|
|
556
|
+
match recv_blocking(&mut rx) {
|
|
557
|
+
Wait::Ready(bytes) => {
|
|
558
|
+
this.rx.lock().replace(rx);
|
|
559
|
+
return Ok(Some(ruby.str_from_slice(&bytes)));
|
|
560
|
+
}
|
|
561
|
+
Wait::Closed => return Ok(None),
|
|
562
|
+
Wait::Interrupted => {
|
|
563
|
+
if let Err(e) = ruby.thread_check_ints() {
|
|
564
|
+
this.rx.lock().replace(rx);
|
|
565
|
+
return Err(e);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/// How the call ended, as described in [`Outcome::into_ruby`], once
|
|
573
|
+
/// `#next` has returned nil.
|
|
574
|
+
pub fn result(ruby: &Ruby, this: &Self) -> Result<RArray, Error> {
|
|
575
|
+
let outcome = this
|
|
576
|
+
.outcome
|
|
577
|
+
.lock()
|
|
578
|
+
.take()
|
|
579
|
+
.unwrap_or_else(|| Outcome::error(Code::Cancelled, "the stream was closed before it ended"));
|
|
580
|
+
outcome.into_ruby(ruby)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/// Stop reading. A call still running is cancelled on the server.
|
|
584
|
+
pub fn close(&self) {
|
|
585
|
+
if self.pid != std::process::id() {
|
|
586
|
+
// Started by the parent: see the module docs.
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
drop(self.task.lock().take());
|
|
590
|
+
drop(self.rx.lock().take());
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
impl Drop for ResponseStream {
|
|
595
|
+
fn drop(&mut self) {
|
|
596
|
+
if self.pid != std::process::id() {
|
|
597
|
+
// The task and channel belong to the parent's runtime.
|
|
598
|
+
std::mem::forget(self.task.get_mut().take());
|
|
599
|
+
std::mem::forget(self.rx.get_mut().take());
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/// The system's root certificates as a PEM bundle, loaded on first use.
|
|
605
|
+
fn system_roots() -> Result<&'static [u8], String> {
|
|
606
|
+
static ROOTS: OnceLock<Result<Vec<u8>, String>> = OnceLock::new();
|
|
607
|
+
let roots = ROOTS.get_or_init(|| {
|
|
608
|
+
let loaded = rustls_native_certs::load_native_certs();
|
|
609
|
+
if loaded.certs.is_empty() {
|
|
610
|
+
let cause = loaded.errors.first().map(|e| format!(" ({e})")).unwrap_or_default();
|
|
611
|
+
return Err(format!(
|
|
612
|
+
"no system root certificates found{cause}; install them (e.g. the ca-certificates package) or pass tls_ca_path"
|
|
613
|
+
));
|
|
614
|
+
}
|
|
615
|
+
let mut pem = Vec::new();
|
|
616
|
+
for cert in loaded.certs {
|
|
617
|
+
pem.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n");
|
|
618
|
+
let encoded = base64::engine::general_purpose::STANDARD.encode(cert.as_ref());
|
|
619
|
+
for line in encoded.as_bytes().chunks(64) {
|
|
620
|
+
pem.extend_from_slice(line);
|
|
621
|
+
pem.push(b'\n');
|
|
622
|
+
}
|
|
623
|
+
pem.extend_from_slice(b"-----END CERTIFICATE-----\n");
|
|
624
|
+
}
|
|
625
|
+
Ok(pem)
|
|
626
|
+
});
|
|
627
|
+
roots.as_deref().map_err(Clone::clone)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/// Request metadata from a Hash of wire-form names and values (see
|
|
631
|
+
/// `Tonic::Rails::Metadata.encode`).
|
|
632
|
+
fn header_map(ruby: &Ruby, metadata: RHash) -> Result<HeaderMap, Error> {
|
|
633
|
+
let mut headers = HeaderMap::with_capacity(metadata.len());
|
|
634
|
+
metadata.foreach(|name: String, value: String| {
|
|
635
|
+
let invalid = || {
|
|
636
|
+
Error::new(
|
|
637
|
+
ruby.exception_arg_error(),
|
|
638
|
+
format!("invalid gRPC metadata {name:?}"),
|
|
639
|
+
)
|
|
640
|
+
};
|
|
641
|
+
let header = HeaderName::from_bytes(name.as_bytes()).map_err(|_| invalid())?;
|
|
642
|
+
let value = HeaderValue::from_str(&value).map_err(|_| invalid())?;
|
|
643
|
+
headers.append(header, value);
|
|
644
|
+
Ok(ForEach::Continue)
|
|
645
|
+
})?;
|
|
646
|
+
Ok(headers)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/// tonic's errors wrap the useful message (e.g. "no valid private key")
|
|
650
|
+
/// a level or two down.
|
|
651
|
+
fn source_chain(error: &dyn std::error::Error) -> String {
|
|
652
|
+
append_causes(error.to_string(), error.source())
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/// Append each cause in the chain that the message doesn't already include.
|
|
656
|
+
fn append_causes(mut message: String, mut source: Option<&dyn std::error::Error>) -> String {
|
|
657
|
+
while let Some(e) = source {
|
|
658
|
+
let cause = e.to_string();
|
|
659
|
+
if !message.contains(&cause) {
|
|
660
|
+
message = format!("{message}: {cause}");
|
|
661
|
+
}
|
|
662
|
+
source = e.source();
|
|
663
|
+
}
|
|
664
|
+
message
|
|
665
|
+
}
|
|
@@ -23,8 +23,8 @@ use tonic::Code;
|
|
|
23
23
|
use tonic::body::Body as TonicBody;
|
|
24
24
|
use tonic::transport::server::{TcpConnectInfo, TlsConnectInfo};
|
|
25
25
|
|
|
26
|
-
use crate::body::{
|
|
27
|
-
use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
|
|
26
|
+
use crate::body::{ResponseBody, ServerStats};
|
|
27
|
+
use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout, status_trailers};
|
|
28
28
|
use crate::gvl::{DispatchContext, Peer, RequestError, StreamMessage};
|
|
29
29
|
use crate::log::Level;
|
|
30
30
|
|
|
@@ -84,9 +84,16 @@ fn grpc_response(body: TonicBody, compressed: bool) -> Response<TonicBody> {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
impl DispatchInner {
|
|
87
|
+
/// A Trailers-Only response (the status in the headers, no body) for a
|
|
88
|
+
/// call rejected before it reached a handler. Clients may retry those
|
|
89
|
+
/// safely, and gRPC retries count on the form to tell them apart.
|
|
87
90
|
fn reject(&self, path: &str, start: Instant, code: Code, message: &str) -> Response<TonicBody> {
|
|
88
91
|
self.stats.record(path, code as u32, message, start, true);
|
|
89
|
-
grpc_response(TonicBody::
|
|
92
|
+
let mut response = grpc_response(TonicBody::empty(), false);
|
|
93
|
+
response
|
|
94
|
+
.headers_mut()
|
|
95
|
+
.extend(status_trailers(code as u32, message));
|
|
96
|
+
response
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
async fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
|