microsandbox-rb 0.17.0 → 0.17.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -8
  3. data/Cargo.lock +45 -46
  4. data/DESIGN.md +90 -72
  5. data/README.md +178 -669
  6. data/SECURITY.md +65 -0
  7. data/docs/configuration.md +154 -0
  8. data/docs/errors.md +74 -0
  9. data/docs/execution.md +108 -0
  10. data/docs/filesystem.md +45 -0
  11. data/docs/images.md +65 -0
  12. data/docs/lifecycle.md +141 -0
  13. data/docs/observability.md +99 -0
  14. data/docs/releasing.md +117 -0
  15. data/docs/runtime.md +154 -0
  16. data/docs/surface.md +75 -0
  17. data/ext/microsandbox/Cargo.toml +9 -5
  18. data/ext/microsandbox/src/agent.rs +13 -11
  19. data/ext/microsandbox/src/backend.rs +1 -1
  20. data/ext/microsandbox/src/exec.rs +12 -8
  21. data/ext/microsandbox/src/fs_stream.rs +19 -7
  22. data/ext/microsandbox/src/host_path.rs +108 -0
  23. data/ext/microsandbox/src/image.rs +2 -2
  24. data/ext/microsandbox/src/lib.rs +3 -2
  25. data/ext/microsandbox/src/runtime.rs +189 -13
  26. data/ext/microsandbox/src/sandbox.rs +149 -102
  27. data/ext/microsandbox/src/snapshot.rs +17 -11
  28. data/ext/microsandbox/src/ssh.rs +22 -16
  29. data/ext/microsandbox/src/stream.rs +6 -2
  30. data/ext/microsandbox/src/volume.rs +50 -17
  31. data/lib/microsandbox/fs.rb +4 -8
  32. data/lib/microsandbox/image.rb +2 -2
  33. data/lib/microsandbox/log_entry.rb +1 -1
  34. data/lib/microsandbox/metrics.rb +1 -1
  35. data/lib/microsandbox/outbound_proxy.rb +8 -0
  36. data/lib/microsandbox/sandbox.rb +128 -13
  37. data/lib/microsandbox/snapshot.rb +1 -1
  38. data/lib/microsandbox/ssh.rb +2 -10
  39. data/lib/microsandbox/streams.rb +8 -5
  40. data/lib/microsandbox/version.rb +1 -1
  41. data/lib/microsandbox/volume.rb +31 -14
  42. data/lib/microsandbox-rb.rb +7 -0
  43. data/lib/microsandbox.rb +88 -31
  44. data/sig/microsandbox.rbs +24 -0
  45. metadata +14 -1
@@ -7,6 +7,7 @@
7
7
  //! as a plain Ruby `Hash`/`Array`/`String` and shaped into value objects by the
8
8
  //! Ruby layer.
9
9
 
10
+ use std::path::PathBuf;
10
11
  use std::sync::Arc;
11
12
  use std::time::Duration;
12
13
 
@@ -39,6 +40,7 @@ use tokio::task::JoinHandle;
39
40
  use crate::conv;
40
41
  use crate::error;
41
42
  use crate::exec::ExecHandle;
43
+ use crate::host_path::{self, HostPaths};
42
44
  use crate::runtime::{block_on, ruby};
43
45
  use crate::stream::{LogStream, MetricsStream};
44
46
 
