microsandbox-rb 0.17.3 → 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.
@@ -8,7 +8,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
9
  # The core-crate dependencies below stay on their own tag, which is
10
10
  # Microsandbox::RUNTIME_VERSION and moves independently of this version.
11
- version = "0.17.3"
11
+ version = "0.17.4"
12
12
  authors = ["Super Rad Company <development@superrad.company>"]
13
13
  repository = "https://github.com/superradcompany/microsandbox"
14
14
  license = "Apache-2.0"
@@ -25,12 +25,19 @@ crate-type = ["cdylib"]
25
25
  [dependencies]
26
26
  # "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
27
27
  # uses to check interrupts without letting Ruby longjmp over Rust frames.
28
- magnus = { version = "0.8", features = ["rb-sys"] }
28
+ # Default features are off: `old-api` (the free functions that conjure a `Ruby`
29
+ # handle unchecked in release builds) must not creep back, and `io` only gates
30
+ # `magnus::io`, which the extension does not use.
31
+ magnus = { version = "0.9", default-features = false, features = ["rb-sys"] }
29
32
  # Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
30
33
  # release during blocking sandbox calls) and rb_thread_check_ints. Kept in
31
34
  # lock-step with magnus's own rb-sys.
32
35
  rb-sys = "0.9"
33
36
 
37
+ # `pthread_atfork`, for the fork generation (runtime::ProcessIdentity). Already
38
+ # in the dependency graph; listed so the binding can call it directly.
39
+ libc = "0.2"
40
+
34
41
  # The microsandbox core runtime, wrapped via FFI exactly like the official
35
42
  # Python (pyo3) and Node (napi) SDKs. Pinned to the matching upstream tag so the
36
43
  # gem is self-contained and buildable anywhere (CI, release containers, end-user
@@ -16,14 +16,14 @@
16
16
  //! matching Python and Node.
17
17
 
18
18
  use std::collections::HashMap;
19
- use std::sync::atomic::{AtomicU32, Ordering};
19
+ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
20
20
  use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
21
21
 
22
22
  use magnus::{function, prelude::*, Error, RModule, Ruby};
23
23
  use microsandbox::{Backend, MicrosandboxError, Operation};
24
24
 
25
25
  use crate::error;
26
- use crate::runtime::{block_on, current_pid};
26
+ use crate::runtime::{block_on, ProcessIdentity};
27
27
 
28
28
  /// Resolve the ambient default backend, requiring it to be local.
29
29
  ///
@@ -99,7 +99,7 @@ fn build_backend(
99
99
  }
100
100
 
101
101
  //--------------------------------------------------------------------------------------------------
102
- // Fork safety: the backend generation
102
+ // Fork safety: the backend record
103
103
  //--------------------------------------------------------------------------------------------------
104
104
  //
105
105
  // `fork(2)` copies only the calling thread. A child of a process that used the
