microsandbox-rb 0.17.2 → 0.17.4

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,23 +16,23 @@ 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);
35
+ let inner = Arc::clone(self.inner.get()?);
36
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),
@@ -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,7 +59,7 @@ 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() {
@@ -72,7 +75,7 @@ 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);
78
+ let inner = Arc::clone(self.inner.get()?);
76
79
  // Take AND close in ONE `block_on`. `block_on` returns `Err` when an
77
80
  // interrupt is delivered before it starts; as two calls, an interrupt
78
81
  // between them would drop the sink the first one took without ever
@@ -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
@@ -46,8 +46,10 @@ fn all_sandbox_metrics() -> Result<RHash, Error> {
46
46
  }
47
47
 
48
48
  /// Download and install the `msb` runtime + `libkrunfw` into `~/.microsandbox`.
49
+ /// Never touches the backend, so a forked child does not take the inherited
50
+ /// one over here (see `runtime::block_on_runtime_install`).
49
51
  fn install() -> Result<(), Error> {
50
- runtime::block_on(microsandbox::setup::install())?.map_err(error::to_ruby)
52
+ runtime::block_on_runtime_install(microsandbox::setup::install())?.map_err(error::to_ruby)
51
53
  }
52
54
 
53
55
  /// Customizable install via the core `Setup` builder. `opts`: base_dir (install
@@ -84,7 +86,8 @@ fn setup(opts: RHash) -> Result<(), Error> {
84
86
  .force(force)
85
87
  .build(),
86
88
  };
87
- runtime::block_on(setup.install())?.map_err(error::to_ruby)
89
+ // Backend-free, like `install`.
90
+ runtime::block_on_runtime_install(setup.install())?.map_err(error::to_ruby)
88
91
  }
89
92
 
90
93
  /// Whether the `msb` runtime + `libkrunfw` are installed and resolvable.