@@ -75,6 +77,9 @@ impl Sandbox {
75
77
  /// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
76
78
  fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
77
79
  let mut b = microsandbox::Sandbox::builder(name);
80
+ // Relative host paths are anchored to the current directory here, once,
81
+ // before the core persists them (see host_path.rs).
82
+ let host_paths = HostPaths::for_default_backend();
78
83
 
79
84
  if let Some(v) = conv::opt_string(opts, "image")? {
80
85
  if let Some(fstype) = conv::opt_string(opts, "fstype")? {
@@ -83,13 +88,17 @@ impl Sandbox {
83
88
  // image_with(disk().fstype()). (A bare `image` string otherwise
84
89
  // auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
85
90
  // are captured on the builder and surface at create().
86
- b = b.image_with(move |i| i.disk(v).fstype(fstype));
91
+ let path = host_paths.pin(v, "root disk image path")?;
92
+ b = b.image_with(move |i| i.disk(path).fstype(fstype));
87
93
  } else {
88
- b = b.image(v);
94
+ b = match host_paths.pin_image(v)? {
95
+ host_path::Image::Text(text) => b.image(text),
96
+ host_path::Image::Path(path) => b.image(path),
97
+ };
89
98
  }
90
99
  }
91
100
  if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
92
- b = b.from_snapshot(v);
101
+ b = b.from_snapshot(host_paths.pin_snapshot(v)?);
93
102
  }
94
103
  if let Some(v) = conv::opt_u8(opts, "cpus")? {
95
104
  b = b.cpus(v);
@@ -208,27 +217,35 @@ impl Sandbox {
208
217
  // the per-mount opt-out. Valid for bind/named-directory mounts —
209
218
  // the core rejects it elsewhere at build().
210
219
  let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
211
- // bind/named/disk require a source; tmpfs must not have one.
212
- match kind.as_str() {
213
- "bind" | "named" | "disk" if source.is_some() => {}
214
- "bind" | "named" | "disk" => {
220
+ // bind/named/disk require a source; tmpfs must not have one. A
221
+ // bind or disk source is a host path; a named source is a volume
222
+ // name and is never resolved.
223
+ let backing = match (kind.as_str(), source) {
224
+ ("bind", Some(host)) => {
225
+ MountBacking::Bind(host_paths.pin(host, "bind mount host path")?)
226
+ }
227
+ ("disk", Some(host)) => {
228
+ MountBacking::Disk(host_paths.pin(host, "disk mount host path")?)
229
+ }
230
+ ("named", Some(name)) => MountBacking::Named(name),
231
+ ("bind" | "named" | "disk", None) => {
215
232
  return Err(error::base_error(format!(
216
233
  "volume mount kind {kind:?} requires a source"
217
234
  )))
218
235
  }
219
- "tmpfs" => {}
220
- other => {
236
+ ("tmpfs", _) => MountBacking::Tmpfs,
237
+ (other, _) => {
221
238
  return Err(error::base_error(format!(
222
239
  "unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
223
240
  )))
224
241
  }
225
- }
242
+ };
226
243
  b = b.volume(guest, move |mut mb| {
227
- mb = match kind.as_str() {
228
- "named" => mb.named(source.unwrap()),
229
- "tmpfs" => mb.tmpfs(),
230
- "disk" => mb.disk(source.unwrap()),
231
- _ => mb.bind(source.unwrap()), // "bind"
244
+ mb = match backing {
245
+ MountBacking::Named(name) => mb.named(name),
246
+ MountBacking::Tmpfs => mb.tmpfs(),
247
+ MountBacking::Disk(host) => mb.disk(host),
248
+ MountBacking::Bind(host) => mb.bind(host),
232
249
  };
233
250
  if let Some(n) = size_mib {
234
251
  mb = mb.size(n);
@@ -272,7 +289,7 @@ impl Sandbox {
272
289
  // patches: rootfs modifications applied before boot. The Ruby layer
273
290
  // normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
274
291
  // a `kind` discriminator; mirrors the Python binding's `apply_patch`.
275
- for patch in parse_patches(opts)? {
292
+ for patch in parse_patches(opts, host_paths)? {
276
293
  b = b.add_patch(patch);
277
294
  }
278
295
  if let Some(net) = conv::opt_string(opts, "network")? {
@@ -326,7 +343,7 @@ impl Sandbox {
326
343
  // layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
327
344
  // before it reaches the wire.
328
345
  if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
329
- let spec = parse_root_disk(v)?;
346
+ let spec = parse_root_disk(v, host_paths)?;
330
347
  b = b.root_disk_with(move |d| spec.apply(d));
331
348
  }
332
349
  // Registry connection settings, for private / non-default registries:
@@ -374,7 +391,7 @@ impl Sandbox {
374
391
  .map(parse_dns)
375
392
  .transpose()?;
376
393
  let tls = conv::opt::<RHash>(opts, "tls")?
377
- .map(parse_tls)
394
+ .map(|t| parse_tls(t, host_paths))
378
395
  .transpose()?;
379
396
  let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
380
397
  .map(|s| {
@@ -529,7 +546,7 @@ impl Sandbox {
529
546
  /// Create and boot a sandbox. `opts` is a string-keyed options Hash.
530
547
  fn create(name: String, opts: RHash) -> Result<Sandbox, Error> {
531
548
  let b = Self::build_builder(name, opts)?;
532
- let inner = block_on(b.create()).map_err(error::to_ruby)?;
549
+ let inner = block_on(b.create())?.map_err(error::to_ruby)?;
533
550
  Ok(Sandbox::from_inner(inner))
534
551
  }
535
552
 
@@ -542,7 +559,7 @@ impl Sandbox {
542
559
  // `create_with_pull_progress` spawns a tokio task, so it must run inside
543
560
  // the runtime context even though the call itself is synchronous.
544
561
  let (handle, join) =
545
- block_on(async move { b.create_with_pull_progress() }).map_err(error::to_ruby)?;
562
+ block_on(async move { b.create_with_pull_progress() })?.map_err(error::to_ruby)?;
546
563
  Ok(PullSession::new(handle, join))
547
564
  }
548
565
 
@@ -552,7 +569,7 @@ impl Sandbox {
552
569
  /// the combination with `replace`.
553
570
  fn connect_or_create(name: String, opts: RHash) -> Result<Sandbox, Error> {
554
571
  let b = Self::build_builder(name, opts)?;
555
- let inner = block_on(b.connect_or_create()).map_err(error::to_ruby)?;
572
+ let inner = block_on(b.connect_or_create())?.map_err(error::to_ruby)?;
556
573
  Ok(Sandbox::from_inner(inner))
557
574
  }
558
575
 
@@ -560,9 +577,9 @@ impl Sandbox {
560
577
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
561
578
  let detached = conv::opt_bool(opts, "detached")?;
562
579
  let inner = if detached {
563
- block_on(microsandbox::Sandbox::start_detached(&name)).map_err(error::to_ruby)?
580
+ block_on(microsandbox::Sandbox::start_detached(&name))?.map_err(error::to_ruby)?
564
581
  } else {
565
- block_on(microsandbox::Sandbox::start(&name)).map_err(error::to_ruby)?
582
+ block_on(microsandbox::Sandbox::start(&name))?.map_err(error::to_ruby)?
566
583
  };
567
584
  Ok(Sandbox::from_inner(inner))
568
585
  }
@@ -570,7 +587,7 @@ impl Sandbox {
570
587
  /// A controllable handle for a sandbox by name (running or not). Carries
571
588
  /// metadata accessors and the full lifecycle surface (see `SbHandle`).
572
589
  fn get(name: String) -> Result<SbHandle, Error> {
573
- let handle = block_on(microsandbox::Sandbox::get(&name)).map_err(error::to_ruby)?;
590
+ let handle = block_on(microsandbox::Sandbox::get(&name))?.map_err(error::to_ruby)?;
574
591
  Ok(SbHandle::from_inner(handle))
575
592
  }
576
593
 
@@ -590,7 +607,7 @@ impl Sandbox {
590
607
  /// First page of sandboxes (default page size), as a page Hash of
591
608
  /// controllable handles.
592
609
  fn list() -> Result<RHash, Error> {
593
- let page = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
610
+ let page = block_on(microsandbox::Sandbox::list())?.map_err(error::to_ruby)?;
594
611
  Self::page_to_hash(page)
595
612
  }
596
613
 
@@ -609,14 +626,14 @@ impl Sandbox {
609
626
  b = b.cursor(cursor);
610
627
  }
611
628
  b.labels(labels)
612
- }))
629
+ }))?
613
630
  .map_err(error::to_ruby)?;
614
631
  Self::page_to_hash(page)
615
632
  }
616
633
 
617
634
  /// Remove a (stopped) sandbox by name.
618
635
  fn remove(name: String) -> Result<(), Error> {
619
- block_on(microsandbox::Sandbox::remove(&name)).map_err(error::to_ruby)
636
+ block_on(microsandbox::Sandbox::remove(&name))?.map_err(error::to_ruby)
620
637
  }
621
638
 
622
639
  //----------------------------------------------------------------------
@@ -638,7 +655,7 @@ impl Sandbox {
638
655
  /// string-keyed Hash (cwd, user, env, timeout, tty, stdin).
639
656
  fn exec(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<RHash, Error> {
640
657
  let parsed = ExecOpts::parse(args, opts)?;
641
- let output = block_on(self.inner.exec_with(cmd, move |b| parsed.apply(b)))
658
+ let output = block_on(self.inner.exec_with(cmd, move |b| parsed.apply(b)))?
642
659
  .map_err(error::to_ruby)?;
643
660
  exec_output_to_hash(output)
644
661
  }
@@ -646,7 +663,7 @@ impl Sandbox {
646
663
  /// Run a shell script (pipes/redirects allowed).
647
664
  fn shell(&self, script: String, opts: RHash) -> Result<RHash, Error> {
648
665
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
649
- let output = block_on(self.inner.shell_with(script, move |b| parsed.apply(b)))
666
+ let output = block_on(self.inner.shell_with(script, move |b| parsed.apply(b)))?
650
667
  .map_err(error::to_ruby)?;
651
668
  exec_output_to_hash(output)
652
669
  }
@@ -659,7 +676,7 @@ impl Sandbox {
659
676
  opts: RHash,
660
677
  ) -> Result<ExecHandle, Error> {
661
678
  let parsed = ExecOpts::parse(args, opts)?;
662
- let handle = block_on(self.inner.exec_stream_with(cmd, move |b| parsed.apply(b)))
679
+ let handle = block_on(self.inner.exec_stream_with(cmd, move |b| parsed.apply(b)))?
663
680
  .map_err(error::to_ruby)?;
664
681
  Ok(ExecHandle::from_core(handle))
665
682
  }
@@ -671,7 +688,7 @@ impl Sandbox {
671
688
  /// NoDefaultCommandError when the image resolves no executable command.
672
689
  fn exec_default(&self, opts: RHash) -> Result<RHash, Error> {
673
690
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
674
- let output = block_on(self.inner.exec_default_with(move |b| parsed.apply(b)))
691
+ let output = block_on(self.inner.exec_default_with(move |b| parsed.apply(b)))?
675
692
  .map_err(error::to_ruby)?;
676
693
  exec_output_to_hash(output)
677
694
  }
@@ -682,7 +699,7 @@ impl Sandbox {
682
699
  let handle = block_on(
683
700
  self.inner
684
701
  .exec_default_stream_with(move |b| parsed.apply(b)),
685
- )
702
+ )?
686
703
  .map_err(error::to_ruby)?;
687
704
  Ok(ExecHandle::from_core(handle))
688
705
  }
@@ -693,7 +710,7 @@ impl Sandbox {
693
710
  let handle = block_on(
694
711
  self.inner
695
712
  .shell_stream_with(script, move |b| parsed.apply(b)),
696
- )
713
+ )?
697
714
  .map_err(error::to_ruby)?;
698
715
  Ok(ExecHandle::from_core(handle))
699
716
  }
@@ -714,29 +731,29 @@ impl Sandbox {
714
731
  /// Fine-grained control — a custom timeout or fire-and-return `request_*` —
715
732
  /// lives on `SandboxHandle`, obtained via `Sandbox.get`.
716
733
  fn stop(&self) -> Result<(), Error> {
717
- block_on(self.inner.stop()).map_err(error::to_ruby)
734
+ block_on(self.inner.stop())?.map_err(error::to_ruby)
718
735
  }
719
736
 
720
737
  /// Graceful stop, then wait for the process to exit. Returns an exit-status
721
738
  /// Hash (`exit_code`, `success`). Local backend only.
722
739
  fn stop_and_wait(&self) -> Result<RHash, Error> {
723
- let status = block_on(self.inner.stop_and_wait()).map_err(error::to_ruby)?;
740
+ let status = block_on(self.inner.stop_and_wait())?.map_err(error::to_ruby)?;
724
741
  Ok(exit_status_to_hash(status))
725
742
  }
726
743
 
727
744
  /// Force kill (SIGKILL).
728
745
  fn kill(&self) -> Result<(), Error> {
729
- block_on(self.inner.kill()).map_err(error::to_ruby)
746
+ block_on(self.inner.kill())?.map_err(error::to_ruby)
730
747
  }
731
748
 
732
749
  /// Trigger a graceful drain (SIGUSR1 on local).
733
750
  fn drain(&self) -> Result<(), Error> {
734
- block_on(self.inner.drain()).map_err(error::to_ruby)
751
+ block_on(self.inner.drain())?.map_err(error::to_ruby)
735
752
  }
736
753
 
737
754
  /// Wait for the process to exit. Returns an exit-status Hash. Local only.
738
755
  fn wait(&self) -> Result<RHash, Error> {
739
- let status = block_on(self.inner.wait()).map_err(error::to_ruby)?;
756
+ let status = block_on(self.inner.wait())?.map_err(error::to_ruby)?;
740
757
  Ok(exit_status_to_hash(status))
741
758
  }
742
759
 
@@ -745,7 +762,7 @@ impl Sandbox {
745
762
  /// `SandboxReplacedError` instead of silently redirecting the wait.
746
763
  fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
747
764
  let status = sandbox_status_from_str(&status)?;
748
- let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
765
+ let handle = block_on(self.inner.wait_for_status(status))?.map_err(error::to_ruby)?;
749
766
  Ok(SbHandle::from_inner(handle))
750
767
  }
751
768
 
@@ -753,19 +770,19 @@ impl Sandbox {
753
770
  /// `opts` carries `force`/`timeout`/`detached`.
754
771
  fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
755
772
  let options = restart_options(opts)?;
756
- let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
773
+ let inner = block_on(self.inner.restart_with(options))?.map_err(error::to_ruby)?;
757
774
  Ok(Sandbox::from_inner(inner))
758
775
  }
759
776
 
760
777
  /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
761
778
  fn destroy(&self, opts: RHash) -> Result<(), Error> {
762
779
  let options = destroy_options(opts)?;
763
- block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
780
+ block_on(self.inner.destroy_with(options))?.map_err(error::to_ruby)
764
781
  }
765
782
 
766
783
  /// Live status fetched from the backend (a round-trip per call).
767
784
  fn status(&self) -> Result<String, Error> {
768
- let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
785
+ let status = block_on(self.inner.status())?.map_err(error::to_ruby)?;
769
786
  Ok(sandbox_status_str(status).to_string())
770
787
  }
771
788
 
@@ -781,13 +798,13 @@ impl Sandbox {
781
798
  /// detaching a clone disarms the shared handle just the same.
782
799
  fn detach(&self) -> Result<(), Error> {
783
800
  let inner = self.inner.clone();
784
- block_on(inner.detach());
801
+ block_on(inner.detach())?;
785
802
  Ok(())
786
803
  }
787
804
 
788
805
  /// Latest metrics snapshot as a Hash.
789
806
  fn metrics(&self) -> Result<RHash, Error> {
790
- let m = block_on(self.inner.metrics()).map_err(error::to_ruby)?;
807
+ let m = block_on(self.inner.metrics())?.map_err(error::to_ruby)?;
791
808
  Ok(metrics_to_hash(&m))
792
809
  }
793
810
 
@@ -799,14 +816,14 @@ impl Sandbox {
799
816
  /// `{ name, latency_secs }` (round-trip latency as f64 seconds; the Ruby
800
817
  /// layer exposes both seconds and milliseconds).
801
818
  fn ping(&self) -> Result<RHash, Error> {
802
- let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
819
+ let result = block_on(self.inner.ping())?.map_err(error::to_ruby)?;
803
820
  Ok(ping_result_to_hash(&result))
804
821
  }
805
822
 
806
823
  /// Explicitly refresh the idle-activity timer. Returns
807
824
  /// `{ name, activity_seq }`.
808
825
  fn touch(&self) -> Result<RHash, Error> {
809
- let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
826
+ let result = block_on(self.inner.touch())?.map_err(error::to_ruby)?;
810
827
  Ok(touch_result_to_hash(&result))
811
828
  }
812
829
 
@@ -823,7 +840,7 @@ impl Sandbox {
823
840
  /// until_ms, sources (Array of "stdout"/"stderr"/"output"/"system"/"all").
824
841
  fn logs(&self, opts: RHash) -> Result<RArray, Error> {
825
842
  let log_opts = parse_log_options(opts)?;
826
- let entries = block_on(self.inner.logs(&log_opts)).map_err(error::to_ruby)?;
843
+ let entries = block_on(self.inner.logs(&log_opts))?.map_err(error::to_ruby)?;
827
844
  rhash_array(entries.iter().map(log_entry_to_hash))
828
845
  }
829
846
 
@@ -843,7 +860,7 @@ impl Sandbox {
843
860
  // which panics ("no reactor running") unless constructed inside the
844
861
  // runtime context — so build it under `block_on`. (`log_stream` is async
845
862
  // and already runs inside `block_on`, so it needs no such wrapper.)
846
- let stream = block_on(async { self.inner.metrics_stream(dur) });
863
+ let stream = block_on(async { self.inner.metrics_stream(dur) })?;
847
864
  Ok(MetricsStream::from_stream(stream))
848
865
  }
849
866
 
@@ -851,7 +868,7 @@ impl Sandbox {
851
868
  /// from_cursor, until_ms, follow. Returns a LogStream.
852
869
  fn log_stream(&self, opts: RHash) -> Result<LogStream, Error> {
853
870
  let log_opts = parse_log_stream_options(opts)?;
854
- let stream = block_on(self.inner.log_stream(&log_opts)).map_err(error::to_ruby)?;
871
+ let stream = block_on(self.inner.log_stream(&log_opts))?.map_err(error::to_ruby)?;
855
872
  Ok(LogStream::from_stream(stream))
856
873
  }
857
874
 
@@ -861,13 +878,13 @@ impl Sandbox {
861
878
 
862
879
  fn fs_read(&self, path: String) -> Result<RString, Error> {
863
880
  let fs = self.inner.fs();
864
- let bytes = block_on(fs.read(&path)).map_err(error::to_ruby)?;
881
+ let bytes = block_on(fs.read(&path))?.map_err(error::to_ruby)?;
865
882
  Ok(ruby().str_from_slice(bytes.as_ref()))
866
883
  }
867
884
 
868
885
  fn fs_read_text(&self, path: String) -> Result<String, Error> {
869
886
  let fs = self.inner.fs();
870
- block_on(fs.read_to_string(&path)).map_err(error::to_ruby)
887
+ block_on(fs.read_to_string(&path))?.map_err(error::to_ruby)
871
888
  }
872
889
 
873
890
  fn fs_write(&self, path: String, data: RString) -> Result<(), Error> {
@@ -875,72 +892,72 @@ impl Sandbox {
875
892
  // could be moved/freed by GC.compact once block_on releases it.
876
893
  let bytes = unsafe { data.as_slice() }.to_vec();
877
894
  let fs = self.inner.fs();
878
- block_on(fs.write(&path, &bytes)).map_err(error::to_ruby)
895
+ block_on(fs.write(&path, &bytes))?.map_err(error::to_ruby)
879
896
  }
880
897
 
881
898
  fn fs_list(&self, path: String) -> Result<RArray, Error> {
882
899
  let fs = self.inner.fs();
883
- let entries = block_on(fs.list(&path)).map_err(error::to_ruby)?;
900
+ let entries = block_on(fs.list(&path))?.map_err(error::to_ruby)?;
884
901
  rhash_array(entries.iter().map(fs_entry_to_hash))
885
902
  }
886
903
 
887
904
  fn fs_mkdir(&self, path: String) -> Result<(), Error> {
888
905
  let fs = self.inner.fs();
889
- block_on(fs.mkdir(&path)).map_err(error::to_ruby)
906
+ block_on(fs.mkdir(&path))?.map_err(error::to_ruby)
890
907
  }
891
908
 
892
909
  fn fs_remove(&self, path: String) -> Result<(), Error> {
893
910
  let fs = self.inner.fs();
894
- block_on(fs.remove(&path)).map_err(error::to_ruby)
911
+ block_on(fs.remove(&path))?.map_err(error::to_ruby)
895
912
  }
896
913
 
897
914
  fn fs_remove_dir(&self, path: String) -> Result<(), Error> {
898
915
  let fs = self.inner.fs();
899
- block_on(fs.remove_dir(&path)).map_err(error::to_ruby)
916
+ block_on(fs.remove_dir(&path))?.map_err(error::to_ruby)
900
917
  }
901
918
 
902
919
  fn fs_copy(&self, src: String, dst: String) -> Result<(), Error> {
903
920
  let fs = self.inner.fs();
904
- block_on(fs.copy(&src, &dst)).map_err(error::to_ruby)
921
+ block_on(fs.copy(&src, &dst))?.map_err(error::to_ruby)
905
922
  }
906
923
 
907
924
  fn fs_rename(&self, src: String, dst: String) -> Result<(), Error> {
908
925
  let fs = self.inner.fs();
909
- block_on(fs.rename(&src, &dst)).map_err(error::to_ruby)
926
+ block_on(fs.rename(&src, &dst))?.map_err(error::to_ruby)
910
927
  }
911
928
 
912
929
  fn fs_exists(&self, path: String) -> Result<bool, Error> {
913
930
  let fs = self.inner.fs();
914
- block_on(fs.exists(&path)).map_err(error::to_ruby)
931
+ block_on(fs.exists(&path))?.map_err(error::to_ruby)
915
932
  }
916
933
 
917
934
  fn fs_stat(&self, path: String) -> Result<RHash, Error> {
918
935
  let fs = self.inner.fs();
919
- let meta = block_on(fs.stat(&path)).map_err(error::to_ruby)?;
936
+ let meta = block_on(fs.stat(&path))?.map_err(error::to_ruby)?;
920
937
  Ok(fs_metadata_to_hash(&meta))
921
938
  }
922
939
 
923
940
  fn fs_copy_from_host(&self, host_path: String, guest_path: String) -> Result<(), Error> {
924
941
  let fs = self.inner.fs();
925
- block_on(fs.copy_from_host(&host_path, &guest_path)).map_err(error::to_ruby)
942
+ block_on(fs.copy_from_host(&host_path, &guest_path))?.map_err(error::to_ruby)
926
943
  }
927
944
 
928
945
  fn fs_copy_to_host(&self, guest_path: String, host_path: String) -> Result<(), Error> {
929
946
  let fs = self.inner.fs();
930
- block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
947
+ block_on(fs.copy_to_host(&guest_path, &host_path))?.map_err(error::to_ruby)
931
948
  }
932
949
 
933
950
  /// Open a streaming reader over a guest file (for files too large to buffer).
934
951
  fn fs_read_stream(&self, path: String) -> Result<crate::fs_stream::FsReadStreamHandle, Error> {
935
952
  let fs = self.inner.fs();
936
- let stream = block_on(fs.read_stream(&path)).map_err(error::to_ruby)?;
953
+ let stream = block_on(fs.read_stream(&path))?.map_err(error::to_ruby)?;
937
954
  Ok(crate::fs_stream::FsReadStreamHandle::new(stream))
938
955
  }
939
956
 
940
957
  /// Open a streaming writer to a guest file.
941
958
  fn fs_write_stream(&self, path: String) -> Result<crate::fs_stream::FsWriteSinkHandle, Error> {
942
959
  let fs = self.inner.fs();
943
- let sink = block_on(fs.write_stream(&path)).map_err(error::to_ruby)?;
960
+ let sink = block_on(fs.write_stream(&path))?.map_err(error::to_ruby)?;
944
961
  Ok(crate::fs_stream::FsWriteSinkHandle::new(sink))
945
962
  }
946
963
 
@@ -969,7 +986,7 @@ impl Sandbox {
969
986
  b = b.inactivity_timeout(t);
970
987
  }
971
988
  b.sftp(sftp)
972
- }))
989
+ }))?
973
990
  .map_err(error::to_ruby)?;
974
991
  Ok(crate::ssh::SshClient::from_core(client))
975
992
  }
@@ -1000,7 +1017,7 @@ impl Sandbox {
1000
1017
  b = b.inactivity_timeout(t);
1001
1018
  }
1002
1019
  b.sftp(sftp)
1003
- }))
1020
+ }))?
1004
1021
  .map_err(error::to_ruby)?;
1005
1022
  Ok(crate::ssh::SshServer::from_core(server))
1006
1023
  }
@@ -1015,12 +1032,12 @@ impl Sandbox {
1015
1032
  /// cwd, user, env, detach_keys, rlimits.
1016
1033
  fn attach(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<i32, Error> {
1017
1034
  let parsed = AttachOpts::parse(args, opts)?;
1018
- block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b))).map_err(error::to_ruby)
1035
+ block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b)))?.map_err(error::to_ruby)
1019
1036
  }
1020
1037
 
1021
1038
  /// Attach an interactive terminal running the sandbox's default shell.
1022
1039
  fn attach_shell(&self) -> Result<i32, Error> {
1023
- block_on(self.inner.attach_shell()).map_err(error::to_ruby)
1040
+ block_on(self.inner.attach_shell())?.map_err(error::to_ruby)
1024
1041
  }
1025
1042
 
1026
1043
  /// Attach an interactive terminal to the image's resolved OCI ENTRYPOINT
@@ -1028,7 +1045,7 @@ impl Sandbox {
1028
1045
  /// cwd, user, env, detach_keys, rlimits.
1029
1046
  fn attach_default(&self, opts: RHash) -> Result<i32, Error> {
1030
1047
  let parsed = AttachOpts::parse(Vec::new(), opts)?;
1031
- block_on(self.inner.attach_default_with(move |b| parsed.apply(b))).map_err(error::to_ruby)
1048
+ block_on(self.inner.attach_default_with(move |b| parsed.apply(b)))?.map_err(error::to_ruby)
1032
1049
  }
1033
1050
  }
1034
1051
 
@@ -1217,7 +1234,7 @@ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
1217
1234
  /// Parse the `patches` option into core `Patch` operations. The Ruby layer
1218
1235
  /// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
1219
1236
  /// `kind` discriminator plus the variant-specific fields.
1220
- fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1237
+ fn parse_patches(opts: RHash, host_paths: HostPaths) -> Result<Vec<Patch>, Error> {
1221
1238
  let mut out = Vec::new();
1222
1239
  for h in conv::opt_hash_vec(opts, "patches")? {
1223
1240
  let kind = patch_str(h, "kind")?;
@@ -1241,13 +1258,13 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1241
1258
  content: patch_str(h, "content")?,
1242
1259
  },
1243
1260
  "copy_file" => Patch::CopyFile {
1244
- src: patch_str(h, "src")?.into(),
1261
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1245
1262
  dst: patch_str(h, "dst")?,
1246
1263
  mode,
1247
1264
  replace,
1248
1265
  },
1249
1266
  "copy_dir" => Patch::CopyDir {
1250
- src: patch_str(h, "src")?.into(),
1267
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1251
1268
  dst: patch_str(h, "dst")?,
1252
1269
  replace,
1253
1270
  },
@@ -1584,20 +1601,25 @@ struct TlsSpec {
1584
1601
  verify_upstream: Option<bool>,
1585
1602
  intercepted_ports: Option<Vec<u16>>,
1586
1603
  block_quic: Option<bool>,
1587
- upstream_ca_cert: Option<String>,
1588
- intercept_ca_cert: Option<String>,
1589
- intercept_ca_key: Option<String>,
1604
+ upstream_ca_cert: Option<PathBuf>,
1605
+ intercept_ca_cert: Option<PathBuf>,
1606
+ intercept_ca_key: Option<PathBuf>,
1590
1607
  }
1591
1608
 
1592
- fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
1609
+ fn parse_tls(t: RHash, host_paths: HostPaths) -> Result<TlsSpec, Error> {
1610
+ let pin = |key: &str, label: &str| {
1611
+ conv::opt_string(t, key)?
1612
+ .map(|path| host_paths.pin(path, label))
1613
+ .transpose()
1614
+ };
1593
1615
  Ok(TlsSpec {
1594
1616
  bypass: conv::opt_string_vec(t, "bypass")?,
1595
1617
  verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
1596
1618
  intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
1597
1619
  block_quic: conv::opt::<bool>(t, "block_quic")?,
1598
- upstream_ca_cert: conv::opt_string(t, "upstream_ca_cert")?,
1599
- intercept_ca_cert: conv::opt_string(t, "intercept_ca_cert")?,
1600
- intercept_ca_key: conv::opt_string(t, "intercept_ca_key")?,
1620
+ upstream_ca_cert: pin("upstream_ca_cert", "upstream CA certificate path")?,
1621
+ intercept_ca_cert: pin("intercept_ca_cert", "interception CA certificate path")?,
1622
+ intercept_ca_key: pin("intercept_ca_key", "interception CA key path")?,
1601
1623
  })
1602
1624
  }
1603
1625
 
@@ -1711,6 +1733,14 @@ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
1711
1733
  }
1712
1734
  }
1713
1735
 
1736
+ /// What backs a volume mount, with any host path already pinned.
1737
+ enum MountBacking {
1738
+ Bind(PathBuf),
1739
+ Disk(PathBuf),
1740
+ Named(String),
1741
+ Tmpfs,
1742
+ }
1743
+
1714
1744
  /// Parsed `root_disk` create option. Integer shorthand = managed upper of N
1715
1745
  /// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
1716
1746
  /// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
@@ -1726,7 +1756,7 @@ struct RootDiskSpec {
1726
1756
  enum RootDiskKindSpec {
1727
1757
  Managed,
1728
1758
  Tmpfs,
1729
- Disk(String),
1759
+ Disk(PathBuf),
1730
1760
  Flat,
1731
1761
  }
1732
1762
 
@@ -1754,7 +1784,7 @@ impl RootDiskSpec {
1754
1784
  }
1755
1785
  }
1756
1786
 
1757
- fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1787
+ fn parse_root_disk(v: Value, host_paths: HostPaths) -> Result<RootDiskSpec, Error> {
1758
1788
  if let Ok(mib) = u32::try_convert(v) {
1759
1789
  return Ok(RootDiskSpec {
1760
1790
  kind: RootDiskKindSpec::Managed,
@@ -1777,7 +1807,7 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1777
1807
  let Some(path) = conv::opt_string(h, "path")? else {
1778
1808
  return Err(error::base_error("root_disk disk kind requires path:"));
1779
1809
  };
1780
- RootDiskKindSpec::Disk(path)
1810
+ RootDiskKindSpec::Disk(host_paths.pin(path, "root disk image path")?)
1781
1811
  }
1782
1812
  Some("flat") => RootDiskKindSpec::Flat,
1783
1813
  Some(other) => {
@@ -2343,7 +2373,7 @@ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String
2343
2373
  } else {
2344
2374
  builder.apply().await
2345
2375
  }
2346
- })
2376
+ })?
2347
2377
  .map_err(error::to_ruby)?;
2348
2378
  serde_json::to_string(&plan)
2349
2379
  .map_err(|e| error::base_error(format!("failed to serialize modification plan: {e}")))
@@ -2571,7 +2601,7 @@ impl PullSession {
2571
2601
  /// Next progress event as a Hash, or nil when the pull is finished.
2572
2602
  fn recv(&self) -> Result<Option<RHash>, Error> {
2573
2603
  let progress = Arc::clone(&self.progress);
2574
- let event = block_on(async move { progress.lock().await.recv().await });
2604
+ let event = block_on(async move { progress.lock().await.recv().await })?;
2575
2605
  Ok(event.map(|p| pull_progress_to_hash(&p)))
2576
2606
  }
2577
2607
 
@@ -2579,9 +2609,20 @@ impl PullSession {
2579
2609
  /// create task). Consumes the join handle, so it is callable only once.
2580
2610
  fn result(&self) -> Result<Sandbox, Error> {
2581
2611
  let join = Arc::clone(&self.join);
2582
- let taken = block_on(async move { join.lock().await.take() });
2583
- let handle = taken.ok_or_else(|| error::base_error("pull session result already taken"))?;
2584
- let inner = block_on(handle)
2612
+ // Take the join handle AND await it in ONE `block_on`. `block_on`
2613
+ // returns `Err` when an interrupt is delivered before it starts; as two
2614
+ // calls, an interrupt between them would drop the taken `JoinHandle`
2615
+ // (which detaches the create task rather than cancelling it) and with
2616
+ // it the only way to reach the sandbox that task boots.
2617
+ let joined = block_on(async move {
2618
+ let taken = join.lock().await.take();
2619
+ match taken {
2620
+ Some(handle) => Some(handle.await),
2621
+ None => None,
2622
+ }
2623
+ })?;
2624
+ let inner = joined
2625
+ .ok_or_else(|| error::base_error("pull session result already taken"))?
2585
2626
  .map_err(|e| error::base_error(format!("sandbox creation task failed: {e}")))?
2586
2627
  .map_err(error::to_ruby)?;
2587
2628
  Ok(Sandbox::from_inner(inner))
@@ -2633,37 +2674,37 @@ impl SbHandle {
2633
2674
 
2634
2675
  /// Graceful stop (SIGTERM→SIGKILL escalation, 10s default) and wait.
2635
2676
  fn stop(&self) -> Result<(), Error> {
2636
- block_on(self.inner.stop()).map_err(error::to_ruby)
2677
+ block_on(self.inner.stop())?.map_err(error::to_ruby)
2637
2678
  }
2638
2679
 
2639
2680
  /// Graceful stop with a custom escalation timeout (seconds).
2640
2681
  fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
2641
- block_on(self.inner.stop_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
2682
+ block_on(self.inner.stop_with_timeout(secs_to_duration(secs)?))?.map_err(error::to_ruby)
2642
2683
  }
2643
2684
 
2644
2685
  /// Force kill (SIGKILL) and wait.
2645
2686
  fn kill(&self) -> Result<(), Error> {
2646
- block_on(self.inner.kill()).map_err(error::to_ruby)
2687
+ block_on(self.inner.kill())?.map_err(error::to_ruby)
2647
2688
  }
2648
2689
 
2649
2690
  /// Force kill, waiting up to `secs` for the process to disappear.
2650
2691
  fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
2651
- block_on(self.inner.kill_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
2692
+ block_on(self.inner.kill_with_timeout(secs_to_duration(secs)?))?.map_err(error::to_ruby)
2652
2693
  }
2653
2694
 
2654
2695
  /// Send the graceful-shutdown request and return without waiting.
2655
2696
  fn request_stop(&self) -> Result<(), Error> {
2656
- block_on(self.inner.request_stop()).map_err(error::to_ruby)
2697
+ block_on(self.inner.request_stop())?.map_err(error::to_ruby)
2657
2698
  }
2658
2699
 
2659
2700
  /// Send the force-kill request and return without waiting.
2660
2701
  fn request_kill(&self) -> Result<(), Error> {
2661
- block_on(self.inner.request_kill()).map_err(error::to_ruby)
2702
+ block_on(self.inner.request_kill())?.map_err(error::to_ruby)
2662
2703
  }
2663
2704
 
2664
2705
  /// Send the drain request (SIGUSR1) and return without waiting.
2665
2706
  fn request_drain(&self) -> Result<(), Error> {
2666
- block_on(self.inner.request_drain()).map_err(error::to_ruby)
2707
+ block_on(self.inner.request_drain())?.map_err(error::to_ruby)
2667
2708
  }
2668
2709
 
2669
2710
  /// Connect when this exact sandbox is running, wait while it is starting,
@@ -2677,7 +2718,7 @@ impl SbHandle {
2677
2718
  } else {
2678
2719
  self.inner.connect_or_start().await
2679
2720
  }
2680
- })
2721
+ })?
2681
2722
  .map_err(error::to_ruby)?;
2682
2723
  Ok(Sandbox::from_inner(inner))
2683
2724
  }
