microsandbox-rb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,13 +17,13 @@
17
17
 
18
18
  use std::collections::HashMap;
19
19
  use std::sync::atomic::{AtomicU32, Ordering};
20
- use std::sync::{Arc, Mutex, OnceLock};
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, current_pid};
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,166 @@ 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 generation
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
+ // fork generation — 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 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);
164
+
165
+ static REBUILD_LOCK: Mutex<()> = Mutex::new(());
166
+
167
+ /// Lock one of this module's mutexes. A poisoned one is recovered: the data
168
+ /// behind them stays consistent across a panic (plain assignments), and
169
+ /// refusing to go on would only turn one failure into a permanent one.
170
+ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
171
+ mutex.lock().unwrap_or_else(PoisonError::into_inner)
172
+ }
173
+
174
+ /// Make sure the core's default backend belongs to THIS process, rebuilding it
175
+ /// after a fork. Every Ruby-facing entry that can reach the default backend
176
+ /// runs this first: [`crate::runtime::block_on`] for the async operations, and
177
+ /// each synchronous entry that reads the backend itself. (The three that are
178
+ /// about to replace the default anyway — `set_default_backend`,
179
+ /// `push_default_backend` and `pop_default_backend` — adopt with the
180
+ /// selection they install instead.)
181
+ ///
182
+ /// Runs only with the GVL held — never inside a future (see the locking note
183
+ /// above).
184
+ pub fn ensure_this_process_backend() -> Result<(), Error> {
185
+ // The Ruby exception is built here, after the locks are released.
186
+ adopt_backend(current_pid(), None)
187
+ .map(|_| ())
188
+ .map_err(error::to_ruby)
189
+ }
190
+
191
+ /// Take the backend state over for process `cur` if it still belongs to a
192
+ /// parent process: install a backend built in this process and leak every
193
+ /// inherited one.
194
+ ///
195
+ /// What gets installed is `replacement` when the caller itself determines the
196
+ /// backend (a selection, or the restore of a scope), and otherwise a rebuild
197
+ /// of the inherited selection. The difference
198
+ /// matters when the inherited selection no longer resolves in the child (say
199
+ /// the ladder now reads `MSB_BACKEND=cloud` without credentials): the rebuild
200
+ /// fails, and keeps failing on every call, but an explicit selection must
201
+ /// still get the child out of that state — so it never depends on the
202
+ /// inherited one.
203
+ ///
204
+ /// Returns `Some(inherited selection)` when `replacement` was installed here
205
+ /// (the caller has nothing left to install), `None` when the caller proceeds
206
+ /// 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.
208
+ fn adopt_backend(
209
+ cur: u32,
210
+ replacement: Option<&Selection>,
211
+ ) -> Result<Option<Selection>, MicrosandboxError> {
212
+ if BACKEND_PID.load(Ordering::Acquire) == cur {
213
+ return Ok(None);
214
+ }
215
+ let _rebuild = lock(&REBUILD_LOCK);
216
+ let seen = BACKEND_PID.load(Ordering::Acquire);
217
+ if seen == cur {
218
+ return Ok(None);
219
+ }
220
+ if seen == 0 {
221
+ // Nothing was inherited; the core resolves its default lazily, in this
222
+ // process, on first use.
223
+ BACKEND_PID.store(cur, Ordering::Release);
224
+ return Ok(None);
225
+ }
226
+
227
+ // A process that used the backend forked us. Build ours first, so that a
228
+ // failure leaves everything as it was.
229
+ let fresh = match replacement {
230
+ Some(selection) => selection.build()?,
231
+ None => lock(&SELECTION).build()?,
232
+ };
233
+ // The inherited default must never be dropped (see above).
234
+ std::mem::forget(microsandbox::swap_default_backend(fresh));
235
+ // Neither may the backends that open `with_backend` scopes saved for their
236
+ // restore. The entries themselves stay — pid and selection — so a scope
237
+ // the parent opened can still be closed here (see `pop_default_backend`).
238
+ for scope in lock(backend_scopes()).values_mut() {
239
+ std::mem::forget(scope.previous.take());
240
+ }
241
+ let inherited =
242
+ replacement.map(|selection| std::mem::replace(&mut *lock(&SELECTION), selection.clone()));
243
+ BACKEND_PID.store(cur, Ordering::Release);
244
+ Ok(inherited)
245
+ }
246
+
99
247
  /// Install a process-wide default backend. Synchronous (an `RwLock` write) — no
100
248
  /// `block_on`. Mirrors Python `set_default_backend` / Node `setDefaultBackend`.
