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.
@@ -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
+ }