microsandbox-rb 0.5.9 → 0.5.10

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.
@@ -13,12 +13,16 @@ MSRV = Gem::Version.new("1.91")
13
13
  rustc_version = begin
14
14
  out = `rustc --version 2>/dev/null`
15
15
  out[/\d+\.\d+(\.\d+)?/] && Gem::Version.new(out[/\d+\.\d+(\.\d+)?/])
16
- rescue StandardError
16
+ rescue
17
17
  nil
18
18
  end
19
19
 
20
20
  if rustc_version && rustc_version < MSRV
21
- which_rustc = (`which rustc 2>/dev/null`.strip rescue "")
21
+ which_rustc = begin
22
+ `which rustc 2>/dev/null`.strip
23
+ rescue
24
+ ""
25
+ end
22
26
  abort(<<~MSG)
23
27
 
24
28
  [microsandbox-rb] Rust #{rustc_version} is too old — the embedded core requires rustc >= #{MSRV}.
@@ -0,0 +1,170 @@
1
+ //! Backend routing: the ambient process-wide backend and its selection surface.
2
+ //!
3
+ //! Mirrors the official Python (`sdk/python/src/lib.rs`) and Node
4
+ //! (`sdk/node-ts/native/runtime_config.rs`) bindings. As of upstream v0.5.8
5
+ //! (PR #754) every operation routes through a [`microsandbox::Backend`]: the
6
+ //! ambient default is a lazily-initialised [`microsandbox::LocalBackend`], and
7
+ //! callers may install a different default process-wide or for a scoped block.
8
+ //!
9
+ //! Local-only operations (image cache, aggregate metrics, `msb` path) need a
10
+ //! concrete `&LocalBackend`; [`local_backend`] resolves the ambient default and
11
+ //! downcasts it, surfacing a clean [`MicrosandboxError::Unsupported`] under a
12
+ //! cloud backend (exactly as the pyo3 `resolve_local` helper does). The backend
13
+ //! selection setters (`set_default_backend` / `with_backend` push-pop /
14
+ //! `default_backend_kind`) deliberately expose only a `kind`/`url`/`api_key`/
15
+ //! `profile` facade — the raw `LocalBackend`/`CloudBackend` builders stay hidden,
16
+ //! matching Python and Node.
17
+
18
+ use std::collections::HashMap;
19
+ use std::sync::atomic::{AtomicU32, Ordering};
20
+ use std::sync::{Arc, Mutex, OnceLock};
21
+
22
+ use magnus::{function, prelude::*, Error, RModule, Ruby};
23
+ use microsandbox::{Backend, MicrosandboxError};
24
+
25
+ use crate::error;
26
+ use crate::runtime::block_on;
27
+
28
+ /// Resolve the ambient default backend, requiring it to be local.
29
+ ///
30
+ /// Returns the `Arc<dyn Backend>` so the caller can keep it alive while
31
+ /// borrowing `&LocalBackend` from it (`as_local()` borrows the `Arc`). Pure
32
+ /// Rust — safe to call inside `block_on` (no Ruby C API), and it returns a raw
33
+ /// `MicrosandboxError` so the Ruby-exception mapping happens *after* `block_on`
34
+ /// re-acquires the GVL. Cloud backends yield `Unsupported`, mirroring pyo3's
35
+ /// `resolve_local`.
36
+ pub fn local_backend() -> Result<Arc<dyn Backend>, MicrosandboxError> {
37
+ let backend = microsandbox::default_backend();
38
+ if backend.as_local().is_some() {
39
+ Ok(backend)
40
+ } else {
41
+ Err(MicrosandboxError::Unsupported {
42
+ feature: "this operation requires a local backend".into(),
43
+ available_when: "with the local backend (the default)".into(),
44
+ })
45
+ }
46
+ }
47
+
48
+ /// Resolve the ambient backend (requiring it to be local), then run `op` with a
49
+ /// borrowed `&LocalBackend` inside the blocking runtime, mapping any core error
50
+ /// to a Ruby exception. Local-only operations (image cache, aggregate metrics)
51
+ /// share this instead of repeating the resolve/downcast dance; the single
52
+ /// `as_local()` unwrap is provably infallible — [`local_backend`] just checked
53
+ /// it and returns the same `Arc` kept alive for the borrow — so it lives here
54
+ /// once rather than at every call site.
55
+ pub fn with_local_backend<T>(
56
+ op: impl AsyncFnOnce(&microsandbox::LocalBackend) -> Result<T, MicrosandboxError>,
57
+ ) -> Result<T, Error> {
58
+ block_on(async move {
59
+ let backend = local_backend()?;
60
+ let local = backend
61
+ .as_local()
62
+ .expect("local_backend() guarantees a local backend");
63
+ op(local).await
64
+ })
65
+ .map_err(error::to_ruby)
66
+ }
67
+
68
+ /// Build an `Arc<dyn Backend>` from the SDK facade. Ported from pyo3
69
+ /// `build_backend` (`sdk/python/src/lib.rs`) / node `build_backend`. Synchronous
70
+ /// (no network I/O): cloud construction only builds the HTTP client. Runs on the
71
+ /// Ruby thread with the GVL held, so it may map errors to Ruby directly.
72
+ fn build_backend(
73
+ kind: String,
74
+ url: Option<String>,
75
+ api_key: Option<String>,
76
+ profile: Option<String>,
77
+ ) -> Result<Arc<dyn Backend>, Error> {
78
+ match kind.trim().to_ascii_lowercase().as_str() {
79
+ "local" => Ok(Arc::new(microsandbox::LocalBackend::lazy())),
80
+ "cloud" => {
81
+ let cloud = match profile {
82
+ Some(profile) => microsandbox::CloudBackend::from_profile(&profile),
83
+ None => match (url, api_key) {
84
+ (Some(url), Some(api_key)) => microsandbox::CloudBackend::new(url, api_key),
85
+ _ => {
86
+ return Err(error::to_ruby(MicrosandboxError::InvalidConfig(
87
+ "cloud backend requires url + api_key or profile".into(),
88
+ )))
89
+ }
90
+ },
91
+ }
92
+ .map_err(error::to_ruby)?;
93
+ Ok(Arc::new(cloud))
94
+ }
95
+ other => Err(error::to_ruby(MicrosandboxError::InvalidConfig(format!(
96
+ "backend kind must be 'local' or 'cloud', got {other:?}"
97
+ )))),
98
+ }
99
+ }
100
+
101
+ /// Install a process-wide default backend. Synchronous (an `RwLock` write) — no
102
+ /// `block_on`. Mirrors Python `set_default_backend` / Node `setDefaultBackend`.
103
+ fn set_default_backend(
104
+ kind: String,
105
+ url: Option<String>,
106
+ api_key: Option<String>,
107
+ profile: Option<String>,
108
+ ) -> Result<(), Error> {
109
+ microsandbox::set_default_backend(build_backend(kind, url, api_key, profile)?);
110
+ Ok(())
111
+ }
112
+
113
+ // Scoped-override registry. `with_backend` in Ruby is a swap-and-restore around
114
+ // a synchronous block, so it cannot use the core's async task-local
115
+ // `with_backend`. We mirror Node's process-wide push/pop token registry: push
116
+ // swaps the default and stores the previous backend under a fresh token; pop
117
+ // restores it. The Ruby wrapper drives this through an `ensure` block.
118
+ static NEXT_BACKEND_SCOPE: AtomicU32 = AtomicU32::new(1);
119
+ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Arc<dyn Backend>>>> = OnceLock::new();
120
+
121
+ fn backend_scopes() -> &'static Mutex<HashMap<u32, Arc<dyn Backend>>> {
122
+ BACKEND_SCOPES.get_or_init(|| Mutex::new(HashMap::new()))
123
+ }
124
+
125
+ /// Swap in a new default backend and return a token for restoring the previous
126
+ /// one via [`pop_default_backend`]. Process-wide while active (not task-local):
127
+ /// concurrent Ruby threads observe the swapped default.
128
+ fn push_default_backend(
129
+ kind: String,
130
+ url: Option<String>,
131
+ api_key: Option<String>,
132
+ profile: Option<String>,
133
+ ) -> Result<u32, Error> {
134
+ let previous = microsandbox::swap_default_backend(build_backend(kind, url, api_key, profile)?);
135
+ let token = NEXT_BACKEND_SCOPE.fetch_add(1, Ordering::Relaxed);
136
+ backend_scopes()
137
+ .lock()
138
+ .map_err(|_| error::base_error("backend scope registry poisoned"))?
139
+ .insert(token, previous);
140
+ Ok(token)
141
+ }
142
+
143
+ /// Restore the backend saved by [`push_default_backend`].
144
+ fn pop_default_backend(token: u32) -> Result<(), Error> {
145
+ let previous = backend_scopes()
146
+ .lock()
147
+ .map_err(|_| error::base_error("backend scope registry poisoned"))?
148
+ .remove(&token)
149
+ .ok_or_else(|| error::base_error("unknown backend scope token"))?;
150
+ microsandbox::set_default_backend(previous);
151
+ Ok(())
152
+ }
153
+
154
+ /// The active default backend kind: `"local"` or `"cloud"`. First call lazily
155
+ /// resolves the env/profile/config ladder. Synchronous (an `RwLock` read).
156
+ fn default_backend_kind() -> String {
157
+ match microsandbox::default_backend().kind() {
158
+ microsandbox::BackendKind::Local => "local",
159
+ microsandbox::BackendKind::Cloud => "cloud",
160
+ }
161
+ .to_string()
162
+ }
163
+
164
+ pub fn define(_ruby: &Ruby, native: &RModule) -> Result<(), Error> {
165
+ native.define_singleton_method("set_default_backend", function!(set_default_backend, 4))?;
166
+ native.define_singleton_method("push_default_backend", function!(push_default_backend, 4))?;
167
+ native.define_singleton_method("pop_default_backend", function!(pop_default_backend, 1))?;
168
+ native.define_singleton_method("default_backend_kind", function!(default_backend_kind, 0))?;
169
+ Ok(())
170
+ }
@@ -28,6 +28,12 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
28
28
  MetricsDisabled(_) => "MetricsDisabledError",