@@ -108,6 +111,7 @@ fn set_runtime_libkrunfw_path(path: String) {
108
111
  /// no async) — runs on the Ruby thread; resolves the path from the local
109
112
  /// backend's config.
110
113
  fn resolved_msb_path() -> Result<String, Error> {
114
+ backend::ensure_this_process_backend()?;
111
115
  let backend = microsandbox::default_backend();
112
116
  // Shim-only entry point with no SDK `Operation` — report the Ruby-facing
113
117
  // name (`Microsandbox.runtime_path`), like Python's name-based local_only.
@@ -118,10 +122,52 @@ fn resolved_msb_path() -> Result<String, Error> {
118
122
  Ok(path.to_string_lossy().into_owned())
119
123
  }
120
124
 
125
+ /// This process's fork generation (see `runtime::ProcessIdentity`): how many
126
+ /// `fork`s led from the process that loaded the extension to this one. A
127
+ /// diagnostic for the fork specs, not part of the Ruby API (nothing in
128
+ /// `lib/` calls it, and `Native` is the extension's internal namespace).
129
+ fn fork_generation() -> u32 {
130
+ runtime::ProcessIdentity::current().generation()
131
+ }
132
+
133
+ /// Whether two given identities are the same process, by the two comparisons
134
+ /// every ownership decision uses: `PartialEq` (the runtime cell, handles,
135
+ /// `with_backend` scopes) and the packed word (the backend record). Returns
136
+ /// `[equal, same_bits]`. A spec-only diagnostic, like `fork_generation`:
137
+ /// pid reuse cannot be staged on demand, so this is where the specs pin that
138
+ /// both the pid and the generation take part. Pure: no state is read or
139
+ /// changed.
140
+ fn fork_identity_equal(pid_a: u32, gen_a: u32, pid_b: u32, gen_b: u32) -> (bool, bool) {
141
+ let a = runtime::ProcessIdentity::from_parts(pid_a, gen_a);
142
+ let b = runtime::ProcessIdentity::from_parts(pid_b, gen_b);
143
+ (a == b, a.to_bits() == b.to_bits())
144
+ }
145
+
146
+ /// Run a handle's real ownership check (`ForkGuarded::get_in`) for a handle
147
+ /// created by the process (`born_pid`, `born_gen`) and used in (`cur_pid`,
148
+ /// `cur_gen`): `nil` when it is allowed, the `ForkedHandleError` that `get`
149
+ /// would raise otherwise — including the equal-pid wording. Spec-only and
150
+ /// pure, like `fork_identity_equal`; the guard holds no payload.
151
+ fn fork_identity_guard(
152
+ born_pid: u32,
153
+ born_gen: u32,
154
+ cur_pid: u32,
155
+ cur_gen: u32,
156
+ ) -> Result<(), Error> {
157
+ let born = runtime::ProcessIdentity::from_parts(born_pid, born_gen);
158
+ let cur = runtime::ProcessIdentity::from_parts(cur_pid, cur_gen);
159
+ let guard = runtime::ForkGuarded::owned_by(born, "Microsandbox::VolumeFs", ());
160
+ guard.get_in(cur).map(|_| ())
161
+ }
162
+
121
163
  /// magnus entry point. RubyGems loads this via `require "microsandbox/microsandbox_rb"`,
122
164
  /// which calls `Init_microsandbox_rb` (matching the cdylib `[lib] name`).
123
165
  #[magnus::init]
124
166
  fn init(ruby: &Ruby) -> Result<(), Error> {
167
+ // Before anything can fork: from here on every fork's child gets a fork
168
+ // generation one higher than its parent's (see `runtime::ProcessIdentity`).
169
+ runtime::register_fork_handler();
170
+
125
171
  let module = ruby.define_module("Microsandbox")?;
126
172
  let native = module.define_module("Native")?;
127
173
 
@@ -136,6 +182,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
136
182
  )?;
137
183
  native.define_singleton_method("resolved_msb_path", function!(resolved_msb_path, 0))?;
138
184
  native.define_singleton_method("all_sandbox_metrics", function!(all_sandbox_metrics, 0))?;
185
+ native.define_singleton_method("fork_generation", function!(fork_generation, 0))?;
186
+ native.define_singleton_method("fork_identity_equal", function!(fork_identity_equal, 4))?;
187
+ native.define_singleton_method("fork_identity_guard", function!(fork_identity_guard, 4))?;
139
188
 
140
189
  backend::define(ruby, &native)?;
141
190
  sandbox::define(ruby, &native)?;
@@ -41,23 +41,125 @@
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
46
  use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
46
- use std::sync::Mutex;
47
47
 
48
48
  use magnus::{Error, Ruby, TypedData};
49
49
  use tokio::runtime::Runtime;
50
50
 
51
51
  use crate::error;
52
52
 
53
- /// The current Ruby handle. Safe to call from any bound method or value
54
- /// conversion: we always hold the GVL there (conversions run after `block_on`
55
- /// returns and re-acquires it).
53
+ /// The current Ruby handle. Safe to call from any bound method, value
54
+ /// conversion or error construction (`error::*`): we always hold the GVL there
55
+ /// (both run after `block_on` returns and re-acquires it).
56
+ ///
57
+ /// Panics when the calling thread does not hold the GVL (a `nogvl` callback, a
58
+ /// tokio worker): `Ruby::get` checks that in release builds too, so a misplaced
59
+ /// call fails loudly instead of touching Ruby without the lock.
56
60
  pub fn ruby() -> Ruby {
57
- Ruby::get().expect("microsandbox: not on a Ruby thread")
61
+ Ruby::get().expect("microsandbox: not on a Ruby thread holding the GVL")
62
+ }
63
+
64
+ //--------------------------------------------------------------------------------------------------
65
+ // Process identity
66
+ //--------------------------------------------------------------------------------------------------
67
+
68
+ /// Which process this is, for fork safety: the pid plus a fork generation.
69
+ ///
70
+ /// Everything fork-aware in the extension — the runtime cell below, the
71
+ /// backend record in `backend.rs`, [`ForkGuarded`] — tags what it builds with
72
+ /// the identity of the process that built it and compares that against
73
+ /// [`ProcessIdentity::current`] before use. Both halves are compared:
74
+ ///
75
+ /// * The pid alone is not enough. A descendant can end up with the pid of an
76
+ /// ancestor whose state it inherited, if the pid counter wraps onto exactly
77
+ /// that value and no SDK call happened in between; it would then pass for
78
+ /// that ancestor.
79
+ /// * The generation alone is not enough either. It counts the `fork`s that
80
+ /// ran this library's atfork child handler, and a process can be created
81
+ /// without running it — `vfork`, a raw `clone` — in which case only the pid
82
+ /// tells the child apart.
83
+ ///
84
+ /// Every `fork(2)` that runs atfork handlers (which `Kernel#fork` /
85
+ /// `Process.fork` always do: Ruby calls libc's `fork`) gives the child a
86
+ /// generation one higher than its parent's, so parent and child differ even
87
+ /// when their pids are equal.
88
+ #[derive(Clone, Copy, PartialEq, Eq)]
89
+ pub struct ProcessIdentity {
90
+ pub pid: u32,
91
+ generation: u32,
92
+ }
93
+
94
+ /// How many `fork`s, counted from the process that loaded the extension, led
95
+ /// to this process. Incremented only by [`on_fork_child`], in a child, and
96
+ /// inherited (as plain memory) by every child. 32 bits: it would take four
97
+ /// billion nested forks in one lineage to wrap, and a wrap matters only if
98
+ /// the pid matches as well.
99
+ static FORK_GENERATION: AtomicU32 = AtomicU32::new(0);
100
+
101
+ /// The `pthread_atfork` child handler. It runs in the new child before `fork`
102
+ /// returns there, while the child is single-threaded, and must be
103
+ /// async-signal-safe: one lock-free atomic add, nothing else (no allocation,
104
+ /// no lock, no Ruby). `Relaxed` is enough: the only thread that exists to read
105
+ /// the counter is this one, and threads started later see it through the
106
+ /// spawn's own synchronization. The parent never changes its counter.
107
+ extern "C" fn on_fork_child() {
108
+ FORK_GENERATION.fetch_add(1, Ordering::Relaxed);
109
+ }
110
+
111
+ /// Register [`on_fork_child`]. Called once, from the extension's init.
112
+ ///
113
+ /// Ruby never unloads (`dlclose`s) an extension, so the handler's code stays
114
+ /// mapped for the life of the process, as `pthread_atfork` requires — handlers
115
+ /// cannot be unregistered. If registration fails (`ENOMEM`), the generation
116
+ /// stays 0 and identity degrades to the pid alone, which is how the extension
117
+ /// behaved before the generation existed; that is not worth refusing to load
118
+ /// over. Platforms without `pthread_atfork` (the gem ships for macOS and
119
+ /// Linux only) get the same pid-only identity.
120
+ pub fn register_fork_handler() {
121
+ static ONCE: std::sync::Once = std::sync::Once::new();
122
+ ONCE.call_once(|| {
123
+ #[cfg(unix)]
124
+ // SAFETY: `on_fork_child` is an `extern "C" fn()` that stays valid for
125
+ // the life of the process (see above) and is async-signal-safe.
126
+ unsafe {
127
+ let _ = libc::pthread_atfork(None, None, Some(on_fork_child));
128
+ }
129
+ });
58
130
  }
59
131
 
60
- // The process-wide tokio runtime, guarded by the pid it was built under. A
132
+ impl ProcessIdentity {
133
+ /// This process's identity. The single `std::process::id()` call site.
134
+ pub fn current() -> Self {
135
+ Self {
136
+ pid: std::process::id(),
137
+ generation: FORK_GENERATION.load(Ordering::Relaxed),
138
+ }
139
+ }
140
+
141
+ /// An identity from its two halves — ONLY for the fork-identity
142
+ /// diagnostics in `lib.rs`, which let the specs compare identities that no
143
+ /// real fork can produce on demand (an equal pid with another generation).
144
+ pub fn from_parts(pid: u32, generation: u32) -> Self {
145
+ Self { pid, generation }
146
+ }
147
+
148
+ /// The fork generation, for `Native.fork_generation` (a diagnostic the
149
+ /// fork specs read; not part of the Ruby API).
150
+ pub fn generation(self) -> u32 {
151
+ self.generation
152
+ }
153
+
154
+ /// Both halves in one word, for a lock-free atomic record. `0` is never a
155
+ /// real identity (no user process has pid 0), so it can mean "none".
156
+ pub fn to_bits(self) -> u64 {
157
+ (u64::from(self.pid) << 32) | u64::from(self.generation)
158
+ }
159
+ }
160
+
161
+ // The process-wide tokio runtime, tagged with the identity of the process it
162
+ // was built under (see `ProcessIdentity`). A
61
163
  // multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
62
164
  // ONLY the calling thread — so a child that inherits this runtime has a runtime
63
165
  // whose threads are gone: `block_on` can still drive the current thread, but the
@@ -65,16 +167,16 @@ pub fn ruby() -> Ruby {
65
167
  // connections stall/drop mid-use. This is hit in practice by forking job servers
66
168
  // (Solid Queue, Resque) and clustered web servers (Puma workers).
67
169
  //
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(());
170
+ // The runtime and its process identity live in ONE heap cell behind ONE atomic
171
+ // pointer, so a reader sees either a whole cell or none: there is no window in
172
+ // which the pointer is new and the identity old (or the reverse), and no mutex
173
+ // that a fork could capture locked or that a panic could poison.
174
+ struct RuntimeCell {
175
+ owner: ProcessIdentity,
176
+ rt: Runtime,
177
+ }
178
+
179
+ static RUNTIME: AtomicPtr<RuntimeCell> = AtomicPtr::new(std::ptr::null_mut());
78
180
 
79
181
  fn build_runtime() -> Runtime {
80
182
  tokio::runtime::Builder::new_multi_thread()
@@ -85,32 +187,126 @@ fn build_runtime() -> Runtime {
85
187
  }
86
188
 
87
189
  /// 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).
190
+ /// rebuilt after a `fork(2)` (see the note above).
89
191
  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 };
192
+ let cur = ProcessIdentity::current();
193
+ let mut observed = RUNTIME.load(Ordering::Acquire);
194
+ // SAFETY (every dereference below): a non-null pointer in `RUNTIME` came
195
+ // from `Box::into_raw` and is never freed once published, so it is valid
196
+ // for `'static`.
197
+ if let Some(cell) = unsafe { observed.as_ref() } {
198
+ if cell.owner == cur {
199
+ // Built in this process: its threads are alive here.
200
+ return &cell.rt;
201
+ }
96
202
  }
97
203
 
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 };
204
+ // Slow path: no runtime yet, or the one we see was built by a parent
205
+ // process. Build ours and publish it with a compare-exchange.
206
+ let fresh = Box::into_raw(Box::new(RuntimeCell {
207
+ owner: cur,
208
+ rt: build_runtime(),
209
+ }));
210
+ loop {
211
+ match RUNTIME.compare_exchange(observed, fresh, Ordering::AcqRel, Ordering::Acquire) {
212
+ // Published. The cell we replaced (if any) belonged to a parent
213
+ // process and is LEAKED on purpose, never dropped: its worker
214
+ // threads did not survive the fork, and `Runtime`'s `Drop` joins
215
+ // them. One runtime is leaked per forked process that uses the
216
+ // SDK.
217
+ Ok(_) => return unsafe { &(*fresh).rt },
218
+ Err(winner) => {
219
+ if let Some(cell) = unsafe { winner.as_ref() } {
220
+ if cell.owner == cur {
221
+ // Another thread of THIS process won the race. Our
222
+ // cell was never published and its threads are alive
223
+ // here, so dropping it is safe (and joins them).
224
+ drop(unsafe { Box::from_raw(fresh) });
225
+ return &cell.rt;
226
+ }
227
+ }
228
+ observed = winner;
229
+ }
230
+ }
105
231
  }
232
+ }
106
233
 
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
234
+ /// The Rust payload of a wrapped Ruby object, bound to the process that
235
+ /// created it.
236
+ ///
237
+ /// A handle that crosses a `fork(2)` is useless in the child and dangerous to
238
+ /// destroy there: it owns tasks, channels and connections of the parent's
239
+ /// runtime, clones of the parent's backend, the parent-watchdog pipe of a
240
+ /// sandbox. So every `#[magnus::wrap]` type keeps its payload in one of these:
241
+ ///
242
+ /// * [`get`](Self::get) — the only way to the payload for anything that acts
243
+ /// on it — fails with `Microsandbox::ForkedHandleError` in any process other
244
+ /// than the one that built the value. `Deref` is deliberately not
245
+ /// implemented: an access that skips the check should not compile.
246
+ /// * `Drop` runs the payload's destructor only in that same process. A forked
247
+ /// child that garbage-collects an inherited handle — at the latest when it
248
+ /// exits: Ruby frees every `T_DATA` object in `rb_objspace_call_finalizer` —
249
+ /// leaks it instead.
250
+ pub struct ForkGuarded<T> {
251
+ owner: ProcessIdentity,
252
+ /// The Ruby class users know the handle by, for the error message.
253
+ class: &'static str,
254
+ inner: ManuallyDrop<T>,
255
+ }
256
+
257
+ impl<T> ForkGuarded<T> {
258
+ pub fn new(class: &'static str, inner: T) -> Self {
259
+ Self {
260
+ owner: ProcessIdentity::current(),
261
+ class,
262
+ inner: ManuallyDrop::new(inner),
263
+ }
264
+ }
265
+
266
+ /// A guard owned by `owner` instead of this process — ONLY for the
267
+ /// fork-identity diagnostic in `lib.rs`, which runs the real ownership
268
+ /// decision ([`get_in`](Self::get_in)) on identities given by a spec.
269
+ pub fn owned_by(owner: ProcessIdentity, class: &'static str, inner: T) -> Self {
270
+ Self {
271
+ owner,
272
+ class,
273
+ inner: ManuallyDrop::new(inner),
274
+ }
275
+ }
276
+
277
+ /// The payload, or `ForkedHandleError` when this is not the process that
278
+ /// created it.
279
+ pub fn get(&self) -> Result<&T, Error> {
280
+ self.get_in(ProcessIdentity::current())
281
+ }
282
+
283
+ /// [`get`](Self::get) as decided in the process identified by `cur`.
284
+ pub fn get_in(&self, cur: ProcessIdentity) -> Result<&T, Error> {
285
+ if self.owner == cur {
286
+ Ok(&self.inner)
287
+ } else {
288
+ Err(error::forked_handle(self.class, self.owner.pid, cur.pid))
289
+ }
290
+ }
291
+
292
+ /// The payload without the process check — ONLY for accessors that copy
293
+ /// plain data out of the value's own memory (a name, an id, a cached
294
+ /// status). Those stay readable in a forked child, which needs e.g. the
295
+ /// name to open its own handle. Never use this for anything that takes a
296
+ /// lock, touches a file descriptor, or reaches the runtime or the backend.
297
+ pub fn peek(&self) -> &T {
298
+ &self.inner
299
+ }
300
+ }
301
+
302
+ impl<T> Drop for ForkGuarded<T> {
303
+ fn drop(&mut self) {
304
+ if self.owner == ProcessIdentity::current() {
305
+ // SAFETY: dropped exactly once, here; `inner` is not used again.
306
+ unsafe { ManuallyDrop::drop(&mut self.inner) };
307
+ }
308
+ // Otherwise: inherited across a fork. Leak it (see the type docs).
309
+ }
114
310
  }
115
311
 
116
312
  /// Resolve the Ruby class of a `#[magnus::wrap]` type now, at extension load.
@@ -123,6 +319,13 @@ pub fn runtime() -> &'static Runtime {
123
319
  /// Every module's `define` calls this for each type it wraps, right after
124
320
  /// defining the class, so wrapping a value afterwards is `TypedData::class`
125
321
  /// reading a resolved static plus `rb_data_typed_object_wrap`.
322
+ ///
323
+ /// Doing it at load also keeps that first resolution away from concurrent
324
+ /// callers, which matters since magnus 0.9: the static is a magnus `Lazy`, an
325
+ /// `OnceLock` whose initializer (the `const_get`) runs while holding the lock.
326
+ /// That `funcall` can switch Ruby threads, and a second thread reaching the
327
+ /// same lock would block on it while holding the GVL the first one needs to
328
+ /// finish.
126
329
  pub fn preload_class<T: TypedData>(ruby: &Ruby) {
127
330
  let _ = T::class(ruby);
128
331
  }
@@ -301,10 +504,44 @@ where
301
504
 
302
505
  /// Drive a future to completion on the shared runtime with the GVL released.
303
506
  ///
304
- /// `Err` means the future was never polled (see [`nogvl`]); propagate it with
305
- /// `?` before looking at the future's own output:
507
+ /// `Err` means the future was never polled (see [`nogvl`], and the fork check
508
+ /// below); propagate it with `?` before looking at the future's own output:
306
509
  /// `block_on(fut)?.map_err(error::to_ruby)?`.
510
+ ///
511
+ /// Every native call goes through this, with one deliberate exception: the
512
+ /// runtime installer, see [`block_on_runtime_install`].
307
513
  pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
514
+ where
515
+ F: Future,
516
+ {
517
+ // Many core operations resolve the process-wide default backend while they
518
+ // run. After a fork that backend is the parent's; replace it now, while we
519
+ // still hold the GVL (the check takes a mutex and may build a Ruby
520
+ // exception — neither is allowed once the GVL is released).
521
+ crate::backend::ensure_this_process_backend()?;
522
+ nogvl(|| runtime().block_on(fut))
523
+ }
524
+
525
+ /// [`block_on`] without its backend check, for the runtime installer ONLY:
526
+ /// `Native.install` and `Native.setup` (`microsandbox::setup`).
527
+ ///
528
+ /// The check at the top of [`block_on`] is deliberate and stays there: it is
529
+ /// what makes every other native call safe after a fork. This entry is not a
530
+ /// cheaper alternative to it. It exists because the installer never touches
531
+ /// the default backend or the catalog — `Setup::install` creates the
532
+ /// `bin`/`lib` directories, runs the installed `msb --version`, copies a CI
533
+ /// build or downloads and unpacks the release bundle, and checks that the two
534
+ /// files exist, all under the base directory it resolves from `MSB_HOME` —
535
+ /// so in a forked child the check would only rebuild (and leak) a backend
536
+ /// nothing uses, or fail the install when the inherited backend selection no
537
+ /// longer resolves there. Skipping it leaves the backend record as it was:
538
+ /// the child's next call that does reach the backend is the one that takes
539
+ /// it over.
540
+ ///
541
+ /// Before adding a caller, trace its core path and confirm it never reaches
542
+ /// `microsandbox::default_backend()` (directly or through a `Sandbox`,
543
+ /// `Volume`, `Image`, `Snapshot` or agent function that resolves it).
544
+ pub fn block_on_runtime_install<F>(fut: F) -> Result<F::Output, Error>
308
545
  where
309
546
  F: Future,
310
547
  {