@@ -2687,7 +2728,7 @@ impl SbHandle {
2687
2728
  /// `SandboxReplacedError` instead of silently redirecting the wait.
2688
2729
  fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2689
2730
  let status = sandbox_status_from_str(&status)?;
2690
- let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
2731
+ let handle = block_on(self.inner.wait_for_status(status))?.map_err(error::to_ruby)?;
2691
2732
  Ok(SbHandle::from_inner(handle))
2692
2733
  }
2693
2734
 
@@ -2695,20 +2736,20 @@ impl SbHandle {
2695
2736
  /// `opts` carries `force`/`timeout`/`detached`.
2696
2737
  fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2697
2738
  let options = restart_options(opts)?;
2698
- let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
2739
+ let inner = block_on(self.inner.restart_with(options))?.map_err(error::to_ruby)?;
2699
2740
  Ok(Sandbox::from_inner(inner))
2700
2741
  }
2701
2742
 
2702
2743
  /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2703
2744
  fn destroy(&self, opts: RHash) -> Result<(), Error> {
2704
2745
  let options = destroy_options(opts)?;
2705
- block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
2746
+ block_on(self.inner.destroy_with(options))?.map_err(error::to_ruby)
2706
2747
  }
2707
2748
 
2708
2749
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2709
2750
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2710
2751
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
2711
- let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
2752
+ let result = block_on(self.inner.wait_until_stopped())?.map_err(error::to_ruby)?;
2712
2753
  Ok(stop_result_to_hash(&result))
