microsandbox-rb 0.17.1 → 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.
@@ -16,24 +16,24 @@ use microsandbox::sandbox::{FsReadStream, FsWriteSink};
16
16
  use tokio::sync::Mutex;
17
17
 
18
18
  use crate::error;
19
- use crate::runtime::{block_on, ruby};
19
+ use crate::runtime::{block_on, ruby, ForkGuarded};
20
20
 
21
21
  #[magnus::wrap(class = "Microsandbox::Native::FsReadStream", free_immediately, size)]
22
22
  pub struct FsReadStreamHandle {
23
- inner: Arc<Mutex<FsReadStream>>,
23
+ inner: ForkGuarded<Arc<Mutex<FsReadStream>>>,
24
24
  }
25
25
 
26
26
  impl FsReadStreamHandle {
27
27
  pub fn new(stream: FsReadStream) -> Self {
28
28
  Self {
29
- inner: Arc::new(Mutex::new(stream)),
29
+ inner: ForkGuarded::new("Microsandbox::FsReadStream", Arc::new(Mutex::new(stream))),
30
30
  }
31
31
  }
32
32
 
33
33
  /// Next chunk of bytes (ASCII-8BIT), or nil at end of stream.
34
34
  fn recv(&self) -> Result<Option<RString>, Error> {
35
- let inner = Arc::clone(&self.inner);
36
- match block_on(async move { inner.lock().await.recv().await }).map_err(error::to_ruby)? {
35
+ let inner = Arc::clone(self.inner.get()?);
36
+ match block_on(async move { inner.lock().await.recv().await })?.map_err(error::to_ruby)? {
37
37
  Some(bytes) => Ok(Some(ruby().str_from_slice(bytes.as_ref()))),
38
38
  None => Ok(None),
39
39
  }
@@ -42,13 +42,16 @@ impl FsReadStreamHandle {
42
42
 
43
43
  #[magnus::wrap(class = "Microsandbox::Native::FsWriteSink", free_immediately, size)]
44
44
  pub struct FsWriteSinkHandle {
45
- inner: Arc<Mutex<Option<FsWriteSink>>>,
45
+ inner: ForkGuarded<Arc<Mutex<Option<FsWriteSink>>>>,
46
46
  }
47
47
 
48
48
  impl FsWriteSinkHandle {
49
49
  pub fn new(sink: FsWriteSink) -> Self {
50
50
  Self {
51
- inner: Arc::new(Mutex::new(Some(sink))),
51
+ inner: ForkGuarded::new(
52
+ "Microsandbox::FsWriteSink",
53
+ Arc::new(Mutex::new(Some(sink))),
54
+ ),
52
55
  }
53
56
  }
54
57
 
@@ -56,14 +59,14 @@ impl FsWriteSinkHandle {
56
59
  fn write(&self, data: RString) -> Result<(), Error> {
57
60
  // Copy out while the GVL is held (GC.compact could move the buffer).
58
61
  let bytes = unsafe { data.as_slice() }.to_vec();
59
- let inner = Arc::clone(&self.inner);
62
+ let inner = Arc::clone(self.inner.get()?);
60
63
  let result = block_on(async move {
61
64
  let guard = inner.lock().await;
62
65
  match guard.as_ref() {
63
66
  Some(sink) => Some(sink.write(&bytes).await),
64
67
  None => None,
65
68
  }
66
- });
69
+ })?;
67
70
  match result {
68
71
  Some(r) => r.map_err(error::to_ruby),
69
72
  None => Err(error::base_error("write to a closed FsWriteSink")),
@@ -72,20 +75,32 @@ impl FsWriteSinkHandle {
72
75
 
73
76
  /// Flush and close the sink. Idempotent.
74
77
  fn close(&self) -> Result<(), Error> {
75
- let inner = Arc::clone(&self.inner);
76
- let taken = block_on(async move { inner.lock().await.take() });
77
- match taken {
78
- Some(sink) => block_on(sink.close()).map_err(error::to_ruby),
79
- None => Ok(()),
80
- }
78
+ let inner = Arc::clone(self.inner.get()?);
79
+ // Take AND close in ONE `block_on`. `block_on` returns `Err` when an
80
+ // interrupt is delivered before it starts; as two calls, an interrupt
81
+ // between them would drop the sink the first one took without ever
82
+ // sending its EOF (the guest file is left unfinished, and the handle
83
+ // can no longer close it).
84
+ block_on(async move {
85
+ let taken = inner.lock().await.take();
86
+ match taken {
87
+ Some(sink) => sink.close().await,
88
+ None => Ok(()),
89
+ }
90
+ })?
91
+ .map_err(error::to_ruby)
81
92
  }
82
93
  }
83
94
 
84
95
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
85
96
  let rs = native.define_class("FsReadStream", ruby.class_object())?;
97
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
98
+ crate::runtime::preload_class::<FsReadStreamHandle>(ruby);
86
99
  rs.define_method("recv", method!(FsReadStreamHandle::recv, 0))?;
87
100
 
88
101
  let ws = native.define_class("FsWriteSink", ruby.class_object())?;
102
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
103
+ crate::runtime::preload_class::<FsWriteSinkHandle>(ruby);
89
104
  ws.define_method("write", method!(FsWriteSinkHandle::write, 1))?;
90
105
  ws.define_method("close", method!(FsWriteSinkHandle::close, 0))?;
91
106
  Ok(())
@@ -47,10 +47,11 @@ pub struct HostPaths {
47
47
 
48
48
  impl HostPaths {
49
49
  /// Capture which backend the create call is about to dispatch to.
50
- pub fn for_default_backend() -> Self {
51
- Self {
50
+ pub fn for_default_backend() -> Result<Self, Error> {
51
+ crate::backend::ensure_this_process_backend()?;
52
+ Ok(Self {
52
53
  local: matches!(microsandbox::default_backend().kind(), BackendKind::Local),
53
- }
54
+ })
54
55
  }
55
56
 
56
57
  /// Anchor a relative host path to the current directory. Absolute paths
@@ -48,8 +48,8 @@ fn detail_to_hash(detail: ImageDetail) -> RHash {
48
48
  let _ = c.aset("user", config.user);
49
49
  // OCI config labels: a free-form JSON object (or nil). Converted to a
50
50
  // Ruby Hash so `ImageDetail#config["labels"]` matches the Python/Node
51
- // `config.labels` dict. (The pinned v0.5.8 runtime persists this as
52
- // null today; the key exists for forward-compatibility/parity.)
51
+ // `config.labels` dict. (The runtime persisted this as null when the
52
+ // key was added, at v0.5.8; it exists for forward-compatibility/parity.)
53
53
  let _ = c.aset("labels", config.labels.as_ref().map(conv::json_to_ruby));
54
54
  let _ = c.aset("stop_signal", config.stop_signal);
55
55
  let _ = hash.aset("config", c);
@@ -47,7 +47,7 @@ fn all_sandbox_metrics() -> Result<RHash, Error> {
47
47
 
48
48
  /// Download and install the `msb` runtime + `libkrunfw` into `~/.microsandbox`.
49
49
  fn install() -> Result<(), Error> {
50
- runtime::block_on(microsandbox::setup::install()).map_err(error::to_ruby)
50
+ runtime::block_on(microsandbox::setup::install())?.map_err(error::to_ruby)
51
51
  }
52
52
 
53
53
  /// Customizable install via the core `Setup` builder. `opts`: base_dir (install
@@ -84,7 +84,7 @@ fn setup(opts: RHash) -> Result<(), Error> {
84
84
  .force(force)
85
85
  .build(),
86
86
  };
87
- runtime::block_on(setup.install()).map_err(error::to_ruby)
87
+ runtime::block_on(setup.install())?.map_err(error::to_ruby)
88
88
  }
89
89
 
90
90
  /// Whether the `msb` runtime + `libkrunfw` are installed and resolvable.
@@ -108,6 +108,7 @@ fn set_runtime_libkrunfw_path(path: String) {
108
108
  /// no async) — runs on the Ruby thread; resolves the path from the local
109
109
  /// backend's config.
110
110
  fn resolved_msb_path() -> Result<String, Error> {
111
+ backend::ensure_this_process_backend()?;
111
112
  let backend = microsandbox::default_backend();
112
113
  // Shim-only entry point with no SDK `Operation` — report the Ruby-facing
113
114
  // name (`Microsandbox.runtime_path`), like Python's name-based local_only.
@@ -5,16 +5,51 @@
5
5
  //! tokio runtime. Crucially, the blocking call happens inside [`nogvl`], which
6
6
  //! releases Ruby's Global VM Lock for the duration so other Ruby threads keep
7
7
  //! running while a (potentially long) sandbox operation is in flight.
8
+ //!
9
+ //! # Interrupts must not lose a result
10
+ //!
11
+ //! A native call cannot be interrupted while it runs (see `DESIGN.md`), so a
12
+ //! `Thread#raise` (what `Timeout.timeout` uses), a `Thread#kill` or the main
13
+ //! thread's SIGINT that arrives meanwhile is delivered *after* it. Ruby delivers
14
+ //! those by `longjmp`, which skips Rust frames without running destructors: if
15
+ //! that happened while the value the call just produced still lived only in a
16
+ //! Rust frame, the value — a booted sandbox, a guest exec session, megabytes of
17
+ //! output — would be neither returned to Ruby nor dropped. Two rules keep that
18
+ //! from happening; every bound method relies on both:
19
+ //!
20
+ //! 1. **[`nogvl`] never lets Ruby jump out of it.** It checks interrupts only
21
+ //! *before* the work starts, under `rb_protect`, and reports one as an
22
+ //! ordinary `Err` that unwinds the Rust frames normally. After the work it
23
+ //! does not check at all (see the function for the CRuby details).
24
+ //! 2. **Between [`block_on`] returning and the bound method returning, the code
25
+ //! must not run Ruby code or check interrupts**: no `funcall` (CRuby's
26
+ //! `vm_call0_body` runs `RUBY_VM_CHECK_INTS` after *every* call it makes, C
27
+ //! functions included), no `rb_thread_check_ints`, nothing that can enter a
28
+ //! Ruby method body. Only C-level construction is allowed there — `rb_hash_*`,
29
+ //! `rb_ary_*`, `rb_str_*`, number boxing, and `TypedData` wrapping. The one
30
+ //! `funcall` magnus would otherwise make at that point — `#[magnus::wrap]`
31
+ //! resolves its class with `const_get` the first time a value is wrapped — is
32
+ //! moved to extension load by [`preload_class`]. Error construction
33
+ //! (`error::to_ruby`) may call Ruby: on that path there is no value to lose.
34
+ //!
35
+ //! Together: after `block_on` returns `Ok`, the next interrupt checkpoint is
36
+ //! the Ruby VM's own, reached only once the bound method has handed its value
37
+ //! to Ruby (the first one is `vm_pop_frame` popping the native method's frame,
38
+ //! in `vm_call_cfunc_with_frame_`). From then on the value is an ordinary Ruby
39
+ //! object — returned to the caller, or garbage whose `free` drops the Rust
40
+ //! value. It is never abandoned.
8
41
 
9
42
  use std::ffi::c_void;
10
43
  use std::future::Future;
44
+ use std::mem::ManuallyDrop;
11
45
  use std::panic::{catch_unwind, AssertUnwindSafe};
12
- use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
13
- use std::sync::Mutex;
46
+ use std::sync::atomic::{AtomicPtr, Ordering};
14
47
 
15
- use magnus::Ruby;
48
+ use magnus::{Error, Ruby, TypedData};
16
49
  use tokio::runtime::Runtime;
17
50
 
51
+ use crate::error;
52
+
18
53
  /// The current Ruby handle. Safe to call from any bound method or value
19
54
  /// conversion: we always hold the GVL there (conversions run after `block_on`
20
55
  /// returns and re-acquires it).
@@ -22,7 +57,14 @@ pub fn ruby() -> Ruby {
22
57
  Ruby::get().expect("microsandbox: not on a Ruby thread")
23
58
  }
24
59
 
25
- // 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
26
68
  // multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
27
69
  // ONLY the calling thread — so a child that inherits this runtime has a runtime
28
70
  // whose threads are gone: `block_on` can still drive the current thread, but the
@@ -30,16 +72,16 @@ pub fn ruby() -> Ruby {
30
72
  // connections stall/drop mid-use. This is hit in practice by forking job servers
31
73
  // (Solid Queue, Resque) and clustered web servers (Puma workers).
32
74
  //
33
- // Fix: store the runtime behind a leaked raw pointer tagged with the building
34
- // pid. If `runtime()` is called in a process whose pid differs (we forked), build
35
- // a FRESH runtime for this process and swap the pointer. The stale runtime is
36
- // LEAKED, never dropped — dropping a tokio runtime whose worker threads vanished
37
- // across fork can hang on the shutdown thread-join. One runtime is leaked per
38
- // process that uses the SDK (bounded; equivalent to the old set-once behavior in
39
- // the common no-fork case).
40
- static RUNTIME_PTR: AtomicPtr<Runtime> = AtomicPtr::new(std::ptr::null_mut());
41
- static RUNTIME_PID: AtomicU32 = AtomicU32::new(0);
42
- 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());
43
85
 
44
86
  fn build_runtime() -> Runtime {
45
87
  tokio::runtime::Builder::new_multi_thread()
@@ -50,32 +92,157 @@ fn build_runtime() -> Runtime {
50
92
  }
51
93
 
52
94
  /// The multi-threaded tokio runtime for THIS process, built on first use and
53
- /// rebuilt after a `fork(2)` (fork-safe — see the note above).
95
+ /// rebuilt after a `fork(2)` (see the note above).
54
96
  pub fn runtime() -> &'static Runtime {
55
- let cur = std::process::id();
56
- let ptr = RUNTIME_PTR.load(Ordering::Acquire);
57
- if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
58
- // SAFETY: ptr was produced by Box::leak (valid for 'static) and matches
59
- // this process's pid, so its runtime threads are live in this process.
60
- 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
+ }
107
+ }
108
+
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
+ }
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
+ }
161
+
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
+ }
61
169
  }
62
170
 
63
- let _guard = RUNTIME_LOCK
64
- .lock()
65
- .expect("microsandbox: runtime lock poisoned");
66
- // Re-check under the lock (another thread may have just built it).
67
- let ptr = RUNTIME_PTR.load(Ordering::Acquire);
68
- if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
69
- return unsafe { &*ptr };
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
+ }
70
180
  }
71
181
 
72
- // Build a fresh runtime for this process and publish it. The previous pointer
73
- // (if any) belonged to a parent process and is intentionally leaked, not
74
- // dropped (its threads are gone post-fork; Drop would block on join).
75
- let rt: &'static Runtime = Box::leak(Box::new(build_runtime()));
76
- RUNTIME_PTR.store(rt as *const Runtime as *mut Runtime, Ordering::Release);
77
- RUNTIME_PID.store(cur, Ordering::Release);
78
- rt
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
+ }
200
+ }
201
+
202
+ /// Resolve the Ruby class of a `#[magnus::wrap]` type now, at extension load.
203
+ ///
204
+ /// The `wrap` macro looks its class up lazily, the first time a value of the
205
+ /// type is handed to Ruby, with `funcall("const_get", ...)` — and a `funcall`
206
+ /// is an interrupt checkpoint (rule 2 in the module docs). Left lazy, the first
207
+ /// `Sandbox.create` of a process that an interrupt raced would hit that
208
+ /// checkpoint while the freshly booted sandbox was still a plain Rust value.
209
+ /// Every module's `define` calls this for each type it wraps, right after
210
+ /// defining the class, so wrapping a value afterwards is `TypedData::class`
211
+ /// reading a resolved static plus `rb_data_typed_object_wrap`.
212
+ pub fn preload_class<T: TypedData>(ruby: &Ruby) {
213
+ let _ = T::class(ruby);
214
+ }
215
+
216
+ /// How often [`nogvl`] lets Ruby process interrupts before the work starts
217
+ /// until it gives up. See the comment in the loop: this is a defensive bound,
218
+ /// not a path a healthy process takes.
219
+ const MAX_ENTRY_ATTEMPTS: u32 = 64;
220
+
221
+ /// Let Ruby deliver whatever interrupts are pending for this thread, turning a
222
+ /// raised exception or any other non-local exit (e.g. the `TAG_FATAL` jump of a
223
+ /// `Thread#kill`) into an `Err` instead of letting it `longjmp` through us.
224
+ ///
225
+ /// `rb_thread_check_ints` is `rb_ec_check_ints` → `RUBY_VM_CHECK_INTS_BLOCKING`
226
+ /// → `vm_check_ints_blocking` → `rb_threadptr_execute_interrupts(th, 1)`
227
+ /// (thread.c): it runs trap handlers, may switch threads on a timer tick, and
228
+ /// raises queued `Thread#raise` exceptions — with *blocking* timing, so it also
229
+ /// raises the ones a caller deferred with
230
+ /// `Thread.handle_interrupt(X => :on_blocking)`.
231
+ ///
232
+ /// (All CRuby references in this file were read from the Ruby 3.4.5 sources;
233
+ /// the functions named behave the same in 3.1–3.3, the other versions this gem
234
+ /// supports.)
235
+ /// `magnus::rb_sys::protect` is `rb_protect`; magnus re-raises (or re-jumps)
236
+ /// the returned `Error` once it reaches the method boundary, after every Rust
237
+ /// frame in between has unwound normally.
238
+ fn check_interrupts() -> Result<(), Error> {
239
+ magnus::rb_sys::protect(|| {
240
+ // SAFETY: called on a Ruby thread holding the GVL (every caller of
241
+ // `nogvl` is a bound method), inside `rb_protect`.
242
+ unsafe { rb_sys::rb_thread_check_ints() };
243
+ rb_sys::Qnil as rb_sys::VALUE
244
+ })
245
+ .map(|_| ())
79
246
  }
80
247
 
81
248
  /// Run `f` with the Ruby GVL released.
@@ -85,7 +252,12 @@ pub fn runtime() -> &'static Runtime {
85
252
  /// the GVL is released. Panics are caught and re-raised after the GVL is
86
253
  /// re-acquired, because unwinding across the C frame would be undefined
87
254
  /// behaviour.
88
- pub fn nogvl<F, R>(f: F) -> R
255
+ ///
256
+ /// Returns `Err` only when `f` did NOT run: an interrupt was delivered before
257
+ /// the work started (a pending `Thread#raise`/`Thread#kill`/SIGINT), so there
258
+ /// is nothing to lose and the caller just propagates it with `?`. Once `f` has
259
+ /// run, this returns `Ok` with its value and Ruby is not consulted again.
260
+ pub fn nogvl<F, R>(f: F) -> Result<R, Error>
89
261
  where
90
262
  F: FnOnce() -> R,
91
263
  {
@@ -99,7 +271,7 @@ where
99
271
  F: FnOnce() -> R,
100
272
  {
101
273
  // SAFETY: `arg` points to the `State` on the caller's stack, which
102
- // outlives this call (rb_thread_call_without_gvl blocks until return).
274
+ // outlives this call (rb_thread_call_without_gvl2 blocks until return).
103
275
  let state = unsafe { &mut *(arg as *mut State<F, R>) };
104
276
  let f = state.f.take().expect("nogvl callback invoked twice");
105
277
  state.out = Some(catch_unwind(AssertUnwindSafe(f)));
@@ -111,26 +283,121 @@ where
111
283
  out: None,
112
284
  };
113
285
 
114
- unsafe {
115
- rb_sys::rb_thread_call_without_gvl(
116
- Some(call::<F, R>),
117
- &mut state as *mut _ as *mut c_void,
118
- None,
119
- std::ptr::null_mut(),
120
- );
286
+ // Why not the plain `rb_thread_call_without_gvl`: `rb_nogvl` (thread.c)
287
+ // ends it with `RUBY_VM_CHECK_INTS_BLOCKING(ec)` — step (5) "Check
288
+ // interrupts" of the contract in its doc comment — AFTER the callback has
289
+ // returned. An exception raised there leaves by `longjmp` past this frame,
290
+ // `block_on`'s and the bound method's, with the callback's result sitting
291
+ // in `state.out`: never returned, never dropped. That exit check is the bug
292
+ // this function exists to avoid, so the plain call must not come back here
293
+ // in any form, not even as a fallback.
294
+ //
295
+ // `rb_thread_call_without_gvl2` is `rb_nogvl(..., RB_NOGVL_INTR_FAIL)`:
296
+ // * at entry `unblock_function_set(.., fail_if_interrupted = 1)` returns
297
+ // FALSE when `RUBY_VM_INTERRUPTED_ANY(ec)` — any unmasked bit of
298
+ // `ec->interrupt_flag` — is set, and `BLOCKING_REGION` then skips the
299
+ // callback altogether;
300
+ // * at exit the `RUBY_VM_CHECK_INTS_BLOCKING` is skipped
301
+ // (`if ((flags & RB_NOGVL_INTR_FAIL) == 0)`).
302
+ // So it never jumps, but it can decline to run. We do the checking
303
+ // ourselves, only at points where nothing has happened yet.
304
+
305
+ // (i) One protected check before we start. The plain call also checked at
306
+ // entry (`unblock_function_set` → `RUBY_VM_CHECK_INTS`), so an interrupt
307
+ // that is already pending keeps being delivered before any work is
308
+ // done. One difference, on purpose: the plain call's entry check used
309
+ // non-blocking timing and its EXIT check was the one that delivered
310
+ // exceptions deferred with `Thread.handle_interrupt(X => :on_blocking)`
311
+ // — i.e. at exactly the point that lost the result. This check uses
312
+ // blocking timing (see `check_interrupts`), so such an exception is now
313
+ // raised here, before the call, if it is already queued; one that
314
+ // arrives during the call waits for the thread's next blocking point.
315
+ // Nothing has happened yet, so an `Err` unwinds our frames normally and
316
+ // drops `f` (and the future it owns).
317
+ check_interrupts()?;
318
+
319
+ let mut attempts = 0;
320
+ loop {
321
+ // SAFETY: `call` matches the expected signature, `state` outlives the
322
+ // call, and a null unblock function is allowed (the call is simply not
323
+ // cancellable, as before).
324
+ unsafe {
325
+ rb_sys::rb_thread_call_without_gvl2(
326
+ Some(call::<F, R>),
327
+ &mut state as *mut _ as *mut c_void,
328
+ None,
329
+ std::ptr::null_mut(),
330
+ );
331
+ }
332
+ if state.out.is_some() {
333
+ // The callback ran. Do NOT check interrupts: see the invariant below.
334
+ break;
335
+ }
336
+
337
+ // (ii) An interrupt bit was set at entry, so the callback did NOT run
338
+ // and nothing has happened. This is routine, not exotic:
339
+ // `RUBY_VM_INTERRUPTED_ANY` also covers the timer tick, postponed
340
+ // jobs and the VM barrier, and a `Thread#raise` can land in the few
341
+ // instructions since the check above. Let Ruby process it under
342
+ // protection, then retry.
343
+ //
344
+ // The retry cannot spin: `rb_threadptr_execute_interrupts` loops on
345
+ // `threadptr_get_interrupts`, which clears every unmasked bit of
346
+ // `ec->interrupt_flag` (the `ATOMIC_CAS` to `interrupt &
347
+ // ec->interrupt_mask`) before dispatching, until none is left. That
348
+ // holds even when the queued exception stays masked
349
+ // (`handle_interrupt(X => :never)`):
350
+ // `rb_threadptr_pending_interrupt_deque` then returns `Qundef` and
351
+ // sets `pending_interrupt_queue_checked`, and although
352
+ // `vm_check_ints_blocking` re-sets the pending bit on each call, the
353
+ // same call consumes it again. We hold the GVL from the return of
354
+ // the check to the entry test of the next attempt, so only a
355
+ // non-Ruby source (timer thread, signal) can set a bit in between.
356
+ check_interrupts()?;
357
+
358
+ // The one case in which the bits are NOT consumed is `ec->raised_flag`:
359
+ // `rb_threadptr_execute_interrupts` returns at once while CRuby is in
360
+ // the middle of raising (`setup_exception` in eval.c — an overridden
361
+ // `Exception#backtrace`, a `TracePoint` `:raise` hook). A native call
362
+ // made from there with an interrupt pending can never start under
363
+ // `_gvl2`. Fail it with a normal error rather than loop forever (or
364
+ // reach for the plain call); nothing has run, so nothing is lost.
365
+ attempts += 1;
366
+ if attempts >= MAX_ENTRY_ATTEMPTS {
367
+ return Err(error::base_error(
368
+ "native call could not start: interrupt check did not settle",
369
+ ));
370
+ }
121
371
  }
122
372
 
373
+ // INVARIANT: from here until the bound method returns its value to Ruby,
374
+ // nothing checks interrupts (rule 2 in the module docs). The next
375
+ // checkpoint is the Ruby VM's own, AFTER the value has become a Ruby
376
+ // object, so the value is either returned or dropped by Rust — never
377
+ // abandoned in this frame by a `longjmp`.
123
378
  match state.out.take() {
124
- Some(Ok(value)) => value,
379
+ Some(Ok(value)) => Ok(value),
125
380
  Some(Err(panic)) => std::panic::resume_unwind(panic),
126
- None => unreachable!("nogvl callback did not run"),
381
+ // Not reachable: the loop above only breaks once `state.out` is `Some`.
382
+ // Kept as an error instead of a panic so a future edit to the loop
383
+ // cannot turn into an abort across the FFI boundary.
384
+ None => Err(error::base_error("native call did not run")),
127
385
  }
128
386
  }
129
387
 
130
388
  /// Drive a future to completion on the shared runtime with the GVL released.
131
- pub fn block_on<F>(fut: F) -> F::Output
389
+ ///
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:
392
+ /// `block_on(fut)?.map_err(error::to_ruby)?`.
393
+ pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
132
394
  where
133
395
  F: Future,
134
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()?;
135
402
  nogvl(|| runtime().block_on(fut))
136
403
  }