microsandbox-rb 0.14.0 → 0.16.0

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.
data/README.md CHANGED
@@ -186,6 +186,39 @@ Microsandbox::Sandbox.start("box") # restart a stopped sandbox
186
186
  Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
187
187
  ```
188
188
 
189
+ **Convergent lifecycle** (runtime `v0.6.16`) — idempotent operations that take a
190
+ name to the state you want, whatever state it is in now:
191
+
192
+ ```ruby
193
+ # Create it, or connect to (and start) the one that is already there. The
194
+ # keyword options are `create`'s and apply only when a create actually happens.
195
+ sb = Microsandbox::Sandbox.connect_or_create("box", image: "public.ecr.aws/docker/library/alpine:latest")
196
+
197
+ sb.id # opaque identity of the *persisted* sandbox — unlike
198
+ # the reusable name, it changes on remove+recreate
199
+ sb.wait_for_status(:running) # => SandboxHandle (no built-in timeout)
200
+ sb = sb.restart # stop + start; => a new live Sandbox
201
+ sb.destroy # stop + remove, in one step
202
+
203
+ h = Microsandbox::Sandbox.get("box")
204
+ h.connect_or_start # connect if running, start if not => Sandbox
205
+ h.restart(force: true, timeout: 5)
206
+ h.destroy
207
+ ```
208
+
209
+ Everything that acts on an *existing* receiver — `wait_for_status`, `restart`,
210
+ `destroy`, `connect_or_start` — compares the identity it was bound to against
211
+ the name's current owner first, raising `Microsandbox::SandboxReplacedError`
212
+ rather than touching a sandbox someone else recreated under the same name.
213
+ (`connect_or_create` is the entry point, so it has no prior identity to check:
214
+ it simply converges on whichever sandbox now owns the name.)
215
+
216
+ `connect_or_create`'s block form stops the sandbox only when the call owns its
217
+ lifecycle — i.e. when it created it attached. A sandbox it merely connected to,
218
+ or started with `detached: true`, is left running. And the stop it does issue is
219
+ scoped to that sandbox's `id`, so a name removed and recreated while the block
220
+ ran cannot be taken down by the teardown either.
221
+
189
222
  > **v0.5.8 lifecycle change.** Upstream split the lifecycle into the live
190
223
  > `Sandbox` and a controllable `SandboxHandle`, and the gem mirrors it. The live
191
224
  > `Sandbox#stop`/`#kill` no longer take a `timeout:`; `#request_stop`/
@@ -430,7 +463,9 @@ Microsandbox::Volume.remove("cache")
430
463
  ```
431
464
 
432
465
  `volumes:` accepts a host path String (bind mount) or `{ bind: "/host" }` /
433
- `{ named: "volume-name" }` per guest path. Boot from a snapshot with
466
+ `{ named: "volume-name" }` per guest path. A bind or named mount may pin the
467
+ fallback guest owner for host files with `uid:`/`gid:` (both required together,
468
+ runtime `v0.6.15`). Boot from a snapshot with
434
469
  `Sandbox.create(name, from_snapshot: "snap-name-or-path")`.
435
470
 
436
471
  ### Error handling
@@ -524,8 +559,8 @@ change diverged the two numbers — the gem version is **not** a reliable indica
524
559
  of the embedded runtime version. To learn which runtime a build wraps, ask it:
525
560
 
526
561
  ```ruby
527
- Microsandbox::VERSION # => "0.13.0" (the gem's own version)
528
- Microsandbox.runtime_version # => "v0.6.9" (the embedded upstream runtime tag)
562
+ Microsandbox::VERSION # => "0.16.0" (the gem's own version)
563
+ Microsandbox.runtime_version # => "v0.6.16" (the embedded upstream runtime tag)
529
564
  ```
530
565
 
531
566
  The companion [`microsandbox-rb-binaries`](#the-runtime-binaries) gem is
@@ -557,6 +592,8 @@ stale.
557
592
  | `0.12.0` | `v0.6.8` | adopts upstream `v0.6.8` (**breaking**): `Sandbox.list`/`.list_with` return a cursor-paginated `SandboxPage` (`limit:`/`cursor:` keywords), `UnsupportedError` re-keyed by structured operations with `#operation`/`#hint`; runtime adds a shared log registry for followed streams and cloud exec/ssh reconnects |
