microsandbox-rb 0.17.3 → 0.17.5

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.
@@ -9,6 +9,8 @@ use magnus::{value::ReprValue, Error, ExceptionClass, Module, RClass, RModule, R
9
9
  use microsandbox::{AgentClientError, MicrosandboxError, Operation, UnsupportedReason};
10
10
  use microsandbox_network::policy::BuildError;
11
11
 
12
+ use crate::runtime::ruby;
13
+
12
14
  /// The Ruby class (relative to the `Microsandbox` module) for a core error.
13
15
  /// `"Error"` is the base class; anything else is a named subclass.
14
16
  fn class_name(err: &MicrosandboxError) -> &'static str {
@@ -88,16 +90,16 @@ fn exception_class(ruby: &Ruby, name: &str) -> Option<ExceptionClass> {
88
90
  }
89
91
 
90
92
  /// Convert a core error into a Ruby exception, preserving the typed class.
91
- // The `exception::runtime_error()` fallbacks fire only off a Ruby thread (which
92
- // never happens from a bound method); there is no handle-based alternative there.
93
- #[allow(deprecated)]
93
+ ///
94
+ /// Every constructor in this module builds a Ruby exception class, so it must
95
+ /// run on a Ruby thread holding the GVL: in a bound method, never inside a
96
+ /// `block_on` future or a `nogvl` callback (those return the core error and
97
+ /// get mapped after `block_on` returns). There is no exception class to build
98
+ /// without the GVL, so [`ruby`] panics instead of handing out a handle that
99
+ /// isn't backed by one.
94
100
  pub fn to_ruby(err: MicrosandboxError) -> Error {
95
101
  let message = err.to_string();
96
- let ruby = match Ruby::get() {
97
- Ok(ruby) => ruby,
98
- // Not on a Ruby thread (should never happen from a bound method).
99
- Err(_) => return Error::new(magnus::exception::runtime_error(), message),
100
- };
102
+ let ruby = ruby();
101
103
 
102
104
  // `Unsupported` gets a Ruby-idiom message (`sandbox.kill` instead of
103
105
  // `Sandbox::kill`) plus structured `operation` / `hint` attributes on the
@@ -116,15 +118,8 @@ pub fn to_ruby(err: MicrosandboxError) -> Error {
116
118
  /// backend but have no SDK [`Operation`] (Ruby-only diagnostic hooks such as
117
119
  /// `Microsandbox.runtime_path`). `name` is the Ruby-facing API name. Mirrors
118
120
  /// the Python SDK's name-based `local_only` helper.
119
- #[allow(deprecated)]
120
121
  pub fn local_only(name: &str) -> Error {
121
- match Ruby::get() {
122
- Ok(ruby) => unsupported_error(&ruby, name, "use a local backend"),
123
- Err(_) => Error::new(
124
- magnus::exception::runtime_error(),
125
- format!("{name} is not supported by this backend: use a local backend"),
126
- ),
127
- }
122
+ unsupported_error(&ruby(), name, "use a local backend")
128
123
  }
129
124
 
130
125
  /// Build a `Microsandbox::UnsupportedError` carrying the rendered message and
@@ -191,32 +186,37 @@ fn camel_to_snake(name: &str) -> String {
191
186
 
192
187
  /// A plain `Microsandbox::Error` (base) with a custom message — used for
193
188
  /// binding-level validation errors that have no core counterpart.
194
- #[allow(deprecated)]
195
189
  pub fn base_error(message: impl Into<String>) -> Error {
196
190
  let message = message.into();
197
- match Ruby::get() {
198
- Ok(ruby) => match exception_class(&ruby, "Error") {
199
- Some(class) => Error::new(class, message),
200
- None => Error::new(ruby.exception_runtime_error(), message),
201
- },
202
- Err(_) => Error::new(magnus::exception::runtime_error(), message),
191
+ let ruby = ruby();
192
+ match exception_class(&ruby, "Error") {
193
+ Some(class) => Error::new(class, message),
194
+ None => Error::new(ruby.exception_runtime_error(), message),
203
195
  }
204
196
  }
205
197
 
206
- /// `Microsandbox::ForkedHandleError`: a handle created in process `born` was
207
- /// used in process `cur`, a fork descendant (see `runtime::ForkGuarded`).
208
- /// `class` is the Ruby class users know the handle by.
209
- #[allow(deprecated)]
198
+ /// `Microsandbox::ForkedHandleError`: a handle created in the process with pid
199
+ /// `born` was used in a fork descendant with pid `cur` (see
200
+ /// `runtime::ForkGuarded`). `class` is the Ruby class users know the handle
201
+ /// by. The two pids are equal when the descendant was given its ancestor's
202
+ /// pid again (they differ in fork generation, which is not shown): that
203
+ /// ancestor has exited, so the message must not claim two processes or point
204
+ /// at the original.
210
205
  pub fn forked_handle(class: &str, born: u32, cur: u32) -> Error {
211
- let message = format!(
212
- "{class} was created in process {born} and cannot be used in forked process {cur}; \
213
- create a new handle after fork (the original keeps working in process {born})"
214
- );
215
- match Ruby::get() {
216
- Ok(ruby) => match exception_class(&ruby, "ForkedHandleError") {
217
- Some(class) => Error::new(class, message),
218
- None => Error::new(ruby.exception_runtime_error(), message),
219
- },
220
- Err(_) => Error::new(magnus::exception::runtime_error(), message),
206
+ let message = if born == cur {
207
+ format!(
208
+ "{class} was created in an earlier process that also had pid {born} and cannot be \
209
+ used in this forked process; create a new handle after fork"
210
+ )
211
+ } else {
212
+ format!(
213
+ "{class} was created in process {born} and cannot be used in forked process {cur}; \
214
+ create a new handle after fork (the original keeps working in process {born})"
215
+ )
216
+ };
217
+ let ruby = ruby();
218
+ match exception_class(&ruby, "ForkedHandleError") {
219
+ Some(class) => Error::new(class, message),
220
+ None => Error::new(ruby.exception_runtime_error(), message),
221
221
  }
222
222
  }
@@ -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.
@@ -119,10 +122,52 @@ fn resolved_msb_path() -> Result<String, Error> {
119
122
  Ok(path.to_string_lossy().into_owned())
120
123
  }
121
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
+
122
163
  /// magnus entry point. RubyGems loads this via `require "microsandbox/microsandbox_rb"`,
123
164
  /// which calls `Init_microsandbox_rb` (matching the cdylib `[lib] name`).
124
165
  #[magnus::init]
125
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
+
126
171
  let module = ruby.define_module("Microsandbox")?;
127
172
  let native = module.define_module("Native")?;
128
173
 
@@ -137,6 +182,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
137
182
  )?;
138
183
  native.define_singleton_method("resolved_msb_path", function!(resolved_msb_path, 0))?;
139
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))?;
140
188
 
141
189
  backend::define(ruby, &native)?;
142
190
  sandbox::define(ruby, &native)?;
@@ -43,28 +43,123 @@ use std::ffi::c_void;
43
43
  use std::future::Future;
44
44
  use std::mem::ManuallyDrop;
45
45
  use std::panic::{catch_unwind, AssertUnwindSafe};
46
- use std::sync::atomic::{AtomicPtr, Ordering};
46
+ use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
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")
58
62
  }
59
63
 
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()
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
+ });
130
+ }
131
+
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
+ }
65
159
  }