@@ -114,7 +114,7 @@ fn build_backend(
114
114
  // So the child never touches what it inherited: the first native call after a
115
115
  // fork builds a fresh backend the same way the inherited one was chosen and
116
116
  // swaps it in, and every inherited `Arc` is `mem::forget`-ed — leaked once per
117
- // fork generation — instead of dropped.
117
+ // forked process — instead of dropped.
118
118
  //
119
119
  // Locking. The three mutexes below are only ever taken on a Ruby thread that
120
120
  // holds the GVL, and nothing that runs while one is held calls into Ruby or
@@ -158,9 +158,11 @@ impl Selection {
158
158
 
159
159
  static SELECTION: Mutex<Selection> = Mutex::new(Selection::Ladder);
160
160
 
161
- /// The pid of the process whose default backend the core currently holds.
162
- /// 0 = no native operation has touched the backend in this process lineage.
163
- static BACKEND_PID: AtomicU32 = AtomicU32::new(0);
161
+ /// The identity of the process whose default backend the core currently holds
162
+ /// ([`ProcessIdentity::to_bits`]: pid and fork generation in one word, so it
163
+ /// is read and written whole). 0 = no native operation has touched the
164
+ /// backend in this process lineage.
165
+ static BACKEND_OWNER: AtomicU64 = AtomicU64::new(0);
164
166
 
165
167
  static REBUILD_LOCK: Mutex<()> = Mutex::new(());
166
168
 
@@ -183,7 +185,7 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
183
185
  /// above).
184
186
  pub fn ensure_this_process_backend() -> Result<(), Error> {
185
187
  // The Ruby exception is built here, after the locks are released.
186
- adopt_backend(current_pid(), None)
188
+ adopt_backend(ProcessIdentity::current(), None)
187
189
  .map(|_| ())
188
190
  .map_err(error::to_ruby)
189
191
  }
@@ -204,23 +206,24 @@ pub fn ensure_this_process_backend() -> Result<(), Error> {
204
206
  /// Returns `Some(inherited selection)` when `replacement` was installed here
205
207
  /// (the caller has nothing left to install), `None` when the caller proceeds
206
208
  /// as in any process that already owns its backend. On an error nothing has
207
- /// changed, `BACKEND_PID` included, so the next call tries again.
209
+ /// changed, `BACKEND_OWNER` included, so the next call tries again.
208
210
  fn adopt_backend(
209
- cur: u32,
211
+ cur: ProcessIdentity,
210
212
  replacement: Option<&Selection>,
211
213
  ) -> Result<Option<Selection>, MicrosandboxError> {
212
- if BACKEND_PID.load(Ordering::Acquire) == cur {
214
+ let cur_bits = cur.to_bits();
215
+ if BACKEND_OWNER.load(Ordering::Acquire) == cur_bits {
213
216
  return Ok(None);
214
217
  }
215
218
  let _rebuild = lock(&REBUILD_LOCK);
216
- let seen = BACKEND_PID.load(Ordering::Acquire);
217
- if seen == cur {
219
+ let seen = BACKEND_OWNER.load(Ordering::Acquire);
220
+ if seen == cur_bits {
218
221
  return Ok(None);
219
222
  }
220
223
  if seen == 0 {
221
224
  // Nothing was inherited; the core resolves its default lazily, in this
222
225
  // process, on first use.
223
- BACKEND_PID.store(cur, Ordering::Release);
226
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
224
227
  return Ok(None);
225
228
  }
226
229
 
@@ -233,14 +236,14 @@ fn adopt_backend(
233
236
  // The inherited default must never be dropped (see above).
234
237
  std::mem::forget(microsandbox::swap_default_backend(fresh));
235
238
  // Neither may the backends that open `with_backend` scopes saved for their
236
- // restore. The entries themselves stay — pid and selection — so a scope
239
+ // restore. The entries themselves stay — owner and selection — so a scope
237
240
  // the parent opened can still be closed here (see `pop_default_backend`).
238
241
  for scope in lock(backend_scopes()).values_mut() {
239
242
  std::mem::forget(scope.previous.take());
240
243
  }
241
244
  let inherited =
242
245
  replacement.map(|selection| std::mem::replace(&mut *lock(&SELECTION), selection.clone()));
243
- BACKEND_PID.store(cur, Ordering::Release);
246
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
244
247
  Ok(inherited)
245
248
  }
246
249
 
@@ -261,7 +264,7 @@ fn set_default_backend(
261
264
  // A forked child that starts by selecting a backend: the inherited default
262
265
  // is forgotten and this selection installed in its place, without ever
263
266
  // rebuilding the inherited one.
264
- if adopt_backend(current_pid(), Some(&selection))
267
+ if adopt_backend(ProcessIdentity::current(), Some(&selection))
265
268
  .map_err(error::to_ruby)?
266
269
  .is_some()
267
270
  {
@@ -283,7 +286,7 @@ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Scope>>> = OnceLock::new();
283
286
  /// What an open `with_backend` scope restores when it closes.
284
287
  struct Scope {
285
288
  /// The process that opened the scope.
286
- pid: u32,
289
+ owner: ProcessIdentity,
287
290
  /// The backend that was the default before the push, kept to be put back
288
291
  /// by a pop in the same process. `None` when there is no such backend of
289
292
  /// this process's own: a forked child took it out to leak it, or the push
@@ -308,7 +311,7 @@ fn push_default_backend(
308
311
  api_key: Option<String>,
309
312
  profile: Option<String>,
310
313
  ) -> Result<u32, Error> {
311
- let cur = current_pid();
314
+ let cur = ProcessIdentity::current();
312
315
  let selection = Selection::Explicit {
313
316
  kind,
314
317
  url,
@@ -332,7 +335,7 @@ fn push_default_backend(
332
335
  lock(backend_scopes()).insert(
333
336
  token,
334
337
  Scope {
335
- pid: cur,
338
+ owner: cur,
336
339
  previous,
337
340
  selection: before,
338
341
  },
@@ -352,7 +355,7 @@ fn push_default_backend(
352
355
  /// is a state the caller can describe and leave with `set_default_backend`;
353
356
  /// a token nobody can retry (`with_backend` never hands it out) would not be.
354
357
  fn pop_default_backend(token: u32) -> Result<(), Error> {
355
- let cur = current_pid();
358
+ let cur = ProcessIdentity::current();
356
359
  // (The registry lock is not held while an exception is built.)
357
360
  let Some(scope) = lock(backend_scopes()).remove(&token) else {
358
361
  return Err(error::base_error("unknown backend scope token"));
@@ -384,7 +387,7 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
384
387
 
385
388
  match scope.previous {
386
389
  // Opened and closed in the same process: put the saved backend back.
387
- Some(previous) if scope.pid == cur => microsandbox::set_default_backend(previous),
390
+ Some(previous) if scope.owner == cur => microsandbox::set_default_backend(previous),
388
391
  // No backend of this process's own was saved (see `Scope::previous`):
389
392
  // restore one built here from how the saved one had been chosen. On
390
393
  // failure the temporary backend stays installed (see above). The
@@ -393,8 +396,9 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
393
396
  // forked child ever drops a backend it might have inherited. That
394
397
  // leaks at most one backend per scope closed this way.
395
398
  previous => {
396
- // `None` unless the pid of the process that opened the scope was
397
- // reused; an inherited `Arc` is leaked either way.
399
+ // Always `None` here: a process that is not the scope's opener
400
+ // took the saved backend out when it adopted (`adopt_backend`).
401
+ // An inherited `Arc` would be leaked either way.
398
402
  std::mem::forget(previous);
399
403
  let fresh = scope.selection.build().map_err(error::to_ruby)?;
400
404
  std::mem::forget(microsandbox::swap_default_backend(fresh));
@@ -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)?;