558
593
  | `0.13.0` | `v0.6.9` | adopts upstream `v0.6.9` (**breaking**): a bare `MSB_API_KEY` no longer selects the cloud backend (explicit `MSB_BACKEND=cloud` or a cloud profile required; invalid cloud config fails closed with `InvalidConfigError`); snapshot payload integrity becomes opt-in (`record_integrity:`, `verify` can report `:not_recorded`). Parity: default-workload execution (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`), flat root disks (`RootDisk.flat`), `modify(root_disk_size:)`, `rate_limiter:`, `vsock:`, `default_backend_info`, `Volume.get_default` |
559
594
  | `0.14.0` | `v0.6.9` | two-gem split: SDK-only gem (no build-time runtime download) + companion `microsandbox-rb-binaries` platform gems |
595
+ | `0.15.0` | `v0.6.14` | adopts upstream `v0.6.10`–`v0.6.14` step by step: bind-mount correctness, guest bootstrap off the kernel command line, DNS pins for deferred domain allows, Linux glibc 2.28 baseline for the prebuilt runtime, legacy ext4 upper-disk resize, `msb_krun` 0.1.32. Parity: `ssh.open_client`/`prepare_server` accept `inactivity_timeout:` (seconds; `0` disables, `nil` inherits the 600s global default) |
596
+ | `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
560
597
 
561
598
  **Going forward** — the gem version moves on its own semver track and no longer
562
599
  mirrors the upstream tag:
@@ -6,8 +6,8 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
- # The core-crate dependency below stays pinned at its own tag (v0.6.9).
10
- version = "0.14.0"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.6.16).
10
+ version = "0.16.0"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -42,8 +42,8 @@ rb-sys = "0.9"
42
42
  # first-use download into ~/.microsandbox (`setup::install`, which is not
43
43
  # gated on "prebuilt"). "net"/"ssh"/"keyring" are the features the official
44
44
  # SDKs ship with.
45
- microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9", default-features = false, features = ["keyring", "net", "ssh"] }
46
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9" }
45
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.16", default-features = false, features = ["keyring", "net", "ssh"] }
46
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.16" }
47
47
  # The *guest* agent (`agentd`, PID 1 inside every microVM) is embedded into the
48
48
  # extension by microsandbox-filesystem's build.rs, which without its "prebuilt"
49
49
  # feature demands a locally built `build/agentd` (workspace-only, `just
@@ -52,7 +52,7 @@ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox"
52
52
  # prebuilt agentd for the target arch is fetched (same behaviour as before)
53
53
  # while the SDK-level msb download above stays off. Feature unification means
54
54
  # this adds no new crates; keep it on the same tag as the deps above.
55
- microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.9", default-features = false, features = ["prebuilt"] }
55
+ microsandbox-runtime = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.16", default-features = false, features = ["prebuilt"] }
56
56
 
57
57
  # Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
58
58
  tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
@@ -17,6 +17,11 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
17
17
  SandboxNotFound(_) => "SandboxNotFoundError",
18
18
  SandboxAlreadyExists(_) => "SandboxAlreadyExistsError",
19
19
  SandboxStillRunning(_) => "SandboxStillRunningError",
20
+ // v0.6.16 (#1462): the receiver's captured identity no longer owns the
21
+ // name — it was removed and recreated. Raised by the identity-checked
22
+ // convergent lifecycle APIs instead of acting on the replacement.
23
+ // Mirrors the Python `SandboxReplacedError`.
24
+ SandboxReplaced { .. } => "SandboxReplacedError",
20
25
  // v0.6.6 (#1099): the sandbox exists but isn't running. Raised by the
21
26
  // handle's exec/attach/ping/touch not-running guards and the fs
22
27
  // agent-endpoint lookup. The `SandboxNotRunningError` class already
