quietquic 0.1.0.alpha.3
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/HISTORY.md +79 -0
- data/LICENSE +13 -0
- data/README.md +318 -0
- data/examples/cooperative_async.rb +91 -0
- data/examples/echo_client.rb +40 -0
- data/examples/echo_server.rb +48 -0
- data/ext/quietquic/Cargo.lock +1377 -0
- data/ext/quietquic/Cargo.toml +25 -0
- data/ext/quietquic/extconf.rb +7 -0
- data/ext/quietquic/src/client.rs +89 -0
- data/ext/quietquic/src/config.rs +292 -0
- data/ext/quietquic/src/conn.rs +283 -0
- data/ext/quietquic/src/errors.rs +140 -0
- data/ext/quietquic/src/lib.rs +59 -0
- data/ext/quietquic/src/pending.rs +386 -0
- data/ext/quietquic/src/runtime.rs +72 -0
- data/ext/quietquic/src/server.rs +259 -0
- data/lib/quietquic/await.rb +22 -0
- data/lib/quietquic/client.rb +40 -0
- data/lib/quietquic/connection.rb +55 -0
- data/lib/quietquic/server.rb +70 -0
- data/lib/quietquic/stream.rb +79 -0
- data/lib/quietquic/version.rb +4 -0
- data/lib/quietquic.rb +8 -0
- metadata +125 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
//! [`PendingOp`]: the bridge object handed to Ruby for an in-flight async op.
|
|
3
|
+
//!
|
|
4
|
+
//! Mechanism:
|
|
5
|
+
//! - On start we create a self-pipe (`pipe(2)`). Rust owns both ends.
|
|
6
|
+
//! - The future is spawned on the shared tokio runtime. On completion the task
|
|
7
|
+
//! (1) stores a *result producer* into a `Mutex<Option<..>>` slot, then (2)
|
|
8
|
+
//! writes one byte to the pipe write end. The write establishes a
|
|
9
|
+
//! happens-before edge: once the reader observes the fd readable, the slot is
|
|
10
|
+
//! populated.
|
|
11
|
+
//! - `#fd` returns the *read* end as an `Integer`. Ruby waits on it with
|
|
12
|
+
//! `IO#wait_readable`, which is fiber-scheduler aware (Ruby >= 3.0): under
|
|
13
|
+
//! `Async` it parks the fiber; otherwise it blocks with the GVL released.
|
|
14
|
+
//! - `#take_result` locks the slot, takes the producer, and runs it **on the
|
|
15
|
+
//! Ruby thread** to build the Ruby `Value` (or raise the mapped exception).
|
|
16
|
+
//! - `#close` (and `Drop`) abort the tokio task and close both fds — no leak.
|
|
17
|
+
//! The write fd is shared with the task (`Arc<Mutex<i32>>`) and both `wake`
|
|
18
|
+
//! and dispose operate on it only under that lock, so the wake-write and the
|
|
19
|
+
//! dispose-close are mutually exclusive: a wake racing a close either writes
|
|
20
|
+
//! before close, or sees the `-1` close swapped in and skips — never writing
|
|
21
|
+
//! to a closed (and possibly reused) fd number.
|
|
22
|
+
//!
|
|
23
|
+
//! ## FFI thread-safety invariant
|
|
24
|
+
//! Ruby `Value`s may only be created on the Ruby VM thread. The spawned future
|
|
25
|
+
//! therefore yields **plain Rust data** (`Result<T, MappedError>`); the stored
|
|
26
|
+
//! producer closure captures that data plus a `to_ruby` converter and is only
|
|
27
|
+
//! ever invoked inside `take_result`, on the Ruby thread. No `magnus` value is
|
|
28
|
+
//! constructed on a tokio worker thread.
|
|
29
|
+
|
|
30
|
+
use std::future::Future;
|
|
31
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
32
|
+
use std::sync::{Arc, Mutex};
|
|
33
|
+
|
|
34
|
+
use magnus::{Error, IntoValue, Ruby, Value};
|
|
35
|
+
use tokio::task::JoinHandle;
|
|
36
|
+
|
|
37
|
+
use crate::errors::{ErrorKind, MappedError};
|
|
38
|
+
use crate::runtime::runtime;
|
|
39
|
+
|
|
40
|
+
/// A closure that converts the task's stored outcome into a Ruby value (or a
|
|
41
|
+
/// mapped exception). Built on the tokio thread from pure Rust data, but only
|
|
42
|
+
/// *called* on the Ruby thread inside `take_result`.
|
|
43
|
+
type ResultProducer = Box<dyn FnOnce(&Ruby) -> Result<Value, Error> + Send>;
|
|
44
|
+
|
|
45
|
+
/// Result slot shared between the tokio task and the Ruby thread.
|
|
46
|
+
/// `None` = not yet complete. `Some(producer)` = completed; run the producer on
|
|
47
|
+
/// the Ruby thread to obtain the value (or raise the mapped error).
|
|
48
|
+
type Slot = Arc<Mutex<Option<ResultProducer>>>;
|
|
49
|
+
|
|
50
|
+
/// Disposal-guarded write end of the self-pipe, shared between the tokio task
|
|
51
|
+
/// (which writes the wake byte) and the Ruby thread (which closes it on
|
|
52
|
+
/// dispose). Holding the lock makes wake-write and dispose-close mutually
|
|
53
|
+
/// exclusive so they can never touch the same fd number concurrently: a
|
|
54
|
+
/// concurrent wake either writes-then-dispose-closes, or observes `-1` (already
|
|
55
|
+
/// swapped out by dispose) and skips the write entirely. Set to `-1` once
|
|
56
|
+
/// disposed.
|
|
57
|
+
type WriteFd = Arc<Mutex<i32>>;
|
|
58
|
+
|
|
59
|
+
#[magnus::wrap(class = "QuietQUIC::Native::PendingOp", free_immediately, size)]
|
|
60
|
+
pub(crate) struct PendingOp {
|
|
61
|
+
/// Read end of the self-pipe. Set to `-1` once disposed.
|
|
62
|
+
read_fd: Mutex<i32>,
|
|
63
|
+
/// Write end of the self-pipe (see [`WriteFd`]). Shared with the tokio task.
|
|
64
|
+
write_fd: WriteFd,
|
|
65
|
+
/// Set once `close`/`Drop` has released the fds + aborted the task, so the
|
|
66
|
+
/// operations are idempotent and `Drop` never double-closes after `close`.
|
|
67
|
+
disposed: AtomicBool,
|
|
68
|
+
slot: Slot,
|
|
69
|
+
handle: JoinHandle<()>,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
impl PendingOp {
|
|
73
|
+
/// Spawn `future` (yielding `Result<T, MappedError>`) on the shared runtime,
|
|
74
|
+
/// returning a `PendingOp` whose fd becomes readable when it resolves. The
|
|
75
|
+
/// `to_ruby` converter runs later, on the Ruby thread, inside `take_result`.
|
|
76
|
+
///
|
|
77
|
+
/// This is the generic op-spawning primitive used by every quietquic async
|
|
78
|
+
/// operation; only `future` and `to_ruby` change per op.
|
|
79
|
+
pub(crate) fn spawn_op<F, T>(future: F, to_ruby: fn(T) -> Value) -> Result<Self, Error>
|
|
80
|
+
where
|
|
81
|
+
F: Future<Output = Result<T, MappedError>> + Send + 'static,
|
|
82
|
+
T: Send + 'static,
|
|
83
|
+
{
|
|
84
|
+
// Create the self-pipe. `pipe(2)` is portable across macOS / FreeBSD /
|
|
85
|
+
// Linux (unlike `pipe2`, which is absent on macOS). fds[0] = read end,
|
|
86
|
+
// fds[1] = write end.
|
|
87
|
+
let mut fds: [i32; 2] = [0; 2];
|
|
88
|
+
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
|
|
89
|
+
if rc != 0 {
|
|
90
|
+
return Err(setup_error(&format!(
|
|
91
|
+
"pipe failed: {}",
|
|
92
|
+
std::io::Error::last_os_error()
|
|
93
|
+
)));
|
|
94
|
+
}
|
|
95
|
+
let read_fd = fds[0];
|
|
96
|
+
let write_fd = fds[1];
|
|
97
|
+
|
|
98
|
+
// Set FD_CLOEXEC on both ends so the fds don't leak into forked
|
|
99
|
+
// children. CLOEXEC is security-relevant, so a failure is fatal to op
|
|
100
|
+
// setup: close the fds we opened and raise rather than proceed.
|
|
101
|
+
if let Err(e) = set_cloexec(read_fd).and_then(|()| set_cloexec(write_fd)) {
|
|
102
|
+
unsafe {
|
|
103
|
+
libc::close(read_fd);
|
|
104
|
+
libc::close(write_fd);
|
|
105
|
+
}
|
|
106
|
+
return Err(setup_error(&e));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Guard against a fork(2) between runtime init and this spawn: the
|
|
110
|
+
// global runtime's worker threads do not survive a fork, so a spawn from
|
|
111
|
+
// a post-fork child would never run — the await would hang forever.
|
|
112
|
+
// Detect it here (on the Ruby thread, before spawning) and raise a clear
|
|
113
|
+
// `QuietQUIC::Error` instead. Close the raw fds we just opened first.
|
|
114
|
+
if let Err(e) = crate::runtime::ensure_same_process() {
|
|
115
|
+
unsafe {
|
|
116
|
+
libc::close(read_fd);
|
|
117
|
+
libc::close(write_fd);
|
|
118
|
+
}
|
|
119
|
+
return Err(e);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let slot: Slot = Arc::new(Mutex::new(None));
|
|
123
|
+
let task_slot = Arc::clone(&slot);
|
|
124
|
+
|
|
125
|
+
// The write fd is shared, not copied, into the task. `wake` writes to it
|
|
126
|
+
// only while holding this lock and only if it is still valid (>= 0), so
|
|
127
|
+
// dispose (which swaps it to -1 under the same lock before closing) and
|
|
128
|
+
// wake are mutually exclusive — no stray write into a reused fd number.
|
|
129
|
+
let write_fd: WriteFd = Arc::new(Mutex::new(write_fd));
|
|
130
|
+
let task_write_fd = Arc::clone(&write_fd);
|
|
131
|
+
|
|
132
|
+
// Spawn onto the shared runtime. On completion: store then signal.
|
|
133
|
+
let handle = runtime().spawn(async move {
|
|
134
|
+
// Catch a panic inside the op's future so it becomes a stored error
|
|
135
|
+
// + a wake, instead of leaving the slot empty (which would park the
|
|
136
|
+
// awaiting fiber/thread forever). `AssertUnwindSafe` is sound here:
|
|
137
|
+
// on the unwind path we discard the payload and produce a fresh
|
|
138
|
+
// `MappedError` — no possibly-inconsistent captured state is
|
|
139
|
+
// observed across the catch.
|
|
140
|
+
use futures_util::future::FutureExt;
|
|
141
|
+
let outcome = match std::panic::AssertUnwindSafe(future).catch_unwind().await {
|
|
142
|
+
Ok(outcome) => outcome,
|
|
143
|
+
Err(_panic_payload) => Err(MappedError::new(
|
|
144
|
+
ErrorKind::Base,
|
|
145
|
+
"quietquic internal error: the async operation panicked",
|
|
146
|
+
)),
|
|
147
|
+
};
|
|
148
|
+
// Build the *producer* on this (tokio) thread from plain Rust data
|
|
149
|
+
// only. It captures `outcome` + `to_ruby` and defers all Ruby-value
|
|
150
|
+
// creation to when it is called on the Ruby thread.
|
|
151
|
+
let producer: ResultProducer = Box::new(move |_ruby: &Ruby| match outcome {
|
|
152
|
+
Ok(value) => Ok(to_ruby(value)),
|
|
153
|
+
Err(mapped) => Err(mapped.into_ruby_error(_ruby)),
|
|
154
|
+
});
|
|
155
|
+
// Store the producer BEFORE writing the wake byte so the reader
|
|
156
|
+
// always sees a populated slot once the fd is readable.
|
|
157
|
+
{
|
|
158
|
+
let mut guard = task_slot.lock().unwrap();
|
|
159
|
+
*guard = Some(producer);
|
|
160
|
+
}
|
|
161
|
+
wake(&task_write_fd);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
Ok(PendingOp {
|
|
165
|
+
read_fd: Mutex::new(read_fd),
|
|
166
|
+
write_fd,
|
|
167
|
+
disposed: AtomicBool::new(false),
|
|
168
|
+
slot,
|
|
169
|
+
handle,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// The read end of the self-pipe, as an integer fd. Becomes readable when
|
|
174
|
+
/// the operation completes. Returns `-1` once disposed.
|
|
175
|
+
fn fd(&self) -> i32 {
|
|
176
|
+
*self.read_fd.lock().unwrap()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Take and run the completed result producer on the Ruby thread, returning
|
|
180
|
+
/// the Ruby value or raising the mapped `QuietQUIC::*` exception. Raises if
|
|
181
|
+
/// the op has not completed yet, or if it was already taken (drained).
|
|
182
|
+
fn take_result(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
|
|
183
|
+
let producer = {
|
|
184
|
+
let mut guard = rb_self.slot.lock().unwrap();
|
|
185
|
+
guard.take()
|
|
186
|
+
};
|
|
187
|
+
match producer {
|
|
188
|
+
Some(producer) => producer(ruby),
|
|
189
|
+
None => Err(Error::new(
|
|
190
|
+
ruby.exception_runtime_error(),
|
|
191
|
+
"take_result called before completion (or already taken)",
|
|
192
|
+
)),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Abort the task and close both fds immediately. Idempotent: safe to call
|
|
197
|
+
/// twice, and `Drop` will not double-close after an explicit `close`.
|
|
198
|
+
fn close(&self) {
|
|
199
|
+
self.dispose();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Shared disposal path for `close` and `Drop`.
|
|
203
|
+
fn dispose(&self) {
|
|
204
|
+
// Ensure the abort+close body runs exactly once.
|
|
205
|
+
if self.disposed.swap(true, Ordering::AcqRel) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Abort the tokio task if still running (best-effort; can't preempt a
|
|
209
|
+
// synchronous `wake` already past its `.await`).
|
|
210
|
+
self.handle.abort();
|
|
211
|
+
// Close the write fd under its lock so a concurrent `wake` either wrote
|
|
212
|
+
// its byte before we swap (and we close after), or observes the -1 we
|
|
213
|
+
// swapped in and skips the write. Either way, no write lands on the fd
|
|
214
|
+
// number after we close it. Swap+close happen while holding the lock.
|
|
215
|
+
{
|
|
216
|
+
let mut guard = self.write_fd.lock().unwrap();
|
|
217
|
+
let write_fd = std::mem::replace(&mut *guard, -1);
|
|
218
|
+
if write_fd >= 0 {
|
|
219
|
+
unsafe {
|
|
220
|
+
libc::close(write_fd);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Swap the read fd out to -1 under its lock, then close what we took.
|
|
225
|
+
let read_fd = std::mem::replace(&mut *self.read_fd.lock().unwrap(), -1);
|
|
226
|
+
unsafe {
|
|
227
|
+
if read_fd >= 0 {
|
|
228
|
+
libc::close(read_fd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
impl Drop for PendingOp {
|
|
235
|
+
fn drop(&mut self) {
|
|
236
|
+
self.dispose();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Set `FD_CLOEXEC` on `fd`, preserving any existing flags. Returns a message
|
|
241
|
+
/// on failure so the caller can turn it into a setup error.
|
|
242
|
+
fn set_cloexec(fd: i32) -> Result<(), String> {
|
|
243
|
+
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
|
|
244
|
+
if flags < 0 {
|
|
245
|
+
return Err(format!(
|
|
246
|
+
"fcntl(F_GETFD) failed: {}",
|
|
247
|
+
std::io::Error::last_os_error()
|
|
248
|
+
));
|
|
249
|
+
}
|
|
250
|
+
let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
|
|
251
|
+
if rc < 0 {
|
|
252
|
+
return Err(format!(
|
|
253
|
+
"fcntl(F_SETFD, FD_CLOEXEC) failed: {}",
|
|
254
|
+
std::io::Error::last_os_error()
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
Ok(())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Write one wake byte to the shared write fd, retrying on EINTR. Called on the
|
|
261
|
+
/// tokio thread after the slot is populated.
|
|
262
|
+
///
|
|
263
|
+
/// The lock is held for the duration of the write so it is mutually exclusive
|
|
264
|
+
/// with `dispose`'s swap-to-`-1`-then-close: if we get the lock first we write
|
|
265
|
+
/// to a still-open fd and dispose closes it afterwards; if dispose got it first
|
|
266
|
+
/// the fd is already `-1` and we skip entirely — never writing to a closed (and
|
|
267
|
+
/// possibly reused) fd number. The write is a single-byte, non-blocking write on
|
|
268
|
+
/// a self-pipe, so the lock is held only briefly.
|
|
269
|
+
fn wake(write_fd: &WriteFd) {
|
|
270
|
+
let guard = write_fd.lock().unwrap();
|
|
271
|
+
let fd = *guard;
|
|
272
|
+
if fd < 0 {
|
|
273
|
+
// Already disposed; the reader is gone. Nothing to do.
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
let byte: [u8; 1] = [1];
|
|
277
|
+
loop {
|
|
278
|
+
let n = unsafe { libc::write(fd, byte.as_ptr() as *const libc::c_void, 1) };
|
|
279
|
+
if n == 1 {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
if n < 0 {
|
|
283
|
+
let e = std::io::Error::last_os_error();
|
|
284
|
+
if e.raw_os_error() == Some(libc::EINTR) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Write end broken (e.g. reader closed). Nothing to do.
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
// n == 0 shouldn't happen for a 1-byte write; retry.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Build a magnus setup error (raised on the Ruby thread during op creation).
|
|
295
|
+
fn setup_error(message: &str) -> Error {
|
|
296
|
+
let ruby = Ruby::get().expect("PendingOp setup called off a Ruby thread");
|
|
297
|
+
Error::new(ruby.exception_runtime_error(), message.to_string())
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// `QuietQUIC::Native.sleep_op(millis) -> PendingOp`.
|
|
301
|
+
/// Spawns `tokio::time::sleep(millis)`, then yields `millis` as an integer.
|
|
302
|
+
pub(crate) fn sleep_op(millis: u64) -> Result<PendingOp, Error> {
|
|
303
|
+
PendingOp::spawn_op(
|
|
304
|
+
async move {
|
|
305
|
+
tokio::time::sleep(std::time::Duration::from_millis(millis)).await;
|
|
306
|
+
Ok::<i64, MappedError>(millis as i64)
|
|
307
|
+
},
|
|
308
|
+
|millis: i64| {
|
|
309
|
+
let ruby = Ruby::get().expect("to_ruby runs on the Ruby thread");
|
|
310
|
+
millis.into_value_with(&ruby)
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// `QuietQUIC::Native.echo_op(str) -> PendingOp`.
|
|
316
|
+
/// Yields the given string back through the generalized slot (a `String`
|
|
317
|
+
/// payload exercises the byte/text conversion path Tasks 4-8 will use).
|
|
318
|
+
pub(crate) fn echo_op(s: String) -> Result<PendingOp, Error> {
|
|
319
|
+
PendingOp::spawn_op(
|
|
320
|
+
async move { Ok::<String, MappedError>(s) },
|
|
321
|
+
|s: String| {
|
|
322
|
+
let ruby = Ruby::get().expect("to_ruby runs on the Ruby thread");
|
|
323
|
+
s.into_value_with(&ruby)
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// `QuietQUIC::Native.nil_op -> PendingOp`.
|
|
329
|
+
/// Yields `nil` through the generalized slot (the unit/`nil` payload path).
|
|
330
|
+
pub(crate) fn nil_op() -> Result<PendingOp, Error> {
|
|
331
|
+
PendingOp::spawn_op(
|
|
332
|
+
async move { Ok::<(), MappedError>(()) },
|
|
333
|
+
|_unit: ()| {
|
|
334
|
+
let ruby = Ruby::get().expect("to_ruby runs on the Ruby thread");
|
|
335
|
+
ruby.qnil().into_value_with(&ruby)
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// `QuietQUIC::Native.fail_op(kind) -> PendingOp`.
|
|
341
|
+
/// Test helper: yields a `MappedError` classified by `kind`, so `take_result`
|
|
342
|
+
/// raises the corresponding `QuietQUIC::*` exception. Exercises the error path.
|
|
343
|
+
pub(crate) fn fail_op(kind: String) -> Result<PendingOp, Error> {
|
|
344
|
+
let error_kind = ErrorKind::from_str(&kind);
|
|
345
|
+
let message = format!("simulated {kind} failure");
|
|
346
|
+
PendingOp::spawn_op(
|
|
347
|
+
async move { Err::<(), MappedError>(MappedError::new(error_kind, message)) },
|
|
348
|
+
// Never reached for the error path, but the type must line up.
|
|
349
|
+
|_unit: ()| {
|
|
350
|
+
let ruby = Ruby::get().expect("to_ruby runs on the Ruby thread");
|
|
351
|
+
ruby.qnil().into_value_with(&ruby)
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// `QuietQUIC::Native.panic_op -> PendingOp` (dev/test helper).
|
|
357
|
+
/// Spawns a future that `panic!`s. Exercises the panic-catch path: awaiting it
|
|
358
|
+
/// must raise `QuietQUIC::Error` (never hang), because the spawn wrapper turns
|
|
359
|
+
/// a panic into a stored error + a wake byte.
|
|
360
|
+
pub(crate) fn panic_op() -> Result<PendingOp, Error> {
|
|
361
|
+
PendingOp::spawn_op(
|
|
362
|
+
async move {
|
|
363
|
+
panic!("simulated async-op panic");
|
|
364
|
+
#[allow(unreachable_code)]
|
|
365
|
+
Ok::<(), MappedError>(())
|
|
366
|
+
},
|
|
367
|
+
// Never reached: the panic path stores a base-class MappedError.
|
|
368
|
+
|_unit: ()| {
|
|
369
|
+
let ruby = Ruby::get().expect("to_ruby runs on the Ruby thread");
|
|
370
|
+
ruby.qnil().into_value_with(&ruby)
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Register `PendingOp` and its instance methods on `QuietQUIC::Native`.
|
|
376
|
+
pub(crate) fn init(ruby: &Ruby, native: &magnus::RModule) -> Result<(), Error> {
|
|
377
|
+
use magnus::{method, Module};
|
|
378
|
+
|
|
379
|
+
let class = native.define_class("PendingOp", ruby.class_object())?;
|
|
380
|
+
class.define_method("fd", method!(PendingOp::fd, 0))?;
|
|
381
|
+
class.define_method("take_result", method!(PendingOp::take_result, 0))?;
|
|
382
|
+
class.define_method("close", method!(PendingOp::close, 0))?;
|
|
383
|
+
// `dispose` is an alias for `close` (explicit-disposal API).
|
|
384
|
+
class.define_method("dispose", method!(PendingOp::close, 0))?;
|
|
385
|
+
Ok(())
|
|
386
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-License-Identifier: 0BSD
|
|
2
|
+
//! Process-global multi-thread tokio runtime shared by all async operations.
|
|
3
|
+
//!
|
|
4
|
+
//! The runtime is created lazily on first use and lives for the life of the
|
|
5
|
+
//! process. Ruby threads spawn futures onto it and wait for completion via a
|
|
6
|
+
//! self-pipe fd (see [`crate::pending`]).
|
|
7
|
+
//!
|
|
8
|
+
//! ## Fork safety
|
|
9
|
+
//! A tokio multi-thread runtime's worker threads do **not** survive `fork(2)`:
|
|
10
|
+
//! only the forking thread is duplicated into the child, so any work spawned
|
|
11
|
+
//! from a child that inherited an already-initialized runtime would never run
|
|
12
|
+
//! and the awaiting fiber/thread would hang forever. This is the classic
|
|
13
|
+
//! pre-forking-server (Puma/Unicorn/Spring) hazard. To turn that silent hang
|
|
14
|
+
//! into an actionable error we record the PID at runtime initialization and
|
|
15
|
+
//! check it before every spawn (see [`ensure_same_process`]); a child that
|
|
16
|
+
//! inherited the parent's runtime gets a clear `QuietQUIC::Error` telling it
|
|
17
|
+
//! to initialize the runtime *after* forking.
|
|
18
|
+
|
|
19
|
+
use std::sync::atomic::{AtomicI32, Ordering};
|
|
20
|
+
use std::sync::OnceLock;
|
|
21
|
+
|
|
22
|
+
use magnus::{Error, Ruby};
|
|
23
|
+
use tokio::runtime::{Builder, Runtime};
|
|
24
|
+
|
|
25
|
+
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
|
26
|
+
|
|
27
|
+
/// PID of the process that first initialized the runtime. `-1` until set.
|
|
28
|
+
/// Recorded once, inside the `OnceLock` initializer, so it always reflects the
|
|
29
|
+
/// process whose worker threads actually back the runtime.
|
|
30
|
+
static INIT_PID: AtomicI32 = AtomicI32::new(-1);
|
|
31
|
+
|
|
32
|
+
/// Return a reference to the process-global tokio runtime, initializing it on
|
|
33
|
+
/// first call. Subsequent calls return the same runtime.
|
|
34
|
+
pub(crate) fn runtime() -> &'static Runtime {
|
|
35
|
+
RUNTIME.get_or_init(|| {
|
|
36
|
+
// Record the PID of the process that owns the runtime's worker threads.
|
|
37
|
+
// Done inside the initializer so it is set exactly once, together with
|
|
38
|
+
// the threads it describes.
|
|
39
|
+
INIT_PID.store(unsafe { libc::getpid() }, Ordering::SeqCst);
|
|
40
|
+
Builder::new_multi_thread()
|
|
41
|
+
.enable_all()
|
|
42
|
+
.thread_name("quietquic-rt")
|
|
43
|
+
.build()
|
|
44
|
+
.expect("failed to build quietquic tokio runtime")
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Verify the current process is the one that initialized the runtime.
|
|
49
|
+
///
|
|
50
|
+
/// Called on the Ruby thread just before spawning an op. Forcing initialization
|
|
51
|
+
/// here (via [`runtime`]) means a fresh child that never touched the runtime
|
|
52
|
+
/// initializes it in *its own* process — no false positive; only a child that
|
|
53
|
+
/// inherited an already-built runtime from its parent trips the guard.
|
|
54
|
+
///
|
|
55
|
+
/// On mismatch, returns a `QuietQUIC::Error` (the base class) rather than
|
|
56
|
+
/// letting the spawn silently hang forever on a dead runtime.
|
|
57
|
+
pub(crate) fn ensure_same_process() -> Result<(), Error> {
|
|
58
|
+
// Ensure the runtime (and thus INIT_PID) is initialized in *some* process.
|
|
59
|
+
let _ = runtime();
|
|
60
|
+
let init_pid = INIT_PID.load(Ordering::SeqCst);
|
|
61
|
+
let current = unsafe { libc::getpid() };
|
|
62
|
+
if init_pid != current {
|
|
63
|
+
let ruby = Ruby::get().expect("ensure_same_process runs on a Ruby thread");
|
|
64
|
+
return Err(Error::new(
|
|
65
|
+
crate::errors::base_error_class(&ruby),
|
|
66
|
+
"quietquic's async runtime does not survive fork(); initialize it \
|
|
67
|
+
after forking (e.g. in a Puma on_worker_boot / after_fork hook) \
|
|
68
|
+
rather than before",
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
Ok(())
|
|
72
|
+
}
|