microsandbox-rb 0.17.2 → 0.17.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.
@@ -41,9 +41,9 @@
41
41
 
42
42
  use std::ffi::c_void;
43
43
  use std::future::Future;
44
+ use std::mem::ManuallyDrop;
44
45
  use std::panic::{catch_unwind, AssertUnwindSafe};
45
- use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
46
- use std::sync::Mutex;
46
+ use std::sync::atomic::{AtomicPtr, Ordering};
47
47
 
48
48
  use magnus::{Error, Ruby, TypedData};
49
49
  use tokio::runtime::Runtime;
@@ -57,7 +57,14 @@ pub fn ruby() -> Ruby {
57
57
  Ruby::get().expect("microsandbox: not on a Ruby thread")
58
58
  }
59
59
 
60
- // The process-wide tokio runtime, guarded by the pid it was built under. A
60
+ /// This process's id. The single `std::process::id()` call site: everything
61
+ /// fork-aware in the extension (the runtime cell below, the backend generation
62
+ /// in `backend.rs`, [`ForkGuarded`]) compares against this.
63
+ pub fn current_pid() -> u32 {
64
+ std::process::id()
65
+ }
66
+
67
+ // The process-wide tokio runtime, tagged with the pid it was built under. A
61
68
  // multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
62
69
  // ONLY the calling thread — so a child that inherits this runtime has a runtime
63
70
  // whose threads are gone: `block_on` can still drive the current thread, but the