101
249
  fn set_default_backend(
@@ -104,7 +252,23 @@ fn set_default_backend(
104
252
  api_key: Option<String>,
105
253
  profile: Option<String>,
106
254
  ) -> Result<(), Error> {
107
- microsandbox::set_default_backend(build_backend(kind, url, api_key, profile)?);
255
+ let selection = Selection::Explicit {
256
+ kind,
257
+ url,
258
+ api_key,
259
+ profile,
260
+ };
261
+ // A forked child that starts by selecting a backend: the inherited default
262
+ // is forgotten and this selection installed in its place, without ever
263
+ // rebuilding the inherited one.
264
+ if adopt_backend(current_pid(), Some(&selection))
265
+ .map_err(error::to_ruby)?
266
+ .is_some()
267
+ {
268
+ return Ok(());
269
+ }
270
+ microsandbox::set_default_backend(selection.build().map_err(error::to_ruby)?);
271
+ *lock(&SELECTION) = selection;
108
272
  Ok(())
109
273
  }
110
274
 
@@ -114,9 +278,24 @@ fn set_default_backend(
114
278
  // swaps the default and stores the previous backend under a fresh token; pop
115
279
  // restores it. The Ruby wrapper drives this through an `ensure` block.
116
280
  static NEXT_BACKEND_SCOPE: AtomicU32 = AtomicU32::new(1);
117
- static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Arc<dyn Backend>>>> = OnceLock::new();
281
+ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Scope>>> = OnceLock::new();
282
+
283
+ /// What an open `with_backend` scope restores when it closes.
284
+ struct Scope {
285
+ /// The process that opened the scope.
286
+ pid: u32,
287
+ /// The backend that was the default before the push, kept to be put back
288
+ /// by a pop in the same process. `None` when there is no such backend of
289
+ /// this process's own: a forked child took it out to leak it, or the push
290
+ /// was the child's first use of the backend and replaced an inherited
291
+ /// default ([`adopt_backend`]). The pop then rebuilds from `selection`.
292
+ previous: Option<Arc<dyn Backend>>,
293
+ /// How that previous backend had been chosen: what a rebuild starts from,
294
+ /// and what `SELECTION` goes back to when the scope closes.
295
+ selection: Selection,
296
+ }
118
297
 
119
- fn backend_scopes() -> &'static Mutex<HashMap<u32, Arc<dyn Backend>>> {
298
+ fn backend_scopes() -> &'static Mutex<HashMap<u32, Scope>> {
120
299
  BACKEND_SCOPES.get_or_init(|| Mutex::new(HashMap::new()))
121
300
  }
122
301
 
@@ -129,34 +308,111 @@ fn push_default_backend(
129
308
  api_key: Option<String>,
130
309
  profile: Option<String>,
131
310
  ) -> Result<u32, Error> {
132
- let previous = microsandbox::swap_default_backend(build_backend(kind, url, api_key, profile)?);
311
+ let cur = current_pid();
312
+ let selection = Selection::Explicit {
313
+ kind,
314
+ url,
315
+ api_key,
316
+ profile,
317
+ };
318
+ let (previous, before) = match adopt_backend(cur, Some(&selection)).map_err(error::to_ruby)? {
319
+ // A forked child's first use of the backend: the scoped backend
320
+ // replaced the inherited default (now forgotten), so there is nothing
321
+ // of this process to put back. The pop rebuilds from how the inherited
322
+ // default had been chosen.
323
+ Some(inherited) => (None, inherited),
324
+ None => {
325
+ let previous =
326
+ microsandbox::swap_default_backend(selection.build().map_err(error::to_ruby)?);
327
+ let before = std::mem::replace(&mut *lock(&SELECTION), selection);
328
+ (Some(previous), before)
329
+ }
330
+ };
133
331
  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);
332
+ lock(backend_scopes()).insert(
333
+ token,
334
+ Scope {
335
+ pid: cur,
336
+ previous,
337
+ selection: before,
338
+ },
339
+ );
138
340
  Ok(token)
139
341
  }
140
342
 
141
- /// Restore the backend saved by [`push_default_backend`].
343
+ /// Close the scope opened by [`push_default_backend`]: restore the backend it
344
+ /// saved.
345
+ ///
346
+ /// A pop consumes its token whether or not it succeeds. It can fail only when
347
+ /// the backend to restore has to be rebuilt from how it had been chosen (the
348
+ /// saved one was the parent's) and that selection does not resolve in this
349
+ /// process. The scope is closed anyway and its temporary selection stays in
350
+ /// effect: the temporary backend remains installed, or — in a child that has
351
+ /// not yet built a backend of its own — is what the next call rebuilds. That
352
+ /// is a state the caller can describe and leave with `set_default_backend`;
353
+ /// a token nobody can retry (`with_backend` never hands it out) would not be.
142
354
  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);
355
+ let cur = current_pid();
356
+ // (The registry lock is not held while an exception is built.)
357
+ let Some(scope) = lock(backend_scopes()).remove(&token) else {
358
+ return Err(error::base_error("unknown backend scope token"));
359
+ };
360
+
361
+ // A forked child whose first use of the backend is this pop (the parent
362
+ // forked inside a `with_backend` block and the child now leaves it) takes
363
+ // over with what the scope restores — directly, never by way of the
364
+ // scope's temporary backend, which the child may be unable to rebuild (a
365
+ // cloud profile that is gone there) and has no use for.
366
+ match adopt_backend(cur, Some(&scope.selection)) {
367
+ // Done: the restored selection is installed and recorded. The backend
368
+ // this scope had saved is the parent's; it left the registry above, so
369
+ // it is leaked here rather than by the adoption.
370
+ Ok(Some(_temporary)) => {
371
+ std::mem::forget(scope.previous);
372
+ return Ok(());
373
+ }
374
+ // This process already owns its backend: restore below.
375
+ Ok(None) => {}
376
+ // Nothing was changed (see `adopt_backend`), so everything in sight is
377
+ // still the parent's: leak what the scope saved, and report. The next
378
+ // call tries to take over with the temporary selection.
379
+ Err(e) => {
380
+ std::mem::forget(scope.previous);
381
+ return Err(error::to_ruby(e));
382
+ }
383
+ }
384
+
385
+ match scope.previous {
386
+ // Opened and closed in the same process: put the saved backend back.
387
+ Some(previous) if scope.pid == cur => microsandbox::set_default_backend(previous),
388
+ // No backend of this process's own was saved (see `Scope::previous`):
389
+ // restore one built here from how the saved one had been chosen. On
390
+ // failure the temporary backend stays installed (see above). The
391
+ // default being replaced was built in this process and could be
392
+ // dropped safely; it is forgotten all the same, so that no path of a
393
+ // forked child ever drops a backend it might have inherited. That
394
+ // leaks at most one backend per scope closed this way.
395
+ previous => {
396
+ // `None` unless the pid of the process that opened the scope was
397
+ // reused; an inherited `Arc` is leaked either way.
398
+ std::mem::forget(previous);
399
+ let fresh = scope.selection.build().map_err(error::to_ruby)?;
400
+ std::mem::forget(microsandbox::swap_default_backend(fresh));
401
+ }
402
+ }
403
+ *lock(&SELECTION) = scope.selection;
149
404
  Ok(())
150
405
  }
151
406
 
152
407
  /// The active default backend kind: `"local"` or `"cloud"`. First call lazily
153
408
  /// resolves the env/profile/config ladder. Synchronous (an `RwLock` read).
154
- fn default_backend_kind() -> String {
155
- match microsandbox::default_backend().kind() {
409
+ fn default_backend_kind() -> Result<String, Error> {
410
+ ensure_this_process_backend()?;
411
+ Ok(match microsandbox::default_backend().kind() {
156
412
  microsandbox::BackendKind::Local => "local",
157
413
  microsandbox::BackendKind::Cloud => "cloud",
158
414
  }
159
- .to_string()
415
+ .to_string())
160
416
  }
161
417
 
162
418
  /// Secret-safe description of the active default backend (v0.6.9):
@@ -164,7 +420,8 @@ fn default_backend_kind() -> String {
164
420
  /// (e.g. "MSB_BACKEND", "programmatic", "default"); the API key is never
165
421
  /// included. Like `default_backend_kind`, the first call freezes ambient
166
422
  /// env/profile resolution for the process.
167
- fn default_backend_info() -> magnus::RHash {
423
+ fn default_backend_info() -> Result<magnus::RHash, Error> {
424
+ ensure_this_process_backend()?;
168
425
  let info = microsandbox::default_backend_info();
169
426
  let hash = crate::runtime::ruby().hash_new();
170
427
  let _ = hash.aset(
@@ -177,7 +434,7 @@ fn default_backend_info() -> magnus::RHash {
177
434
  let _ = hash.aset("api_url", info.api_url);
178
435
  let _ = hash.aset("source", info.source.as_str());
179
436
  let _ = hash.aset("profile", info.profile);
180
- hash
437
+ Ok(hash)
181
438
  }
182
439
 
183
440
  pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
@@ -202,3 +202,21 @@ pub fn base_error(message: impl Into<String>) -> Error {
202
202
  Err(_) => Error::new(magnus::exception::runtime_error(), message),
203
203
  }
204
204
  }
205
+
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)]
210
+ 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),
221
+ }
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
 
@@ -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
@@ -108,6 +108,7 @@ fn set_runtime_libkrunfw_path(path: String) {
108
108
  /// no async) — runs on the Ruby thread; resolves the path from the local
109
109
  /// backend's config.
110
110
  fn resolved_msb_path() -> Result<String, Error> {
111
+ backend::ensure_this_process_backend()?;
111
112
  let backend = microsandbox::default_backend();
112
113
  // Shim-only entry point with no SDK `Operation` — report the Ruby-facing
113
114
  // name (`Microsandbox.runtime_path`), like Python's name-based local_only.