@@ -18,11 +18,11 @@ use microsandbox::logs::{
18
18
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
19
19
  };
20
20
  use microsandbox::sandbox::{
21
- AttachOptionsBuilder, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind, FsMetadata,
22
- HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
- RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
- SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
25
- SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
21
+ AttachOptionsBuilder, DestroyOptions, DiskImageFormat, EnvVar, FlatClone, FsEntry, FsEntryKind,
22
+ FsMetadata, HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle,
23
+ RestartOptions, RlimitResource, RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics,
24
+ SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
25
+ SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
26
26
  };
27
27
  use microsandbox::LogLevel;
28
28
  use microsandbox::MicrosandboxResult;
@@ -167,7 +167,8 @@ impl Sandbox {
167
167
  // Hash — guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
168
168
  // (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
169
169
  // readonly/noexec/nosuid/nodev (bool), stat_virtualization,
170
- // host_permissions. Enum-valued options are validated up front (the
170
+ // host_permissions, override_uid/override_gid (u32 pair).
171
+ // Enum-valued options are validated up front (the
171
172
  // volume closure can't return an error); the core validates the rest
172
173
  // (e.g. rejecting stat_virtualization on tmpfs/disk) at create().
173
174
  for m in conv::opt_hash_vec(opts, "volumes")? {
@@ -195,6 +196,14 @@ impl Sandbox {
195
196
  let host_perms = conv::opt_string(m, "host_permissions")?
196
197
  .map(|s| host_permissions_from_str(&s))
197
198
  .transpose()?;
199
+ // v0.6.15: the fallback guest owner presented for host files that
200
+ // carry no per-file stat override. The Ruby layer validates the pair
201
+ // (both-or-neither, u32 range, bind/named only, not with
202
+ // stat_virtualization=off); whether a *named* volume is directory-
203
+ // or disk-backed is known only to the core, which rejects the
204
+ // disk-backed case at build().
205
+ let override_uid = conv::opt_u32(m, "override_uid")?;
206
+ let override_gid = conv::opt_u32(m, "override_gid")?;
198
207
  // v0.6.7: mount-root symlink protection is on by default; this is
199
208
  // the per-mount opt-out. Valid for bind/named-directory mounts —
200
209
  // the core rejects it elsewhere at build().
@@ -251,6 +260,9 @@ impl Sandbox {
251
260
  if let Some(hp) = host_perms {
252
261
  mb = mb.host_permissions(hp);
253
262
  }
263
+ if let (Some(uid), Some(gid)) = (override_uid, override_gid) {
264
+ mb = mb.owner(uid, gid);
265
+ }
254
266
  if let Some(follow) = follow_root_symlinks {
255
267
  mb = mb.follow_root_symlinks(follow);
256
268
  }
@@ -509,6 +521,16 @@ impl Sandbox {
509
521
  Ok(PullSession::new(handle, join))
510
522
  }
511
523
 
524
+ /// Converge on a live sandbox with this name: connect to (or start) the
525
+ /// persisted one when it already exists, otherwise create it from `opts`.
526
+ /// Concurrent callers converge on the winning identity. The core rejects
527
+ /// the combination with `replace`.
528
+ fn connect_or_create(name: String, opts: RHash) -> Result<Sandbox, Error> {
529
+ let b = Self::build_builder(name, opts)?;
530
+ let inner = block_on(b.connect_or_create()).map_err(error::to_ruby)?;
531
+ Ok(Sandbox::from_inner(inner))
532
+ }
533
+
512
534
  /// Restart a previously-defined sandbox by name.
513
535
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
514
536
  let detached = conv::opt_bool(opts, "detached")?;
@@ -580,6 +602,13 @@ impl Sandbox {
580
602
  self.inner.name().to_string()
581
603
  }
582
604
 
605
+ /// The opaque, backend-assigned identity of this persisted sandbox
606
+ /// (v0.6.16). Stable for the sandbox's lifetime, and different once the
607
+ /// same name is removed and recreated.
608
+ fn id(&self) -> String {
609
+ self.inner.id().to_string()
610
+ }
611
+
583
612
  /// Run a command (no shell). `args` is an Array of strings; `opts` is a
584
613
  /// string-keyed Hash (cwd, user, env, timeout, tty, stdin).
585
614
  fn exec(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<RHash, Error> {
@@ -644,17 +673,23 @@ impl Sandbox {
644
673
  Ok(ExecHandle::from_core(handle))
645
674
  }
646
675
 
647
- /// Graceful stop. Mirrors the official SDKs: the live handle routes through
648
- /// a freshly fetched `SandboxHandle::stop` (SIGTERM→SIGKILL escalation with
649
- /// a 10s default). Fine-grained control — a custom timeout or fire-and-
650
- /// return `request_*` — lives on `SandboxHandle`, obtained via `Sandbox.get`.
676
+ /// Graceful stop (SIGTERM→SIGKILL escalation with a 10s default), routed
677
+ /// straight through the live sandbox like every other lifecycle method here
678
+ /// and like both official bindings (`sdk/python/src/sandbox.rs`,
679
+ /// `sdk/ruby/ext/.../lib.rs`).
680
+ ///
681
+ /// This deliberately does NOT re-fetch a `SandboxHandle` by name first. It
682
+ /// used to, back when the core's live `stop` was not identity-scoped; as of
683
+ /// v0.6.16 the core routes `stop` → `request_stop` → `stop_identified(name,
684
+ /// self.identity())`, so going through `self.inner` is what makes the stop
685
+ /// refuse a same-name replacement (`SandboxReplacedError`) instead of
686
+ /// terminating whatever sandbox happens to own the name right now. The old
687
+ /// by-name refetch threw that identity away.
688
+ ///
689
+ /// Fine-grained control — a custom timeout or fire-and-return `request_*` —
690
+ /// lives on `SandboxHandle`, obtained via `Sandbox.get`.
651
691
  fn stop(&self) -> Result<(), Error> {
652
- let name = self.inner.name().to_string();
653
- block_on(async move {
654
- let handle = microsandbox::sandbox::Sandbox::get(&name).await?;
655
- handle.stop().await
656
- })
657
- .map_err(error::to_ruby)
692
+ block_on(self.inner.stop()).map_err(error::to_ruby)
658
693
  }
659
694
 
660
695
  /// Graceful stop, then wait for the process to exit. Returns an exit-status
@@ -680,6 +715,29 @@ impl Sandbox {
680
715
  Ok(exit_status_to_hash(status))
681
716
  }
682
717
 
718
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
719
+ /// returning a fresh handle. A same-name replacement raises
720
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
721
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
722
+ let status = sandbox_status_from_str(&status)?;
723
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
724
+ Ok(SbHandle::from_inner(handle))
725
+ }
726
+
727
+ /// Stop and start this exact sandbox, returning the new live sandbox.
728
+ /// `opts` carries `force`/`timeout`/`detached`.
729
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
730
+ let options = restart_options(opts)?;
731
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
732
+ Ok(Sandbox::from_inner(inner))
733
+ }
734
+
735
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
736
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
737
+ let options = destroy_options(opts)?;
738
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
739
+ }
740
+
683
741
  /// Live status fetched from the backend (a round-trip per call).
684
742
  fn status(&self) -> Result<String, Error> {
685
743
  let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
@@ -866,11 +924,14 @@ impl Sandbox {
866
924
  //----------------------------------------------------------------------
867
925
 
868
926
  /// Open a native in-process SSH client to this sandbox. `opts`: user, term,
869
- /// sftp (bool, default true).
927
+ /// sftp (bool, default true), inactivity_timeout (seconds, f64; 0 disables).
870
928
  fn ssh_open_client(&self, opts: RHash) -> Result<crate::ssh::SshClient, Error> {
871
929
  let user = conv::opt_string(opts, "user")?;
872
930
  let term = conv::opt_string(opts, "term")?;
873
931
  let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
932
+ let inactivity_timeout = conv::opt_f64(opts, "inactivity_timeout")?
933
+ .map(secs_to_duration)
934
+ .transpose()?;
874
935
  let ssh = self.inner.ssh();
875
936
  let client = block_on(ssh.open_client_with(move |mut b| {
876
937
  if let Some(u) = user {
@@ -879,6 +940,9 @@ impl Sandbox {
879
940
  if let Some(t) = term {
880
941
  b = b.term(t);
881
942
  }
943
+ if let Some(t) = inactivity_timeout {
944
+ b = b.inactivity_timeout(t);
945
+ }
882
946
  b.sftp(sftp)
883
947
  }))
884
948
  .map_err(error::to_ruby)?;
@@ -886,12 +950,16 @@ impl Sandbox {
886
950
  }
887
951
 
888
952
  /// Prepare a reusable SSH server endpoint. `opts`: host_key_path,
889
- /// authorized_keys_path, user, sftp (bool, default true).
953
+ /// authorized_keys_path, user, sftp (bool, default true),
954
+ /// inactivity_timeout (seconds, f64; 0 disables).
890
955
  fn ssh_prepare_server(&self, opts: RHash) -> Result<crate::ssh::SshServer, Error> {
891
956
  let host_key_path = conv::opt_string(opts, "host_key_path")?;
892
957
  let authorized_keys_path = conv::opt_string(opts, "authorized_keys_path")?;
893
958
  let user = conv::opt_string(opts, "user")?;
894
959
  let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
960
+ let inactivity_timeout = conv::opt_f64(opts, "inactivity_timeout")?
961
+ .map(secs_to_duration)
962
+ .transpose()?;
895
963
  let ssh = self.inner.ssh();
896
964
  let server = block_on(ssh.prepare_server_with(move |mut b| {
897
965
  if let Some(p) = host_key_path {
@@ -903,6 +971,9 @@ impl Sandbox {
903
971
  if let Some(u) = user {
904
972
  b = b.user(u);
905
973
  }
974
+ if let Some(t) = inactivity_timeout {
975
+ b = b.inactivity_timeout(t);
976
+ }
906
977
  b.sftp(sftp)
907
978
  }))
908
979
  .map_err(error::to_ruby)?;
@@ -2083,6 +2154,55 @@ fn sandbox_status_str(status: SandboxStatus) -> &'static str {
2083
2154
  }
2084
2155
  }
2085
2156
 
2157
+ /// Parse a Ruby-facing status name back into a core `SandboxStatus`. The Ruby
2158
+ /// layer already validates the seven names (raising `ArgumentError`, the repo's
2159
+ /// idiom for a bad argument value), so reaching the fallback here means the
2160
+ /// native layer was called directly; keep it exhaustive rather than silent.
2161
+ fn sandbox_status_from_str(status: &str) -> Result<SandboxStatus, Error> {
2162
+ match status {
2163
+ "created" => Ok(SandboxStatus::Created),
2164
+ "starting" => Ok(SandboxStatus::Starting),
2165
+ "running" => Ok(SandboxStatus::Running),
2166
+ "draining" => Ok(SandboxStatus::Draining),
2167
+ "paused" => Ok(SandboxStatus::Paused),
2168
+ "stopped" => Ok(SandboxStatus::Stopped),
2169
+ "crashed" => Ok(SandboxStatus::Crashed),
2170
+ other => Err(error::base_error(format!(
2171
+ "unknown sandbox status {other:?} (expected created/starting/running/draining/\
2172
+ paused/stopped/crashed)"
2173
+ ))),
2174
+ }
2175
+ }
2176
+
2177
+ /// Build the core's `RestartOptions` from a string-keyed Hash
2178
+ /// (`force`/`timeout`/`detached`). An absent `timeout` keeps the core's
2179
+ /// ten-second graceful-shutdown default.
2180
+ fn restart_options(opts: RHash) -> Result<RestartOptions, Error> {
2181
+ let mut options = RestartOptions {
2182
+ force: conv::opt_bool(opts, "force")?,
2183
+ detached: conv::opt_bool(opts, "detached")?,
2184
+ ..Default::default()
2185
+ };
2186
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2187
+ options.timeout = secs_to_duration(secs)?;
2188
+ }
2189
+ Ok(options)
2190
+ }
2191
+
2192
+ /// Build the core's `DestroyOptions` from a string-keyed Hash
2193
+ /// (`force`/`timeout`). An absent `timeout` keeps the core's ten-second
2194
+ /// graceful-shutdown default.
2195
+ fn destroy_options(opts: RHash) -> Result<DestroyOptions, Error> {
2196
+ let mut options = DestroyOptions {
2197
+ force: conv::opt_bool(opts, "force")?,
2198
+ ..Default::default()
2199
+ };
2200
+ if let Some(secs) = conv::opt_f64(opts, "timeout")? {
2201
+ options.timeout = secs_to_duration(secs)?;
2202
+ }
2203
+ Ok(options)
2204
+ }
2205
+
2086
2206
  /// A `std::process::ExitStatus` as a Ruby Hash: `exit_code` (Integer or nil) and
2087
2207
  /// `success` (Boolean). Returned by the live `Sandbox#wait` / `#stop_and_wait`.
2088
2208
  fn exit_status_to_hash(status: std::process::ExitStatus) -> RHash {
@@ -2409,6 +2529,13 @@ impl SbHandle {
2409
2529
  self.inner.name().to_string()
2410
2530
  }
2411
2531
 
2532
+ /// The opaque, backend-assigned identity of the persisted sandbox this
2533
+ /// handle is bound to (v0.6.16). The lifecycle operations below refuse to
2534
+ /// act on a same-name replacement by comparing against it.
2535
+ fn id(&self) -> String {
2536
+ self.inner.id().to_string()
2537
+ }
2538
+
2412
2539
  /// Status snapshot captured when the handle was fetched (synchronous).
2413
2540
  fn status(&self) -> String {
2414
2541
  sandbox_status_str(self.inner.status_snapshot()).to_string()
@@ -2457,6 +2584,45 @@ impl SbHandle {
2457
2584
  block_on(self.inner.request_drain()).map_err(error::to_ruby)
2458
2585
  }
2459
2586
 
2587
+ /// Connect when this exact sandbox is running, wait while it is starting,
2588
+ /// or start it when it is created/stopped/crashed. `opts` carries
2589
+ /// `detached`, which applies only when a start is actually required.
2590
+ fn connect_or_start(&self, opts: RHash) -> Result<Sandbox, Error> {
2591
+ let detached = conv::opt_bool(opts, "detached")?;
2592
+ let inner = block_on(async {
2593
+ if detached {
2594
+ self.inner.connect_or_start_detached().await
2595
+ } else {
2596
+ self.inner.connect_or_start().await
2597
+ }
2598
+ })
2599
+ .map_err(error::to_ruby)?;
2600
+ Ok(Sandbox::from_inner(inner))
2601
+ }
2602
+
2603
+ /// Block until this exact sandbox reaches `status` (no built-in timeout),
2604
+ /// returning a fresh handle. A same-name replacement raises
2605
+ /// `SandboxReplacedError` instead of silently redirecting the wait.
2606
+ fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2607
+ let status = sandbox_status_from_str(&status)?;
2608
+ let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
2609
+ Ok(SbHandle::from_inner(handle))
2610
+ }
2611
+
2612
+ /// Stop and start this exact sandbox, returning the new live sandbox.
2613
+ /// `opts` carries `force`/`timeout`/`detached`.
2614
+ fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2615
+ let options = restart_options(opts)?;
2616
+ let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
2617
+ Ok(Sandbox::from_inner(inner))
2618
+ }
2619
+
2620
+ /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2621
+ fn destroy(&self, opts: RHash) -> Result<(), Error> {
2622
+ let options = destroy_options(opts)?;
2623
+ block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
2624
+ }
2625
+
2460
2626
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2461
2627
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2462
2628
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
@@ -2594,6 +2760,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2594
2760
  "create_with_progress",
2595
2761
  function!(Sandbox::create_with_progress, 2),
2596
2762
  )?;
2763
+ class.define_singleton_method(
2764
+ "connect_or_create",
2765
+ function!(Sandbox::connect_or_create, 2),
2766
+ )?;
2597
2767
  class.define_singleton_method("start", function!(Sandbox::start, 2))?;
2598
2768
  class.define_singleton_method("get", function!(Sandbox::get, 1))?;
2599
2769
  class.define_singleton_method("list", function!(Sandbox::list, 0))?;
@@ -2601,6 +2771,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2601
2771
  class.define_singleton_method("remove", function!(Sandbox::remove, 1))?;
2602
2772
 
2603
2773
  class.define_method("name", method!(Sandbox::name, 0))?;
2774
+ class.define_method("id", method!(Sandbox::id, 0))?;
2604
2775
  class.define_method("exec", method!(Sandbox::exec, 3))?;
2605
2776
  class.define_method("shell", method!(Sandbox::shell, 2))?;
2606
2777
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
@@ -2615,6 +2786,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2615
2786
  class.define_method("kill", method!(Sandbox::kill, 0))?;
2616
2787
  class.define_method("drain", method!(Sandbox::drain, 0))?;
2617
2788
  class.define_method("wait", method!(Sandbox::wait, 0))?;
2789
+ class.define_method("wait_for_status", method!(Sandbox::wait_for_status, 1))?;
2790
+ class.define_method("restart", method!(Sandbox::restart, 1))?;
2791
+ class.define_method("destroy", method!(Sandbox::destroy, 1))?;
2618
2792
  class.define_method("status", method!(Sandbox::status, 0))?;
2619
2793
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
2620
2794
  class.define_method("detach", method!(Sandbox::detach, 0))?;
@@ -2654,6 +2828,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2654
2828
 
2655
2829
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
2656
2830
  handle.define_method("name", method!(SbHandle::name, 0))?;
2831
+ handle.define_method("id", method!(SbHandle::id, 0))?;
2657
2832
  handle.define_method("status", method!(SbHandle::status, 0))?;
2658
2833
  handle.define_method("created_at_ms", method!(SbHandle::created_at_ms, 0))?;
2659
2834
  handle.define_method("updated_at_ms", method!(SbHandle::updated_at_ms, 0))?;
@@ -2661,6 +2836,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2661
2836
  handle.define_method("stop_with_timeout", method!(SbHandle::stop_with_timeout, 1))?;
2662
2837
  handle.define_method("kill", method!(SbHandle::kill, 0))?;
2663
2838
  handle.define_method("kill_with_timeout", method!(SbHandle::kill_with_timeout, 1))?;
2839
+ handle.define_method("connect_or_start", method!(SbHandle::connect_or_start, 1))?;
2840
+ handle.define_method("wait_for_status", method!(SbHandle::wait_for_status, 1))?;
2841
+ handle.define_method("restart", method!(SbHandle::restart, 1))?;
2842
+ handle.define_method("destroy", method!(SbHandle::destroy, 1))?;
2664
2843
  handle.define_method("request_stop", method!(SbHandle::request_stop, 0))?;
2665
2844
  handle.define_method("request_kill", method!(SbHandle::request_kill, 0))?;
2666
2845
  handle.define_method("request_drain", method!(SbHandle::request_drain, 0))?;
@@ -35,6 +35,13 @@ module Microsandbox
35
35
  define_error(:SandboxNotRunningError, "sandbox-not-running")
36
36
  define_error(:SandboxAlreadyExistsError, "sandbox-already-exists")
37
37
  define_error(:SandboxStillRunningError, "sandbox-still-running")
38
+ # v0.6.16: a lifecycle operation was aimed at a sandbox identity that no
39
+ # longer owns the name — the name was removed and recreated behind the
40
+ # caller's back. Raised by the identity-checked convergent APIs
41
+ # (`wait_for_status`, `restart`, `destroy`, `connect_or_start`) rather than
42
+ # letting them act on the replacement. Mirrors the Python SDK's
43
+ # SandboxReplacedError.
44
+ define_error(:SandboxReplacedError, "sandbox-replaced")
38
45
 
39
46
  # Execution errors --------------------------------------------------------
40
47
  define_error(:ExecTimeoutError, "exec-timeout")