2713
2754
  }
2714
2755
 
@@ -2725,7 +2766,7 @@ impl SbHandle {
2725
2766
  /// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
2726
2767
  /// with `dest_dir:` for explicit placement.)
2727
2768
  fn snapshot(&self, name: String) -> Result<RHash, Error> {
2728
- let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
2769
+ let snap = block_on(self.inner.snapshot(&name))?.map_err(error::to_ruby)?;
2729
2770
  Ok(crate::snapshot::snapshot_to_hash(&snap))
2730
2771
  }
2731
2772
 
@@ -2733,14 +2774,14 @@ impl SbHandle {
2733
2774
  /// sandbox raises `SandboxNotRunningError` (the handle does not start it
2734
2775
  /// implicitly). Returns `{ name, latency_secs }`.
2735
2776
  fn ping(&self) -> Result<RHash, Error> {
2736
- let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
2777
+ let result = block_on(self.inner.ping())?.map_err(error::to_ruby)?;
2737
2778
  Ok(ping_result_to_hash(&result))
2738
2779
  }
2739
2780
 
2740
2781
  /// Explicitly refresh the idle-activity timer. A stopped sandbox raises
2741
2782
  /// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
2742
2783
  fn touch(&self) -> Result<RHash, Error> {
2743
- let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
2784
+ let result = block_on(self.inner.touch())?.map_err(error::to_ruby)?;
2744
2785
  Ok(touch_result_to_hash(&result))
2745
2786
  }
2746
2787
 
@@ -2836,6 +2877,8 @@ pub(crate) fn log_entry_to_hash(entry: &LogEntry) -> RHash {
2836
2877
 
2837
2878
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2838
2879
  let class = native.define_class("Sandbox", ruby.class_object())?;
2880
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
2881
+ crate::runtime::preload_class::<Sandbox>(ruby);
2839
2882
 
2840
2883
  class.define_singleton_method("create", function!(Sandbox::create, 2))?;
2841
2884
  class.define_singleton_method(
@@ -2909,6 +2952,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2909
2952
  class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
2910
2953
 
2911
2954
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
2955
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
2956
+ crate::runtime::preload_class::<SbHandle>(ruby);
2912
2957
  handle.define_method("name", method!(SbHandle::name, 0))?;
2913
2958
  handle.define_method("id", method!(SbHandle::id, 0))?;
2914
2959
  handle.define_method("status", method!(SbHandle::status, 0))?;
@@ -2936,6 +2981,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2936
2981
  handle.define_method("modify", method!(SbHandle::modify, 1))?;
2937
2982
 
2938
2983
  let session = native.define_class("PullSession", ruby.class_object())?;
2984
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
2985
+ crate::runtime::preload_class::<PullSession>(ruby);
2939
2986
  session.define_method("recv", method!(PullSession::recv, 0))?;
2940
2987
  session.define_method("result", method!(PullSession::result, 0))?;
2941
2988