29
29
  MetricsUnavailable(_) => "MetricsUnavailableError",
30
30
  AgentClient(AgentClientError::UnsupportedOperation { .. }) => "UnsupportedOperationError",
31
+ // Backend routing (v0.5.8 / PR #754). `Unsupported` is reachable on the
32
+ // local backend too (e.g. `Volume::path` on a cloud volume, snapshot
33
+ // ops), so it must map even for local-only use. Distinct from the agent
34
+ // client's `UnsupportedOperation` above.
35
+ CloudHttp { .. } => "CloudHttpError",
36
+ Unsupported { .. } => "UnsupportedError",
31
37
  _ => "Error",
32
38
  }
33
39
  }
@@ -8,8 +8,8 @@
8
8
  use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
9
9
  use microsandbox::image::{Image, ImageDetail, ImageHandle, ImagePruneReport};
10
10
 
11
- use crate::error;
12
- use crate::runtime::{block_on, ruby};
11
+ use crate::backend::with_local_backend;
12
+ use crate::runtime::ruby;
13
13
 
14
14
  fn handle_to_hash(h: &ImageHandle) -> RHash {
15
15
  let hash = ruby().hash_new();
@@ -76,12 +76,12 @@ fn report_to_hash(report: ImagePruneReport) -> RHash {
76
76
  }
77
77
 
78
78
  fn get(reference: String) -> Result<RHash, Error> {
79
- let handle = block_on(Image::get(&reference)).map_err(error::to_ruby)?;
79
+ let handle = with_local_backend(async |local| Image::get(local, &reference).await)?;
80
80
  Ok(handle_to_hash(&handle))
81
81
  }
82
82
 
83
83
  fn list() -> Result<RArray, Error> {
84
- let handles = block_on(Image::list()).map_err(error::to_ruby)?;
84
+ let handles = with_local_backend(async |local| Image::list(local).await)?;
85
85
  let arr = ruby().ary_new();
86
86
  for h in handles.iter() {
87
87
  arr.push(handle_to_hash(h))?;
@@ -90,16 +90,16 @@ fn list() -> Result<RArray, Error> {
90
90
  }
91
91
 
92
92
  fn inspect(reference: String) -> Result<RHash, Error> {
93
- let detail = block_on(Image::inspect(&reference)).map_err(error::to_ruby)?;
93
+ let detail = with_local_backend(async |local| Image::inspect(local, &reference).await)?;
94
94
  Ok(detail_to_hash(detail))
95
95
  }
96
96
 
97
97
  fn remove(reference: String, force: bool) -> Result<(), Error> {
98
- block_on(Image::remove(&reference, force)).map_err(error::to_ruby)
98
+ with_local_backend(async |local| Image::remove(local, &reference, force).await)
99
99
  }
100
100
 
101
101
  fn prune() -> Result<RHash, Error> {
102
- let report = block_on(Image::prune()).map_err(error::to_ruby)?;
102
+ let report = with_local_backend(async |local| Image::prune(local).await)?;
103
103
  Ok(report_to_hash(report))
104
104
  }
105
105
 
@@ -9,6 +9,7 @@
9
9
  //! surface is the pure-Ruby layer in `lib/microsandbox/`.
10
10
 
11
11
  mod agent;
12
+ mod backend;
12
13
  mod conv;
13
14
  mod error;
14
15
  mod exec;
@@ -31,8 +32,9 @@ fn version() -> String {
31
32
  /// Ruby Hash. Mirrors the official `all_sandbox_metrics` / `allSandboxMetrics`
32
33
  /// helpers (Python/Node/Go).
33
34
  fn all_sandbox_metrics() -> Result<RHash, Error> {
34
- let map =
35
- runtime::block_on(microsandbox::sandbox::all_sandbox_metrics()).map_err(error::to_ruby)?;
35
+ let map = backend::with_local_backend(async |local| {
36
+ microsandbox::sandbox::all_sandbox_metrics(local).await
37
+ })?;
36
38
  let hash = runtime::ruby().hash_new();
37
39
  for (name, metrics) in &map {
38
40
  hash.aset(name.as_str(), sandbox::metrics_to_hash(metrics))?;
@@ -55,9 +57,25 @@ fn set_runtime_msb_path(path: String) {
55
57
  microsandbox::config::set_sdk_msb_path(path);
56
58
  }
57
59
 
58
- /// The currently-resolved `msb` runtime path.
60
+ /// Override the resolved `libkrunfw` path (SDK tier of the resolver). Set-once
61
+ /// per process; the `MSB_LIBKRUNFW_PATH` env var still takes precedence. Mirrors
62
+ /// `set_runtime_msb_path` for the libkrunfw shared library.
63
+ fn set_runtime_libkrunfw_path(path: String) {
64
+ microsandbox::config::set_sdk_libkrunfw_path(path);
65
+ }
66
+
67
+ /// The currently-resolved `msb` runtime path. Synchronous (filesystem probes,
68
+ /// no async) — runs on the Ruby thread; resolves the path from the local
69
+ /// backend's config.
59
70
  fn resolved_msb_path() -> Result<String, Error> {
60
- let path = microsandbox::config::resolve_msb_path().map_err(error::to_ruby)?;
71
+ let backend = microsandbox::default_backend();
72
+ let local = backend.as_local().ok_or_else(|| {
73
+ error::to_ruby(microsandbox::MicrosandboxError::Unsupported {
74
+ feature: "resolved_msb_path requires a local backend".into(),
75
+ available_when: "with the local backend".into(),
76
+ })
77
+ })?;
78
+ let path = microsandbox::config::resolve_msb_path(local.config()).map_err(error::to_ruby)?;
61
79
  Ok(path.to_string_lossy().into_owned())
62
80
  }
63
81
 
@@ -72,9 +90,14 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
72
90
  native.define_singleton_method("install", function!(install, 0))?;
73
91
  native.define_singleton_method("installed?", function!(is_installed, 0))?;
74
92
  native.define_singleton_method("set_runtime_msb_path", function!(set_runtime_msb_path, 1))?;
93
+ native.define_singleton_method(
94
+ "set_runtime_libkrunfw_path",
95
+ function!(set_runtime_libkrunfw_path, 1),
96
+ )?;
75
97
  native.define_singleton_method("resolved_msb_path", function!(resolved_msb_path, 0))?;
76
98
  native.define_singleton_method("all_sandbox_metrics", function!(all_sandbox_metrics, 0))?;
77
99
 
100
+ backend::define(ruby, &native)?;
78
101
  sandbox::define(ruby, &native)?;
79
102
  exec::define(ruby, &native)?;
80
103
  stream::define(ruby, &native)?;
@@ -209,27 +209,36 @@ impl Sandbox {
209
209
  Ok(Sandbox::from_inner(inner))
210
210
  }
211
211
 
212
- /// Lightweight metadata for a sandbox by name (running or not).
213
- fn get(name: String) -> Result<RHash, Error> {
212
+ /// A controllable handle for a sandbox by name (running or not). Carries
213
+ /// metadata accessors and the full lifecycle surface (see `SbHandle`).
214
+ fn get(name: String) -> Result<SbHandle, Error> {
214
215
  let handle = block_on(microsandbox::Sandbox::get(&name)).map_err(error::to_ruby)?;
215
- Ok(handle_to_hash(&handle))
216
+ Ok(SbHandle::from_inner(handle))
216
217
  }
217
218
 
218
- /// All sandboxes as metadata hashes.
219
+ /// All sandboxes as controllable handles.
219
220
  fn list() -> Result<RArray, Error> {
220
221
  let handles = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
221
- rhash_array(handles.iter().map(handle_to_hash))
222
+ let arr = ruby().ary_new();
223
+ for h in handles {
224
+ arr.push(SbHandle::from_inner(h))?;
225
+ }
226
+ Ok(arr)
222
227
  }
223
228
 
224
- /// Sandboxes filtered by required `key=value` labels (AND-matched). `opts`
225
- /// carries a string→string `labels` map.
229
+ /// Sandboxes filtered by required `key=value` labels (AND-matched), as
230
+ /// controllable handles. `opts` carries a string→string `labels` map.
226
231
  fn list_with(opts: RHash) -> Result<RArray, Error> {
227
232
  let mut filter = SandboxFilter::new();
228
233
  for (k, v) in conv::opt_string_map(opts, "labels")? {
229
234
  filter = filter.label(k, v);
230
235
  }
231
236
  let handles = block_on(microsandbox::Sandbox::list_with(filter)).map_err(error::to_ruby)?;
232
- rhash_array(handles.iter().map(handle_to_hash))
237
+ let arr = ruby().ary_new();
238
+ for h in handles {
239
+ arr.push(SbHandle::from_inner(h))?;
240
+ }
241
+ Ok(arr)
233
242
  }
234
243
 
235
244
  /// Remove a (stopped) sandbox by name.
@@ -286,44 +295,46 @@ impl Sandbox {
286
295
  Ok(ExecHandle::from_core(handle))
287
296
  }
288
297
 
289
- /// Graceful stop (+ wait). `timeout` is optional seconds.
290
- fn stop(&self, timeout: Option<f64>) -> Result<(), Error> {
291
- match timeout {
292
- Some(secs) => block_on(self.inner.stop_with_timeout(Duration::from_secs_f64(secs))),
293
- None => block_on(self.inner.stop()),
294
- }
298
+ /// Graceful stop. Mirrors the official SDKs: the live handle routes through
299
+ /// a freshly fetched `SandboxHandle::stop` (SIGTERM→SIGKILL escalation with
300
+ /// a 10s default). Fine-grained control — a custom timeout or fire-and-
301
+ /// return `request_*` — lives on `SandboxHandle`, obtained via `Sandbox.get`.
302
+ fn stop(&self) -> Result<(), Error> {
303
+ let name = self.inner.name().to_string();
304
+ block_on(async move {
305
+ let handle = microsandbox::sandbox::Sandbox::get(&name).await?;
306
+ handle.stop().await
307
+ })
295
308
  .map_err(error::to_ruby)
296
309
  }
297
310
 
298
- /// Force kill (SIGKILL). `timeout` is optional seconds.
299
- fn kill(&self, timeout: Option<f64>) -> Result<(), Error> {
300
- match timeout {
301
- Some(secs) => block_on(self.inner.kill_with_timeout(Duration::from_secs_f64(secs))),
302
- None => block_on(self.inner.kill()),
303
- }
304
- .map_err(error::to_ruby)
311
+ /// Graceful stop, then wait for the process to exit. Returns an exit-status
312
+ /// Hash (`exit_code`, `success`). Local backend only.
313
+ fn stop_and_wait(&self) -> Result<RHash, Error> {
314
+ let status = block_on(self.inner.stop_and_wait()).map_err(error::to_ruby)?;
315
+ Ok(exit_status_to_hash(status))
305
316
  }
306
317
 
307
- /// Send the graceful-shutdown request and return without waiting.
308
- fn request_stop(&self) -> Result<(), Error> {
309
- block_on(self.inner.request_stop()).map_err(error::to_ruby)
318
+ /// Force kill (SIGKILL).
319
+ fn kill(&self) -> Result<(), Error> {
320
+ block_on(self.inner.kill()).map_err(error::to_ruby)
310
321
  }
311
322
 
312
- /// Send the force-kill request and return without waiting.
313
- fn request_kill(&self) -> Result<(), Error> {
314
- block_on(self.inner.request_kill()).map_err(error::to_ruby)
323
+ /// Trigger a graceful drain (SIGUSR1 on local).
324
+ fn drain(&self) -> Result<(), Error> {
325
+ block_on(self.inner.drain()).map_err(error::to_ruby)
315
326
  }
316
327
 
317
- /// Send the drain request and return without waiting.
318
- fn request_drain(&self) -> Result<(), Error> {
319
- block_on(self.inner.request_drain()).map_err(error::to_ruby)
328
+ /// Wait for the process to exit. Returns an exit-status Hash. Local only.
329
+ fn wait(&self) -> Result<RHash, Error> {
330
+ let status = block_on(self.inner.wait()).map_err(error::to_ruby)?;
331
+ Ok(exit_status_to_hash(status))
320
332
  }
321
333
 
322
- /// Block until the sandbox is observed in a terminal state; returns a
323
- /// stop-result Hash (name, status, exit_code, signal, observed_at_ms, source).
324
- fn wait_until_stopped(&self) -> Result<RHash, Error> {
325
- let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
326
- Ok(stop_result_to_hash(&result))
334
+ /// Live status fetched from the backend (a round-trip per call).
335
+ fn status(&self) -> Result<String, Error> {
336
+ let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
337
+ Ok(sandbox_status_str(status).to_string())
327
338
  }
328
339
 
329
340
  /// Whether this handle owns the sandbox process lifecycle (a synchronous,
@@ -1064,6 +1075,7 @@ struct ExecOpts {
1064
1075
  timeout: Option<Duration>,
1065
1076
  tty: bool,
1066
1077
  stdin: Option<Vec<u8>>,
1078
+ stdin_pipe: bool,
1067
1079
  rlimits: Vec<(RlimitResource, u64, u64)>,
1068
1080
  }
1069
1081
 
@@ -1078,6 +1090,7 @@ impl ExecOpts {
1078
1090
  timeout: conv::opt_f64(opts, "timeout")?.map(Duration::from_secs_f64),
1079
1091
  tty: conv::opt_bool(opts, "tty")?,
1080
1092
  stdin,
1093
+ stdin_pipe: conv::opt_bool(opts, "stdin_pipe")?,
1081
1094
  rlimits: parse_rlimits(opts)?,
1082
1095
  })
1083
1096
  }
@@ -1104,7 +1117,13 @@ impl ExecOpts {
1104
1117
  if self.tty {
1105
1118
  b = b.tty(true);
1106
1119
  }
1107
- if let Some(stdin) = self.stdin {
1120
+ // Pipe mode opens a writable stdin sink (lifted out by ExecHandle via
1121
+ // `take_stdin`); bytes mode feeds a fixed buffer and closes. The core's
1122
+ // `StdinMode` is a single enum, so the two are mutually exclusive — pipe
1123
+ // wins if a caller somehow sets both.
1124
+ if self.stdin_pipe {
1125
+ b = b.stdin_pipe();
1126
+ } else if let Some(stdin) = self.stdin {
1108
1127
  b = b.stdin_bytes(stdin);
1109
1128
  }
1110
1129
  for (resource, soft, hard) in self.rlimits {
@@ -1195,7 +1214,13 @@ pub(crate) fn metrics_to_hash(m: &SandboxMetrics) -> RHash {
1195
1214
  }
1196
1215
 
1197
1216
  fn sandbox_status_str(status: SandboxStatus) -> &'static str {
1217
+ // Lowercased `Debug` names, matching the official SDKs' `format!("{:?}")`.
1218
+ // `Created`/`Starting` are new in v0.5.8 (cloud-only today). The match is
1219
+ // intentionally exhaustive — no wildcard — so a future upstream variant
1220
+ // surfaces as a compile error rather than a silent fallback.
1198
1221
  match status {
1222
+ SandboxStatus::Created => "created",
1223
+ SandboxStatus::Starting => "starting",
1199
1224
  SandboxStatus::Running => "running",
1200
1225
  SandboxStatus::Draining => "draining",
1201
1226
  SandboxStatus::Paused => "paused",
@@ -1204,6 +1229,15 @@ fn sandbox_status_str(status: SandboxStatus) -> &'static str {
1204
1229
  }
1205
1230
  }
1206
1231
 
1232
+ /// A `std::process::ExitStatus` as a Ruby Hash: `exit_code` (Integer or nil) and
1233
+ /// `success` (Boolean). Returned by the live `Sandbox#wait` / `#stop_and_wait`.
1234
+ fn exit_status_to_hash(status: std::process::ExitStatus) -> RHash {
1235
+ let hash = ruby().hash_new();
1236
+ let _ = hash.aset("exit_code", status.code());
1237
+ let _ = hash.aset("success", status.success());
1238
+ hash
1239
+ }
1240
+
1207
1241
  fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
1208
1242
  let hash = ruby().hash_new();
1209
1243
  let _ = hash.aset("name", result.name.clone());
@@ -1215,19 +1249,85 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
1215
1249
  hash
1216
1250
  }
1217
1251
 
1218
- fn handle_to_hash(handle: &SandboxHandle) -> RHash {
1219
- let hash = ruby().hash_new();
1220
- let _ = hash.aset("name", handle.name().to_string());
1221
- let _ = hash.aset("status", sandbox_status_str(handle.status()));
1222
- let _ = hash.aset(
1223
- "created_at_ms",
1224
- handle.created_at().map(|dt| dt.timestamp_millis()),
1225
- );
1226
- let _ = hash.aset(
1227
- "updated_at_ms",
1228
- handle.updated_at().map(|dt| dt.timestamp_millis()),
1229
- );
1230
- hash
1252
+ //--------------------------------------------------------------------------------------------------
1253
+ // SandboxHandle the controllable lightweight handle
1254
+ //--------------------------------------------------------------------------------------------------
1255
+
1256
+ /// Wraps a core `microsandbox::sandbox::SandboxHandle` (returned by
1257
+ /// `Sandbox.get`/`list`/`list_with`). Carries metadata accessors plus the rich
1258
+ /// lifecycle surface that moved off the live `Sandbox` in v0.5.8 — mirroring the
1259
+ /// official Python (`PySandboxHandle`) and Node (`SandboxHandle`) SDKs. Status is
1260
+ /// a synchronous snapshot read off the handle (no round-trip).
1261
+ #[magnus::wrap(class = "Microsandbox::Native::SandboxHandle", free_immediately, size)]
1262
+ pub struct SbHandle {
1263
+ inner: SandboxHandle,
1264
+ }
1265
+
1266
+ impl SbHandle {
1267
+ fn from_inner(inner: SandboxHandle) -> Self {
1268
+ Self { inner }
1269
+ }
1270
+
1271
+ fn name(&self) -> String {
1272
+ self.inner.name().to_string()
1273
+ }
1274
+
1275
+ /// Status snapshot captured when the handle was fetched (synchronous).
1276
+ fn status(&self) -> String {
1277
+ sandbox_status_str(self.inner.status_snapshot()).to_string()
1278
+ }
1279
+
1280
+ fn created_at_ms(&self) -> Option<i64> {
1281
+ self.inner.created_at().map(|dt| dt.timestamp_millis())
1282
+ }
1283
+
1284
+ fn updated_at_ms(&self) -> Option<i64> {
1285
+ self.inner.updated_at().map(|dt| dt.timestamp_millis())
1286
+ }
1287
+
1288
+ /// Graceful stop (SIGTERM→SIGKILL escalation, 10s default) and wait.
1289
+ fn stop(&self) -> Result<(), Error> {
1290
+ block_on(self.inner.stop()).map_err(error::to_ruby)
1291
+ }
1292
+
1293
+ /// Graceful stop with a custom escalation timeout (seconds).
1294
+ fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
1295
+ block_on(self.inner.stop_with_timeout(Duration::from_secs_f64(secs)))
1296
+ .map_err(error::to_ruby)
1297
+ }
1298
+
1299
+ /// Force kill (SIGKILL) and wait.
1300
+ fn kill(&self) -> Result<(), Error> {
1301
+ block_on(self.inner.kill()).map_err(error::to_ruby)
1302
+ }
1303
+
1304
+ /// Force kill, waiting up to `secs` for the process to disappear.
1305
+ fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
1306
+ block_on(self.inner.kill_with_timeout(Duration::from_secs_f64(secs)))
1307
+ .map_err(error::to_ruby)
1308
+ }
1309
+
1310
+ /// Send the graceful-shutdown request and return without waiting.
1311
+ fn request_stop(&self) -> Result<(), Error> {
1312
+ block_on(self.inner.request_stop()).map_err(error::to_ruby)
1313
+ }
1314
+
1315
+ /// Send the force-kill request and return without waiting.
1316
+ fn request_kill(&self) -> Result<(), Error> {
1317
+ block_on(self.inner.request_kill()).map_err(error::to_ruby)
1318
+ }
1319
+
1320
+ /// Send the drain request (SIGUSR1) and return without waiting.
1321
+ fn request_drain(&self) -> Result<(), Error> {
1322
+ block_on(self.inner.request_drain()).map_err(error::to_ruby)
1323
+ }
1324
+
1325
+ /// Block until the sandbox reaches a terminal state; returns a stop-result
1326
+ /// Hash (name, status, exit_code, signal, observed_at_ms, source).
1327
+ fn wait_until_stopped(&self) -> Result<RHash, Error> {
1328
+ let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
1329
+ Ok(stop_result_to_hash(&result))
1330
+ }
1231
1331
  }
1232
1332
 
1233
1333
  //--------------------------------------------------------------------------------------------------
@@ -1328,15 +1428,12 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
1328
1428
  class.define_method("shell", method!(Sandbox::shell, 2))?;
1329
1429
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
1330
1430
  class.define_method("shell_stream", method!(Sandbox::shell_stream, 2))?;
1331
- class.define_method("stop", method!(Sandbox::stop, 1))?;
1332
- class.define_method("kill", method!(Sandbox::kill, 1))?;
1333
- class.define_method("request_stop", method!(Sandbox::request_stop, 0))?;
1334
- class.define_method("request_kill", method!(Sandbox::request_kill, 0))?;
1335
- class.define_method("request_drain", method!(Sandbox::request_drain, 0))?;
1336
- class.define_method(
1337
- "wait_until_stopped",
1338
- method!(Sandbox::wait_until_stopped, 0),
1339
- )?;
1431
+ class.define_method("stop", method!(Sandbox::stop, 0))?;
1432
+ class.define_method("stop_and_wait", method!(Sandbox::stop_and_wait, 0))?;
1433
+ class.define_method("kill", method!(Sandbox::kill, 0))?;
1434
+ class.define_method("drain", method!(Sandbox::drain, 0))?;
1435
+ class.define_method("wait", method!(Sandbox::wait, 0))?;
1436
+ class.define_method("status", method!(Sandbox::status, 0))?;
1340
1437
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
1341
1438
  class.define_method("detach", method!(Sandbox::detach, 0))?;
1342
1439
  class.define_method("metrics", method!(Sandbox::metrics, 0))?;
@@ -1367,5 +1464,22 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
1367
1464
  class.define_method("attach", method!(Sandbox::attach, 3))?;
1368
1465
  class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
1369
1466
 
1467
+ let handle = native.define_class("SandboxHandle", ruby.class_object())?;
1468
+ handle.define_method("name", method!(SbHandle::name, 0))?;
1469
+ handle.define_method("status", method!(SbHandle::status, 0))?;
1470
+ handle.define_method("created_at_ms", method!(SbHandle::created_at_ms, 0))?;
1471
+ handle.define_method("updated_at_ms", method!(SbHandle::updated_at_ms, 0))?;
1472
+ handle.define_method("stop", method!(SbHandle::stop, 0))?;
1473
+ handle.define_method("stop_with_timeout", method!(SbHandle::stop_with_timeout, 1))?;
1474
+ handle.define_method("kill", method!(SbHandle::kill, 0))?;
1475
+ handle.define_method("kill_with_timeout", method!(SbHandle::kill_with_timeout, 1))?;
1476
+ handle.define_method("request_stop", method!(SbHandle::request_stop, 0))?;
1477
+ handle.define_method("request_kill", method!(SbHandle::request_kill, 0))?;
1478
+ handle.define_method("request_drain", method!(SbHandle::request_drain, 0))?;
1479
+ handle.define_method(
1480
+ "wait_until_stopped",
1481
+ method!(SbHandle::wait_until_stopped, 0),
1482
+ )?;
1483
+
1370
1484
  Ok(())
1371
1485
  }
@@ -65,7 +65,12 @@ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
65
65
  let vol = block_on(builder.create()).map_err(error::to_ruby)?;
66
66
  let hash = ruby().hash_new();
67
67
  hash.aset("name", vol.name().to_string())?;
68
- hash.aset("path", vol.path().display().to_string())?;
68
+ // `path()` is fallible as of v0.5.8 (returns `Unsupported` for cloud
69
+ // volumes); the volume just created here is local, so this resolves.
70
+ hash.aset(
71
+ "path",
72
+ vol.path().map_err(error::to_ruby)?.display().to_string(),
73
+ )?;
69
74
  Ok(hash)
70
75
  }
71
76
 
@@ -65,4 +65,10 @@ module Microsandbox
65
65
 
66
66
  # Runtime compatibility ---------------------------------------------------
67
67
  define_error(:UnsupportedOperationError, "unsupported-operation")
68
+
69
+ # Cloud / backend routing errors (v0.5.8) ---------------------------------
70
+ # `UnsupportedError` (a backend feature gap, e.g. an op not yet wired on the
71
+ # cloud backend) is distinct from `UnsupportedOperationError` above.
72
+ define_error(:CloudHttpError, "cloud-http")
73
+ define_error(:UnsupportedError, "unsupported")
68
74
  end