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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -1
- data/Cargo.lock +34 -32
- data/DESIGN.md +199 -8
- data/README.md +4 -2
- data/docs/errors.md +1 -0
- data/docs/runtime.md +115 -0
- data/docs/surface.md +6 -0
- data/ext/microsandbox/Cargo.toml +9 -2
- data/ext/microsandbox/src/agent.rs +13 -10
- data/ext/microsandbox/src/backend.rs +295 -34
- data/ext/microsandbox/src/error.rs +41 -23
- data/ext/microsandbox/src/exec.rs +32 -18
- data/ext/microsandbox/src/fs_stream.rs +11 -8
- data/ext/microsandbox/src/host_path.rs +4 -3
- data/ext/microsandbox/src/lib.rs +51 -2
- data/ext/microsandbox/src/runtime.rs +276 -39
- data/ext/microsandbox/src/sandbox.rs +175 -91
- data/ext/microsandbox/src/snapshot.rs +7 -4
- data/ext/microsandbox/src/ssh.rs +26 -23
- data/ext/microsandbox/src/stream.rs +13 -7
- data/ext/microsandbox/src/volume.rs +22 -16
- data/lib/microsandbox/errors.rb +8 -0
- data/lib/microsandbox/sandbox.rb +39 -20
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox.rb +20 -1
- data/sig/microsandbox.rbs +1 -0
- metadata +1 -1
|
@@ -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.
|
|
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.
|
|
69
|
-
///
|
|
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:
|
|
74
|
+
kind: &str,
|
|
72
75
|
url: Option<String>,
|
|
73
76
|
api_key: Option<String>,
|
|
74
77
|
profile: Option<String>,
|
|
75
|
-
) -> Result<Arc<dyn Backend>,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
///
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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:
|
|
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:
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
85
|
-
guard.
|
|
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
|
|