66
160
 
67
- // The process-wide tokio runtime, tagged with the pid it was built under. A
161
+ // The process-wide tokio runtime, tagged with the identity of the process it
162
+ // was built under (see `ProcessIdentity`). A
68
163
  // multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
69
164
  // ONLY the calling thread — so a child that inherits this runtime has a runtime
70
165
  // whose threads are gone: `block_on` can still drive the current thread, but the
@@ -72,12 +167,12 @@ pub fn current_pid() -> u32 {
72
167
  // connections stall/drop mid-use. This is hit in practice by forking job servers
73
168
  // (Solid Queue, Resque) and clustered web servers (Puma workers).
74
169
  //
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.
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.
79
174
  struct RuntimeCell {
80
- pid: u32,
175
+ owner: ProcessIdentity,
81
176
  rt: Runtime,
82
177
  }
83
178
 
@@ -94,13 +189,13 @@ fn build_runtime() -> Runtime {
94
189
  /// The multi-threaded tokio runtime for THIS process, built on first use and
95
190
  /// rebuilt after a `fork(2)` (see the note above).
96
191
  pub fn runtime() -> &'static Runtime {
97
- let cur = current_pid();
192
+ let cur = ProcessIdentity::current();
98
193
  let mut observed = RUNTIME.load(Ordering::Acquire);
99
194
  // SAFETY (every dereference below): a non-null pointer in `RUNTIME` came
100
195
  // from `Box::into_raw` and is never freed once published, so it is valid
101
196
  // for `'static`.
102
197
  if let Some(cell) = unsafe { observed.as_ref() } {
103
- if cell.pid == cur {
198
+ if cell.owner == cur {
104
199
  // Built in this process: its threads are alive here.
105
200
  return &cell.rt;
106
201
  }
@@ -109,7 +204,7 @@ pub fn runtime() -> &'static Runtime {
109
204
  // Slow path: no runtime yet, or the one we see was built by a parent
110
205
  // process. Build ours and publish it with a compare-exchange.
111
206
  let fresh = Box::into_raw(Box::new(RuntimeCell {
112
- pid: cur,
207
+ owner: cur,
113
208
  rt: build_runtime(),
114
209
  }));
115
210
  loop {
@@ -117,12 +212,12 @@ pub fn runtime() -> &'static Runtime {
117
212
  // Published. The cell we replaced (if any) belonged to a parent
118
213
  // process and is LEAKED on purpose, never dropped: its worker
119
214
  // threads did not survive the fork, and `Runtime`'s `Drop` joins
120
- // them. One runtime is leaked per fork generation that uses the
215
+ // them. One runtime is leaked per forked process that uses the
121
216
  // SDK.
122
217
  Ok(_) => return unsafe { &(*fresh).rt },
123
218
  Err(winner) => {
124
219
  if let Some(cell) = unsafe { winner.as_ref() } {
125
- if cell.pid == cur {
220
+ if cell.owner == cur {
126
221
  // Another thread of THIS process won the race. Our
127
222
  // cell was never published and its threads are alive
128
223
  // here, so dropping it is safe (and joins them).
@@ -153,7 +248,7 @@ pub fn runtime() -> &'static Runtime {
153
248
  /// exits: Ruby frees every `T_DATA` object in `rb_objspace_call_finalizer` —
154
249
  /// leaks it instead.
155
250
  pub struct ForkGuarded<T> {
156
- pid: u32,
251
+ owner: ProcessIdentity,
157
252
  /// The Ruby class users know the handle by, for the error message.
158
253
  class: &'static str,
159
254
  inner: ManuallyDrop<T>,
@@ -162,7 +257,18 @@ pub struct ForkGuarded<T> {
162
257
  impl<T> ForkGuarded<T> {
163
258
  pub fn new(class: &'static str, inner: T) -> Self {
164
259
  Self {
165
- pid: current_pid(),
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,
166
272
  class,
167
273
  inner: ManuallyDrop::new(inner),
168
274
  }
@@ -171,11 +277,15 @@ impl<T> ForkGuarded<T> {
171
277
  /// The payload, or `ForkedHandleError` when this is not the process that
172
278
  /// created it.
173
279
  pub fn get(&self) -> Result<&T, Error> {
174
- let cur = current_pid();
175
- if self.pid == cur {
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 {
176
286
  Ok(&self.inner)
177
287
  } else {
178
- Err(error::forked_handle(self.class, self.pid, cur))
288
+ Err(error::forked_handle(self.class, self.owner.pid, cur.pid))
179
289
  }
180
290
  }
181
291
 
@@ -191,7 +301,7 @@ impl<T> ForkGuarded<T> {
191
301
 
192
302
  impl<T> Drop for ForkGuarded<T> {
193
303
  fn drop(&mut self) {
194
- if self.pid == current_pid() {
304
+ if self.owner == ProcessIdentity::current() {
195
305
  // SAFETY: dropped exactly once, here; `inner` is not used again.
196
306
  unsafe { ManuallyDrop::drop(&mut self.inner) };
197
307
  }
@@ -209,6 +319,13 @@ impl<T> Drop for ForkGuarded<T> {
209
319
  /// Every module's `define` calls this for each type it wraps, right after
210
320
  /// defining the class, so wrapping a value afterwards is `TypedData::class`
211
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.
212
329
  pub fn preload_class<T: TypedData>(ruby: &Ruby) {
213
330
  let _ = T::class(ruby);
214
331
  }
@@ -390,6 +507,9 @@ where
390
507
  /// `Err` means the future was never polled (see [`nogvl`], and the fork check
391
508
  /// below); propagate it with `?` before looking at the future's own output:
392
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`].
393
513
  pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
394
514
  where
395
515
  F: Future,
@@ -401,3 +521,29 @@ where
401
521
  crate::backend::ensure_this_process_backend()?;
402
522
  nogvl(|| runtime().block_on(fut))
403
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>
545
+ where
546
+ F: Future,
547
+ {
548
+ nogvl(|| runtime().block_on(fut))
549
+ }
@@ -497,7 +497,7 @@ module Microsandbox
497
497
  opts = build_create_opts(**kwargs)
498
498
  Microsandbox.ensure_runtime!
499
499
  # Everything Ruby-level happens BEFORE `acquire`: its interrupt mask must
500
- # cover only the native call plus the wrap, never caller-supplied code.
500
+ # cover only the native call, never caller-supplied code.
501
501
  sandbox_name = name.to_s
502
502
  sandbox = acquire { Native::Sandbox.create(sandbox_name, opts) }
503
503
  return sandbox unless block_given?
@@ -603,17 +603,34 @@ module Microsandbox
603
603
  # sandbox would be garbage and only GC would stop it, "eventually"; for
604
604
  # `Timeout.timeout { Sandbox.create(...) }` that is not good enough. So:
605
605
  #
606
- # - the acquisition runs under `Thread.handle_interrupt(Object => :never)`,
607
- # which holds a queued exception (or kill) back until the wrapper is
608
- # assigned. The mask covers only the uninterruptible native call plus
609
- # the wrap, so it changes no observable timing; runtime provisioning
610
- # ({Microsandbox.ensure_runtime!}) and the block forms' `yield` stay
611
- # outside it, with normal interrupt handling. For the same reason the
612
- # block passed here must contain NOTHING but the native call: argument
613
- # conversion (`name.to_s`, option Hashes) is the caller's own code and
614
- # has to run before `acquire`, where it stays interruptible;
615
- # - the `ensure` plus the `acquired` flag — not `rescue Exception; raise` —
616
- # is what also covers `Thread#kill`, which no `rescue` can catch.
606
+ # - the native call runs under `Thread.handle_interrupt(Object => :never)`,
607
+ # which holds a queued exception (or kill) back until the bare
608
+ # {Native::Sandbox} is assigned to a local. The mask covers only that
609
+ # uninterruptible call, so it changes no observable timing; runtime
610
+ # provisioning ({Microsandbox.ensure_runtime!}), the wrapper's
611
+ # construction and the block forms' `yield` stay outside it, with
612
+ # normal interrupt handling. For the same reason the block passed here
613
+ # must contain NOTHING but the native call: argument conversion
614
+ # (`name.to_s`, option Hashes) is the caller's own code and has to run
615
+ # before `acquire`, where it stays interruptible;
616
+ # - the wrapper is built after the mask (`new` may run a subclass's
617
+ # `initialize`, which must not hold a `Timeout`/`Thread#kill` back), so
618
+ # a held-back interrupt is delivered as the mask ends, before `new`.
619
+ # Until `new` has returned, the cleanup therefore works on the bare
620
+ # native handle, never on the wrapper: an interrupt at that point, one
621
+ # that lands inside a subclass's `initialize`, and an exception that
622
+ # `initialize` raises itself all leave the caller without a reference,
623
+ # so all three stop the sandbox (when owned, see below);
624
+ # - the `ensure` plus the `handed_off` flag — not `rescue Exception;
625
+ # raise` — is what also covers `Thread#kill`, which no `rescue` can
626
+ # catch.
627
+ #
628
+ # The protected window is the native handle's assignment through the
629
+ # wrapper's construction, and the cleanup in it is best-effort: a
630
+ # *further* interrupt that lands during the cleanup, before the native
631
+ # `stop` has started, or one delivered at this method's return boundary
632
+ # (after `handed_off` is set, before the caller has assigned the result),
633
+ # leaves the teardown to GC.
617
634
  #
618
635
  # A SIGINT (Ctrl-C on the main thread) is the exception: it is not queued
619
636
  # like those two and cannot be masked. One that lands during the native
@@ -625,30 +642,32 @@ module Microsandbox
625
642
  # Ownership: {connect_or_create} can hand back a sandbox that was already
626
643
  # running for someone else, and {start} restarts a persisted one. The
627
644
  # cleanup therefore applies EXACTLY the predicate the block form of
628
- # {connect_or_create} uses — {Sandbox#owns_lifecycle?} — in all three
645
+ # {connect_or_create} uses — the lifecycle ownership behind
646
+ # {Sandbox#owns_lifecycle?}, read from the native handle — in all three
629
647
  # callers, {create} included: a sandbox this call does not own is never
630
648
  # stopped here. Its reference is just let go (GC closes the handle, the
631
649
  # sandbox keeps running).
632
650
  # @yieldreturn [Native::Sandbox]
633
651
  # @return [Sandbox]
634
652
  def acquire
635
- sandbox = nil
636
- acquired = false
653
+ native = nil
654
+ handed_off = false
637
655
  begin
638
656
  Thread.handle_interrupt(Object => :never) do
639
- sandbox = new(yield)
657
+ native = yield
640
658
  end
641
- acquired = true
659
+ sandbox = new(native)
660
+ handed_off = true
661
+ sandbox
642
662
  ensure
643
- if sandbox && !acquired && sandbox.owns_lifecycle?
663
+ if native && !handed_off
644
664
  begin
645
- sandbox.stop
665
+ native.stop if native.owns_lifecycle
646
666
  rescue Microsandbox::Error
647
667
  # best-effort cleanup; the interrupt is what the caller must see
648
668
  end
649
669
  end
650
670
  end
651
- sandbox
652
671
  end
653
672
 
654
673
  # @api private
@@ -8,7 +8,7 @@ module Microsandbox
8
8
  # Versioning section of the README for the full gem-to-runtime map. Must equal
9
9
  # the native ext's Cargo crate version (`Native.version`), enforced by
10
10
  # spec/unit/version_spec.rb.
11
- VERSION = "0.17.3"
11
+ VERSION = "0.17.5"
12
12
 
13
13
  # The upstream microsandbox runtime release this gem build embeds — the `tag`
14
14
  # pinned on the `microsandbox`/`microsandbox-network` git deps in
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: microsandbox-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.3
4
+ version: 0.17.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao