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,14 +16,14 @@
16
16
  //! matching Python and Node.
17
17
 
18
18
  use std::collections::HashMap;
19
- use std::sync::atomic::{AtomicU32, Ordering};
20
- use std::sync::{Arc, Mutex, OnceLock};
19
+ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
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;
26
+ use crate::runtime::{block_on, ProcessIdentity};
27
27
 
28
28
  /// Resolve the ambient default backend, requiring it to be local.
29
29
  ///
@@ -31,7 +31,8 @@ use crate::runtime::block_on;
31
31
  /// borrowing `&LocalBackend` from it (`as_local()` borrows the `Arc`). Pure
32
32
  /// Rust — safe to call inside `block_on` (no Ruby C API), and it returns a raw
33
33
  /// `MicrosandboxError` so the Ruby-exception mapping happens *after* `block_on`
34
- /// re-acquires the GVL. Cloud backends yield `Unsupported` for the given
34
+ /// re-acquires the GVL. It does not run [`ensure_this_process_backend`] (that
35
+ /// needs the GVL): the `block_on` it executes under already did. Cloud backends yield `Unsupported` for the given
35
36
  /// operation (v0.6.8 `Operation`-keyed shape), mirroring pyo3's `resolve_local`.
36
37
  pub fn local_backend(op: Operation) -> Result<Arc<dyn Backend>, MicrosandboxError> {
37
38
  let backend = microsandbox::default_backend();
@@ -65,14 +66,16 @@ pub fn with_local_backend<T>(
65
66
 
66
67
  /// Build an `Arc<dyn Backend>` from the SDK facade. Ported from pyo3
67
68
  /// `build_backend` (`sdk/python/src/lib.rs`) / node `build_backend`. Synchronous
68
- /// (no network I/O): cloud construction only builds the HTTP client. Runs on the
69
- /// Ruby thread with the GVL held, so it may map errors to Ruby directly.
69
+ /// (no network I/O): cloud construction only builds the HTTP client. Pure Rust
70
+ /// — it is also called with one of the mutexes below held, where calling into
71
+ /// Ruby (to build an exception) is not allowed; callers map the error
72
+ /// afterwards.
70
73
  fn build_backend(
71
- kind: String,
74
+ kind: &str,
72
75
  url: Option<String>,
73
76
  api_key: Option<String>,
74
77
  profile: Option<String>,
75
- ) -> Result<Arc<dyn Backend>, Error> {
78
+ ) -> Result<Arc<dyn Backend>, MicrosandboxError> {
76
79
  match kind.trim().to_ascii_lowercase().as_str() {
77
80
  "local" => Ok(Arc::new(microsandbox::LocalBackend::lazy())),
78
81
  "cloud" => {
@@ -81,21 +84,169 @@ fn build_backend(
81
84
  None => match (url, api_key) {
82
85
  (Some(url), Some(api_key)) => microsandbox::CloudBackend::new(url, api_key),
83
86
  _ => {
84
- return Err(error::to_ruby(MicrosandboxError::InvalidConfig(
87
+ return Err(MicrosandboxError::InvalidConfig(
85
88
  "cloud backend requires url + api_key or profile".into(),
86
- )))
89
+ ))
87
90
  }
88
91
  },
89
- }
90
- .map_err(error::to_ruby)?;
92
+ }?;
91
93
  Ok(Arc::new(cloud))
92
94
  }
93
- other => Err(error::to_ruby(MicrosandboxError::InvalidConfig(format!(
95
+ other => Err(MicrosandboxError::InvalidConfig(format!(
94
96
  "backend kind must be 'local' or 'cloud', got {other:?}"
95
- )))),
97
+ ))),
98
+ }
99
+ }
100
+
101
+ //--------------------------------------------------------------------------------------------------
102
+ // Fork safety: the backend record
103
+ //--------------------------------------------------------------------------------------------------
104
+ //
105
+ // `fork(2)` copies only the calling thread. A child of a process that used the
106
+ // SDK inherits the core's process-wide default backend — a `LocalBackend`
107
+ // whose SQLite connections are each served by a worker thread that does not
108
+ // exist in the child, or a `CloudBackend` whose pooled connections belong to
109
+ // the parent's dead tokio runtime. Using it hangs (Linux: the pool's 30 s
110
+ // acquire timeout) or kills the process (macOS: waking the missing worker
111
+ // thread traps in libdispatch), and so does DROPPING it, because the
112
+ // destructor wakes the same threads.
113
+ //
114
+ // So the child never touches what it inherited: the first native call after a
115
+ // fork builds a fresh backend the same way the inherited one was chosen and
116
+ // swaps it in, and every inherited `Arc` is `mem::forget`-ed — leaked once per
117
+ // forked process — instead of dropped.
118
+ //
119
+ // Locking. The three mutexes below are only ever taken on a Ruby thread that
120
+ // holds the GVL, and nothing that runs while one is held calls into Ruby or
121
+ // releases the GVL (which is why the code under them is pure Rust and Ruby
122
+ // exceptions are built after they are released). Ruby-level `fork` needs the
123
+ // GVL too, so no fork can happen while one of them is held: a child never
124
+ // inherits them locked.
125
+
126
+ /// How the current default backend was chosen, so a forked child can choose
127
+ /// it again. Deliberately not `Debug`: `Explicit` carries the cloud API key
128
+ /// (which the backend `Arc` in this process holds anyway — recording it here
129
+ /// adds no new exposure, as long as it is never printed).
130
+ #[derive(Clone)]
131
+ enum Selection {
132
+ /// The core's own resolution ladder (`MSB_BACKEND`, `MSB_PROFILE`, the
133
+ /// config file, else local): what `default_backend()` does on first use.
134
+ Ladder,
135
+ /// A `set_default_backend` / `with_backend` selection.
136
+ Explicit {
137
+ kind: String,
138
+ url: Option<String>,
139
+ api_key: Option<String>,
140
+ profile: Option<String>,
141
+ },
142
+ }
143
+
144
+ impl Selection {
145
+ /// A fresh backend for this selection, owned by the calling process.
146
+ fn build(&self) -> Result<Arc<dyn Backend>, MicrosandboxError> {
147
+ match self {
148
+ Selection::Ladder => microsandbox::resolve_default_backend(),
149
+ Selection::Explicit {
150
+ kind,
151
+ url,
152
+ api_key,
153
+ profile,
154
+ } => build_backend(kind, url.clone(), api_key.clone(), profile.clone()),
155
+ }
96
156
  }
97
157
  }
98
158
 
159
+ static SELECTION: Mutex<Selection> = Mutex::new(Selection::Ladder);
160
+
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);
166
+
167
+ static REBUILD_LOCK: Mutex<()> = Mutex::new(());
168
+
169
+ /// Lock one of this module's mutexes. A poisoned one is recovered: the data
170
+ /// behind them stays consistent across a panic (plain assignments), and
171
+ /// refusing to go on would only turn one failure into a permanent one.
172
+ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
173
+ mutex.lock().unwrap_or_else(PoisonError::into_inner)
174
+ }
175
+
176
+ /// Make sure the core's default backend belongs to THIS process, rebuilding it
177
+ /// after a fork. Every Ruby-facing entry that can reach the default backend
178
+ /// runs this first: [`crate::runtime::block_on`] for the async operations, and
179
+ /// each synchronous entry that reads the backend itself. (The three that are
180
+ /// about to replace the default anyway — `set_default_backend`,
181
+ /// `push_default_backend` and `pop_default_backend` — adopt with the
182
+ /// selection they install instead.)
183
+ ///
184
+ /// Runs only with the GVL held — never inside a future (see the locking note
185
+ /// above).
186
+ pub fn ensure_this_process_backend() -> Result<(), Error> {
187
+ // The Ruby exception is built here, after the locks are released.
188
+ adopt_backend(ProcessIdentity::current(), None)
189
+ .map(|_| ())
190
+ .map_err(error::to_ruby)
191
+ }
192
+
193
+ /// Take the backend state over for process `cur` if it still belongs to a
194
+ /// parent process: install a backend built in this process and leak every
195
+ /// inherited one.
196
+ ///
197
+ /// What gets installed is `replacement` when the caller itself determines the
198
+ /// backend (a selection, or the restore of a scope), and otherwise a rebuild
199
+ /// of the inherited selection. The difference
200
+ /// matters when the inherited selection no longer resolves in the child (say
201
+ /// the ladder now reads `MSB_BACKEND=cloud` without credentials): the rebuild
202
+ /// fails, and keeps failing on every call, but an explicit selection must
203
+ /// still get the child out of that state — so it never depends on the
204
+ /// inherited one.
205
+ ///
206
+ /// Returns `Some(inherited selection)` when `replacement` was installed here
207
+ /// (the caller has nothing left to install), `None` when the caller proceeds
208
+ /// as in any process that already owns its backend. On an error nothing has
209
+ /// changed, `BACKEND_OWNER` included, so the next call tries again.
210
+ fn adopt_backend(
211
+ cur: ProcessIdentity,
212
+ replacement: Option<&Selection>,
213
+ ) -> Result<Option<Selection>, MicrosandboxError> {
214
+ let cur_bits = cur.to_bits();
215
+ if BACKEND_OWNER.load(Ordering::Acquire) == cur_bits {
216
+ return Ok(None);
217
+ }
218
+ let _rebuild = lock(&REBUILD_LOCK);
219
+ let seen = BACKEND_OWNER.load(Ordering::Acquire);
220
+ if seen == cur_bits {
221
+ return Ok(None);
222
+ }
223
+ if seen == 0 {
224
+ // Nothing was inherited; the core resolves its default lazily, in this
225
+ // process, on first use.
226
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
227
+ return Ok(None);
228
+ }
229
+
230
+ // A process that used the backend forked us. Build ours first, so that a
231
+ // failure leaves everything as it was.
232
+ let fresh = match replacement {
233
+ Some(selection) => selection.build()?,
234
+ None => lock(&SELECTION).build()?,
235
+ };
236
+ // The inherited default must never be dropped (see above).
237
+ std::mem::forget(microsandbox::swap_default_backend(fresh));
238
+ // Neither may the backends that open `with_backend` scopes saved for their
239
+ // restore. The entries themselves stay — owner and selection — so a scope
240
+ // the parent opened can still be closed here (see `pop_default_backend`).
241
+ for scope in lock(backend_scopes()).values_mut() {
242
+ std::mem::forget(scope.previous.take());
243
+ }
244
+ let inherited =
245
+ replacement.map(|selection| std::mem::replace(&mut *lock(&SELECTION), selection.clone()));
246
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
247
+ Ok(inherited)
248
+ }
249
+
99
250
  /// Install a process-wide default backend. Synchronous (an `RwLock` write) — no
100
251
  /// `block_on`. Mirrors Python `set_default_backend` / Node `setDefaultBackend`.
101
252
  fn set_default_backend(
@@ -104,7 +255,23 @@ fn set_default_backend(
104
255
  api_key: Option<String>,
105
256
  profile: Option<String>,
106
257
  ) -> Result<(), Error> {
107
- microsandbox::set_default_backend(build_backend(kind, url, api_key, profile)?);
258
+ let selection = Selection::Explicit {
259
+ kind,
260
+ url,
261
+ api_key,
262
+ profile,
263
+ };
264
+ // A forked child that starts by selecting a backend: the inherited default
265
+ // is forgotten and this selection installed in its place, without ever
266
+ // rebuilding the inherited one.
267
+ if adopt_backend(ProcessIdentity::current(), Some(&selection))
268
+ .map_err(error::to_ruby)?
269
+ .is_some()
270
+ {
271
+ return Ok(());
272
+ }
273
+ microsandbox::set_default_backend(selection.build().map_err(error::to_ruby)?);
274
+ *lock(&SELECTION) = selection;
108
275
  Ok(())
109
276
  }
110
277
 
@@ -114,9 +281,24 @@ fn set_default_backend(
114
281
  // swaps the default and stores the previous backend under a fresh token; pop
115
282
  // restores it. The Ruby wrapper drives this through an `ensure` block.
116
283
  static NEXT_BACKEND_SCOPE: AtomicU32 = AtomicU32::new(1);
117
- static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Arc<dyn Backend>>>> = OnceLock::new();
284
+ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Scope>>> = OnceLock::new();
285
+
286
+ /// What an open `with_backend` scope restores when it closes.
287
+ struct Scope {
288
+ /// The process that opened the scope.
289
+ owner: ProcessIdentity,
290
+ /// The backend that was the default before the push, kept to be put back
291
+ /// by a pop in the same process. `None` when there is no such backend of
292
+ /// this process's own: a forked child took it out to leak it, or the push
293
+ /// was the child's first use of the backend and replaced an inherited
294
+ /// default ([`adopt_backend`]). The pop then rebuilds from `selection`.
295
+ previous: Option<Arc<dyn Backend>>,
296
+ /// How that previous backend had been chosen: what a rebuild starts from,
297
+ /// and what `SELECTION` goes back to when the scope closes.
298
+ selection: Selection,
299
+ }
118
300
 
119
- fn backend_scopes() -> &'static Mutex<HashMap<u32, Arc<dyn Backend>>> {
301
+ fn backend_scopes() -> &'static Mutex<HashMap<u32, Scope>> {
120
302
  BACKEND_SCOPES.get_or_init(|| Mutex::new(HashMap::new()))
121
303
  }
122
304
 
@@ -129,34 +311,112 @@ fn push_default_backend(
129
311
  api_key: Option<String>,
130
312
  profile: Option<String>,
131
313
  ) -> Result<u32, Error> {
132
- let previous = microsandbox::swap_default_backend(build_backend(kind, url, api_key, profile)?);
314
+ let cur = ProcessIdentity::current();
315
+ let selection = Selection::Explicit {
316
+ kind,
317
+ url,
318
+ api_key,
319
+ profile,
320
+ };
321
+ let (previous, before) = match adopt_backend(cur, Some(&selection)).map_err(error::to_ruby)? {
322
+ // A forked child's first use of the backend: the scoped backend
323
+ // replaced the inherited default (now forgotten), so there is nothing
324
+ // of this process to put back. The pop rebuilds from how the inherited
325
+ // default had been chosen.
326
+ Some(inherited) => (None, inherited),
327
+ None => {
328
+ let previous =
329
+ microsandbox::swap_default_backend(selection.build().map_err(error::to_ruby)?);
330
+ let before = std::mem::replace(&mut *lock(&SELECTION), selection);
331
+ (Some(previous), before)
332
+ }
333
+ };
133
334
  let token = NEXT_BACKEND_SCOPE.fetch_add(1, Ordering::Relaxed);
134
- backend_scopes()
135
- .lock()
136
- .map_err(|_| error::base_error("backend scope registry poisoned"))?
137
- .insert(token, previous);
335
+ lock(backend_scopes()).insert(
336
+ token,
337
+ Scope {
338
+ owner: cur,
339
+ previous,
340
+ selection: before,
341
+ },
342
+ );
138
343
  Ok(token)
139
344
  }
140
345
 
141
- /// Restore the backend saved by [`push_default_backend`].
346
+ /// Close the scope opened by [`push_default_backend`]: restore the backend it
347
+ /// saved.
348
+ ///
349
+ /// A pop consumes its token whether or not it succeeds. It can fail only when
350
+ /// the backend to restore has to be rebuilt from how it had been chosen (the
351
+ /// saved one was the parent's) and that selection does not resolve in this
352
+ /// process. The scope is closed anyway and its temporary selection stays in
353
+ /// effect: the temporary backend remains installed, or — in a child that has
354
+ /// not yet built a backend of its own — is what the next call rebuilds. That
355
+ /// is a state the caller can describe and leave with `set_default_backend`;
356
+ /// a token nobody can retry (`with_backend` never hands it out) would not be.
142
357
  fn pop_default_backend(token: u32) -> Result<(), Error> {
143
- let previous = backend_scopes()
144
- .lock()
145
- .map_err(|_| error::base_error("backend scope registry poisoned"))?
146
- .remove(&token)
147
- .ok_or_else(|| error::base_error("unknown backend scope token"))?;
148
- microsandbox::set_default_backend(previous);
358
+ let cur = ProcessIdentity::current();
359
+ // (The registry lock is not held while an exception is built.)
360
+ let Some(scope) = lock(backend_scopes()).remove(&token) else {
361
+ return Err(error::base_error("unknown backend scope token"));
362
+ };
363
+
364
+ // A forked child whose first use of the backend is this pop (the parent
365
+ // forked inside a `with_backend` block and the child now leaves it) takes
366
+ // over with what the scope restores — directly, never by way of the
367
+ // scope's temporary backend, which the child may be unable to rebuild (a
368
+ // cloud profile that is gone there) and has no use for.
369
+ match adopt_backend(cur, Some(&scope.selection)) {
370
+ // Done: the restored selection is installed and recorded. The backend
371
+ // this scope had saved is the parent's; it left the registry above, so
372
+ // it is leaked here rather than by the adoption.
373
+ Ok(Some(_temporary)) => {
374
+ std::mem::forget(scope.previous);
375
+ return Ok(());
376
+ }
377
+ // This process already owns its backend: restore below.
378
+ Ok(None) => {}
379
+ // Nothing was changed (see `adopt_backend`), so everything in sight is
380
+ // still the parent's: leak what the scope saved, and report. The next
381
+ // call tries to take over with the temporary selection.
382
+ Err(e) => {
383
+ std::mem::forget(scope.previous);
384
+ return Err(error::to_ruby(e));
385
+ }
386
+ }
387
+
388
+ match scope.previous {
389
+ // Opened and closed in the same process: put the saved backend back.
390
+ Some(previous) if scope.owner == cur => microsandbox::set_default_backend(previous),
391
+ // No backend of this process's own was saved (see `Scope::previous`):
392
+ // restore one built here from how the saved one had been chosen. On
393
+ // failure the temporary backend stays installed (see above). The
394
+ // default being replaced was built in this process and could be
395
+ // dropped safely; it is forgotten all the same, so that no path of a
396
+ // forked child ever drops a backend it might have inherited. That
397
+ // leaks at most one backend per scope closed this way.
398
+ previous => {
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.
402
+ std::mem::forget(previous);
403
+ let fresh = scope.selection.build().map_err(error::to_ruby)?;
404
+ std::mem::forget(microsandbox::swap_default_backend(fresh));
405
+ }
406
+ }
407
+ *lock(&SELECTION) = scope.selection;
149
408
  Ok(())
150
409
  }
151
410
 
152
411
  /// The active default backend kind: `"local"` or `"cloud"`. First call lazily
153
412
  /// resolves the env/profile/config ladder. Synchronous (an `RwLock` read).
154
- fn default_backend_kind() -> String {
155
- match microsandbox::default_backend().kind() {
413
+ fn default_backend_kind() -> Result<String, Error> {
414
+ ensure_this_process_backend()?;
415
+ Ok(match microsandbox::default_backend().kind() {
156
416
  microsandbox::BackendKind::Local => "local",
157
417
  microsandbox::BackendKind::Cloud => "cloud",
158
418
  }
159
- .to_string()
419
+ .to_string())
160
420
  }
161
421
 
162
422
  /// Secret-safe description of the active default backend (v0.6.9):
@@ -164,7 +424,8 @@ fn default_backend_kind() -> String {
164
424
  /// (e.g. "MSB_BACKEND", "programmatic", "default"); the API key is never
165
425
  /// included. Like `default_backend_kind`, the first call freezes ambient
166
426
  /// env/profile resolution for the process.
167
- fn default_backend_info() -> magnus::RHash {
427
+ fn default_backend_info() -> Result<magnus::RHash, Error> {
428
+ ensure_this_process_backend()?;
168
429
  let info = microsandbox::default_backend_info();
169
430
  let hash = crate::runtime::ruby().hash_new();
170
431
  let _ = hash.aset(
@@ -177,7 +438,7 @@ fn default_backend_info() -> magnus::RHash {
177
438
  let _ = hash.aset("api_url", info.api_url);
178
439
  let _ = hash.aset("source", info.source.as_str());
179
440
  let _ = hash.aset("profile", info.profile);
180
- hash
441
+ Ok(hash)
181
442
  }
182
443
 
183
444
  pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
@@ -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,14 +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),
195
+ }
196
+ }
197
+
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.
205
+ pub fn forked_handle(class: &str, born: u32, cur: u32) -> Error {
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),
203
221
  }
204
222
  }
@@ -12,14 +12,20 @@ use magnus::{method, prelude::*, Error, RHash, RModule, RString, Ruby};
12
12
  use tokio::sync::Mutex;
13
13
 
14
14
  use crate::error;
15
- use crate::runtime::{block_on, ruby};
15
+ use crate::runtime::{block_on, ruby, ForkGuarded};
16
16
 
17
17
  #[magnus::wrap(class = "Microsandbox::Native::ExecHandle", free_immediately, size)]
18
18
  pub struct ExecHandle {
19
- inner: Arc<Mutex<microsandbox::ExecHandle>>,
19
+ inner: ForkGuarded<ExecParts>,
20
+ /// Plain data, readable in any process.
21
+ id: String,
22
+ }
23
+
24
+ /// Everything of an exec session that is bound to the creating process.
25
+ struct ExecParts {
26
+ handle: Arc<Mutex<microsandbox::ExecHandle>>,
20
27
  control: microsandbox::ExecControl,
21
28
  stdin: std::sync::Mutex<Option<Arc<microsandbox::sandbox::exec::ExecSink>>>,
22
- id: String,
23
29
  }
24
30
 
25
31
  impl ExecHandle {
@@ -29,9 +35,14 @@ impl ExecHandle {
29
35
  let control = handle.control();
30
36
  let stdin = handle.take_stdin().map(Arc::new);
31
37
  Self {
32
- inner: Arc::new(Mutex::new(handle)),
33
- control,
34
- stdin: std::sync::Mutex::new(stdin),
38
+ inner: ForkGuarded::new(
39
+ "Microsandbox::ExecHandle",
40
+ ExecParts {
41
+ handle: Arc::new(Mutex::new(handle)),
42
+ control,
43
+ stdin: std::sync::Mutex::new(stdin),
44
+ },
45
+ ),
35
46
  id,
36
47
  }
37
48
  }
@@ -42,14 +53,14 @@ impl ExecHandle {
42
53
 
43
54
  /// Next event as a Hash, or nil when the stream ends.
44
55
  fn recv(&self) -> Result<Option<RHash>, Error> {
45
- let inner = Arc::clone(&self.inner);
56
+ let inner = Arc::clone(&self.inner.get()?.handle);
46
57
  let event = block_on(async move { inner.lock().await.recv().await })?;
47
58
  Ok(event.map(exec_event_to_hash))
48
59
  }
49
60
 
50
61
  /// Block until exit; returns {exit_code, success}.
51
62
  fn wait(&self) -> Result<RHash, Error> {
52
- let inner = Arc::clone(&self.inner);
63
+ let inner = Arc::clone(&self.inner.get()?.handle);
53
64
  let status =
54
65
  block_on(async move { inner.lock().await.wait().await })?.map_err(error::to_ruby)?;
55
66
  let hash = ruby().hash_new();
@@ -60,46 +71,49 @@ impl ExecHandle {
60
71
 
61
72
  /// Drain the stream and return a collected exec-output Hash.
62
73
  fn collect(&self) -> Result<RHash, Error> {
63
- let inner = Arc::clone(&self.inner);
74
+ let inner = Arc::clone(&self.inner.get()?.handle);
64
75
  let output =
65
76
  block_on(async move { inner.lock().await.collect().await })?.map_err(error::to_ruby)?;
66
77
  crate::sandbox::exec_output_to_hash(output)
67
78
  }
68
79
 
69
80
  fn signal(&self, sig: i32) -> Result<(), Error> {
70
- block_on(self.control.signal(sig))?.map_err(error::to_ruby)
81
+ block_on(self.inner.get()?.control.signal(sig))?.map_err(error::to_ruby)
71
82
  }
72
83
 
73
84
  fn kill(&self) -> Result<(), Error> {
74
- block_on(self.control.kill())?.map_err(error::to_ruby)
85
+ block_on(self.inner.get()?.control.kill())?.map_err(error::to_ruby)
75
86
  }
76
87
 
77
88
  fn resize(&self, rows: u16, cols: u16) -> Result<(), Error> {
78
- block_on(self.control.resize(rows, cols))?.map_err(error::to_ruby)
89
+ block_on(self.inner.get()?.control.resize(rows, cols))?.map_err(error::to_ruby)
79
90
  }
80
91
 
81
92
  /// The stdin sink (only the first call returns it; nil afterwards or if
82
93
  /// stdin was not piped).
83
- fn take_stdin(&self) -> Option<ExecSink> {
84
- let mut guard = self.stdin.lock().expect("exec stdin mutex poisoned");
85
- guard.take().map(|sink| ExecSink { inner: sink })
94
+ fn take_stdin(&self) -> Result<Option<ExecSink>, Error> {
95
+ let parts = self.inner.get()?;
96
+ let mut guard = parts.stdin.lock().expect("exec stdin mutex poisoned");
97
+ Ok(guard.take().map(|sink| ExecSink {
98
+ inner: ForkGuarded::new("Microsandbox::ExecStdin", sink),
99
+ }))
86
100
  }
87
101
  }
88
102
 
89
103
  #[magnus::wrap(class = "Microsandbox::Native::ExecSink", free_immediately, size)]
90
104
  pub struct ExecSink {
91
- inner: Arc<microsandbox::sandbox::exec::ExecSink>,
105
+ inner: ForkGuarded<Arc<microsandbox::sandbox::exec::ExecSink>>,
92
106
  }
93
107
 
94
108
  impl ExecSink {
95
109
  fn write(&self, data: RString) -> Result<(), Error> {
96
110
  // Copy out while holding the GVL (see sandbox::fs_write for the rationale).
97
111
  let bytes = unsafe { data.as_slice() }.to_vec();
98
- block_on(self.inner.write(&bytes))?.map_err(error::to_ruby)
112
+ block_on(self.inner.get()?.write(&bytes))?.map_err(error::to_ruby)
99
113
  }
100
114
 
101
115
  fn close(&self) -> Result<(), Error> {
102
- block_on(self.inner.close())?.map_err(error::to_ruby)
116
+ block_on(self.inner.get()?.close())?.map_err(error::to_ruby)
103
117
  }
104
118
  }
105
119