@@ -65,16 +72,16 @@ pub fn ruby() -> Ruby {
65
72
  // connections stall/drop mid-use. This is hit in practice by forking job servers
66
73
  // (Solid Queue, Resque) and clustered web servers (Puma workers).
67
74
  //
68
- // Fix: store the runtime behind a leaked raw pointer tagged with the building
69
- // pid. If `runtime()` is called in a process whose pid differs (we forked), build
70
- // a FRESH runtime for this process and swap the pointer. The stale runtime is
71
- // LEAKED, never dropped — dropping a tokio runtime whose worker threads vanished
72
- // across fork can hang on the shutdown thread-join. One runtime is leaked per
73
- // process that uses the SDK (bounded; equivalent to the old set-once behavior in
74
- // the common no-fork case).
75
- static RUNTIME_PTR: AtomicPtr<Runtime> = AtomicPtr::new(std::ptr::null_mut());
76
- static RUNTIME_PID: AtomicU32 = AtomicU32::new(0);
77
- static RUNTIME_LOCK: Mutex<()> = Mutex::new(());
75
+ // The runtime and its pid live in ONE heap cell behind ONE atomic pointer, so a
76
+ // reader sees either a whole cell or none: there is no window in which the
77
+ // pointer is new and the pid old (or the reverse), and no mutex that a fork
78
+ // could capture locked or that a panic could poison.
79
+ struct RuntimeCell {
80
+ pid: u32,
81
+ rt: Runtime,
82
+ }
83
+
84
+ static RUNTIME: AtomicPtr<RuntimeCell> = AtomicPtr::new(std::ptr::null_mut());
78
85
 
79
86
  fn build_runtime() -> Runtime {
80
87
  tokio::runtime::Builder::new_multi_thread()
@@ -85,32 +92,111 @@ fn build_runtime() -> Runtime {
85
92
  }
86
93
 
87
94
  /// The multi-threaded tokio runtime for THIS process, built on first use and
88
- /// rebuilt after a `fork(2)` (fork-safe — see the note above).
95
+ /// rebuilt after a `fork(2)` (see the note above).
89
96
  pub fn runtime() -> &'static Runtime {
90
- let cur = std::process::id();
91
- let ptr = RUNTIME_PTR.load(Ordering::Acquire);
92
- if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
93
- // SAFETY: ptr was produced by Box::leak (valid for 'static) and matches
94
- // this process's pid, so its runtime threads are live in this process.
95
- return unsafe { &*ptr };
97
+ let cur = current_pid();
98
+ let mut observed = RUNTIME.load(Ordering::Acquire);
99
+ // SAFETY (every dereference below): a non-null pointer in `RUNTIME` came
100
+ // from `Box::into_raw` and is never freed once published, so it is valid
101
+ // for `'static`.
102
+ if let Some(cell) = unsafe { observed.as_ref() } {
103
+ if cell.pid == cur {
104
+ // Built in this process: its threads are alive here.
105
+ return &cell.rt;
106
+ }
96
107
  }
97
108
 
98
- let _guard = RUNTIME_LOCK
99
- .lock()
100
- .expect("microsandbox: runtime lock poisoned");
101
- // Re-check under the lock (another thread may have just built it).
102
- let ptr = RUNTIME_PTR.load(Ordering::Acquire);
103
- if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
104
- return unsafe { &*ptr };
109
+ // Slow path: no runtime yet, or the one we see was built by a parent
110
+ // process. Build ours and publish it with a compare-exchange.
111
+ let fresh = Box::into_raw(Box::new(RuntimeCell {
112
+ pid: cur,
113
+ rt: build_runtime(),
114
+ }));
115
+ loop {
116
+ match RUNTIME.compare_exchange(observed, fresh, Ordering::AcqRel, Ordering::Acquire) {
117
+ // Published. The cell we replaced (if any) belonged to a parent
118
+ // process and is LEAKED on purpose, never dropped: its worker
119
+ // threads did not survive the fork, and `Runtime`'s `Drop` joins
120
+ // them. One runtime is leaked per fork generation that uses the
121
+ // SDK.
122
+ Ok(_) => return unsafe { &(*fresh).rt },
123
+ Err(winner) => {
124
+ if let Some(cell) = unsafe { winner.as_ref() } {
125
+ if cell.pid == cur {
126
+ // Another thread of THIS process won the race. Our
127
+ // cell was never published and its threads are alive
128
+ // here, so dropping it is safe (and joins them).
129
+ drop(unsafe { Box::from_raw(fresh) });
130
+ return &cell.rt;
131
+ }
132
+ }
133
+ observed = winner;
134
+ }
135
+ }
105
136
  }
137
+ }
138
+
139
+ /// The Rust payload of a wrapped Ruby object, bound to the process that
140
+ /// created it.
141
+ ///
142
+ /// A handle that crosses a `fork(2)` is useless in the child and dangerous to
143
+ /// destroy there: it owns tasks, channels and connections of the parent's
144
+ /// runtime, clones of the parent's backend, the parent-watchdog pipe of a
145
+ /// sandbox. So every `#[magnus::wrap]` type keeps its payload in one of these:
146
+ ///
147
+ /// * [`get`](Self::get) — the only way to the payload for anything that acts
148
+ /// on it — fails with `Microsandbox::ForkedHandleError` in any process other
149
+ /// than the one that built the value. `Deref` is deliberately not
150
+ /// implemented: an access that skips the check should not compile.
151
+ /// * `Drop` runs the payload's destructor only in that same process. A forked
152
+ /// child that garbage-collects an inherited handle — at the latest when it
153
+ /// exits: Ruby frees every `T_DATA` object in `rb_objspace_call_finalizer` —
154
+ /// leaks it instead.
155
+ pub struct ForkGuarded<T> {
156
+ pid: u32,
157
+ /// The Ruby class users know the handle by, for the error message.
158
+ class: &'static str,
159
+ inner: ManuallyDrop<T>,
160
+ }
106
161
 
107
- // Build a fresh runtime for this process and publish it. The previous pointer
108
- // (if any) belonged to a parent process and is intentionally leaked, not
109
- // dropped (its threads are gone post-fork; Drop would block on join).
110
- let rt: &'static Runtime = Box::leak(Box::new(build_runtime()));
111
- RUNTIME_PTR.store(rt as *const Runtime as *mut Runtime, Ordering::Release);
112
- RUNTIME_PID.store(cur, Ordering::Release);
113
- rt
162
+ impl<T> ForkGuarded<T> {
163
+ pub fn new(class: &'static str, inner: T) -> Self {
164
+ Self {
165
+ pid: current_pid(),
166
+ class,
167
+ inner: ManuallyDrop::new(inner),
168
+ }
169
+ }
170
+
171
+ /// The payload, or `ForkedHandleError` when this is not the process that
172
+ /// created it.
173
+ pub fn get(&self) -> Result<&T, Error> {
174
+ let cur = current_pid();
175
+ if self.pid == cur {
176
+ Ok(&self.inner)
177
+ } else {
178
+ Err(error::forked_handle(self.class, self.pid, cur))
179
+ }
180
+ }
181
+
182
+ /// The payload without the process check — ONLY for accessors that copy
183
+ /// plain data out of the value's own memory (a name, an id, a cached
184
+ /// status). Those stay readable in a forked child, which needs e.g. the
185
+ /// name to open its own handle. Never use this for anything that takes a
186
+ /// lock, touches a file descriptor, or reaches the runtime or the backend.
187
+ pub fn peek(&self) -> &T {
188
+ &self.inner
189
+ }
190
+ }
191
+
192
+ impl<T> Drop for ForkGuarded<T> {
193
+ fn drop(&mut self) {
194
+ if self.pid == current_pid() {
195
+ // SAFETY: dropped exactly once, here; `inner` is not used again.
196
+ unsafe { ManuallyDrop::drop(&mut self.inner) };
197
+ }
198
+ // Otherwise: inherited across a fork. Leak it (see the type docs).
199
+ }
114
200
  }
115
201
 
116
202
  /// Resolve the Ruby class of a `#[magnus::wrap]` type now, at extension load.
@@ -301,12 +387,17 @@ where
301
387
 
302
388
  /// Drive a future to completion on the shared runtime with the GVL released.
303
389
  ///
304
- /// `Err` means the future was never polled (see [`nogvl`]); propagate it with
305
- /// `?` before looking at the future's own output:
390
+ /// `Err` means the future was never polled (see [`nogvl`], and the fork check
391
+ /// below); propagate it with `?` before looking at the future's own output:
306
392
  /// `block_on(fut)?.map_err(error::to_ruby)?`.
307
393
  pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
308
394
  where
309
395
  F: Future,
310
396
  {
397
+ // Many core operations resolve the process-wide default backend while they
398
+ // run. After a fork that backend is the parent's; replace it now, while we
399
+ // still hold the GVL (the check takes a mutex and may build a Ruby
400
+ // exception — neither is allowed once the GVL is released).
401
+ crate::backend::ensure_this_process_backend()?;
311
402
  nogvl(|| runtime().block_on(fut))
312
403
  }