microsandbox-rb 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,7 +41,7 @@ use crate::conv;
41
41
  use crate::error;
42
42
  use crate::exec::ExecHandle;
43
43
  use crate::host_path::{self, HostPaths};
44
- use crate::runtime::{block_on, ruby};
44
+ use crate::runtime::{block_on, ruby, ForkGuarded};
45
45
  use crate::stream::{LogStream, MetricsStream};
46
46
 
47
47
  /// Convert a seconds `f64` into a `Duration`, surfacing a clean Ruby error
@@ -61,12 +61,14 @@ fn secs_to_duration(secs: f64) -> Result<Duration, Error> {
61
61
 
62
62
  #[magnus::wrap(class = "Microsandbox::Native::Sandbox", free_immediately, size)]
63
63
  pub struct Sandbox {
64
- inner: microsandbox::Sandbox,
64
+ inner: ForkGuarded<microsandbox::Sandbox>,
65
65
  }
66
66
 
67
67
  impl Sandbox {
68
68
  fn from_inner(inner: microsandbox::Sandbox) -> Self {
69
- Self { inner }
69
+ Self {
70
+ inner: ForkGuarded::new("Microsandbox::Sandbox", inner),
71
+ }
70
72
  }
71
73
 
72
74
  //----------------------------------------------------------------------
@@ -76,10 +78,13 @@ impl Sandbox {
76
78
  /// Build a configured `SandboxBuilder` from a string-keyed options Hash.
77
79
  /// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
78
80
  fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
81
+ // `Sandbox::builder` reads the default backend synchronously (it
82
+ // overlays the local backend's global config), before any `block_on`.
83
+ crate::backend::ensure_this_process_backend()?;
79
84
  let mut b = microsandbox::Sandbox::builder(name);
80
85
  // Relative host paths are anchored to the current directory here, once,
81
86
  // before the core persists them (see host_path.rs).
82
- let host_paths = HostPaths::for_default_backend();
87
+ let host_paths = HostPaths::for_default_backend()?;
83
88
 
84
89
  if let Some(v) = conv::opt_string(opts, "image")? {
85
90
  if let Some(fstype) = conv::opt_string(opts, "fstype")? {
@@ -546,7 +551,7 @@ impl Sandbox {
546
551
  /// Create and boot a sandbox. `opts` is a string-keyed options Hash.
547
552
  fn create(name: String, opts: RHash) -> Result<Sandbox, Error> {
548
553
  let b = Self::build_builder(name, opts)?;
549
- let inner = block_on(b.create()).map_err(error::to_ruby)?;
554
+ let inner = block_on(b.create())?.map_err(error::to_ruby)?;
550
555
  Ok(Sandbox::from_inner(inner))
551
556
  }
552
557
 
@@ -559,7 +564,7 @@ impl Sandbox {
559
564
  // `create_with_pull_progress` spawns a tokio task, so it must run inside
560
565
  // the runtime context even though the call itself is synchronous.
561
566
  let (handle, join) =
562
- block_on(async move { b.create_with_pull_progress() }).map_err(error::to_ruby)?;
567
+ block_on(async move { b.create_with_pull_progress() })?.map_err(error::to_ruby)?;
563
568
  Ok(PullSession::new(handle, join))
564
569
  }
565
570
 
@@ -569,7 +574,7 @@ impl Sandbox {
569
574
  /// the combination with `replace`.
570
575
  fn connect_or_create(name: String, opts: RHash) -> Result<Sandbox, Error> {
571
576
  let b = Self::build_builder(name, opts)?;
572
- let inner = block_on(b.connect_or_create()).map_err(error::to_ruby)?;
577
+ let inner = block_on(b.connect_or_create())?.map_err(error::to_ruby)?;
573
578
  Ok(Sandbox::from_inner(inner))
574
579
  }
575
580
 
@@ -577,9 +582,9 @@ impl Sandbox {
577
582
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
578
583
  let detached = conv::opt_bool(opts, "detached")?;
579
584
  let inner = if detached {
580
- block_on(microsandbox::Sandbox::start_detached(&name)).map_err(error::to_ruby)?
585
+ block_on(microsandbox::Sandbox::start_detached(&name))?.map_err(error::to_ruby)?
581
586
  } else {
582
- block_on(microsandbox::Sandbox::start(&name)).map_err(error::to_ruby)?
587
+ block_on(microsandbox::Sandbox::start(&name))?.map_err(error::to_ruby)?
583
588
  };
584
589
  Ok(Sandbox::from_inner(inner))
585
590
  }
@@ -587,7 +592,7 @@ impl Sandbox {
587
592
  /// A controllable handle for a sandbox by name (running or not). Carries
588
593
  /// metadata accessors and the full lifecycle surface (see `SbHandle`).
589
594
  fn get(name: String) -> Result<SbHandle, Error> {
590
- let handle = block_on(microsandbox::Sandbox::get(&name)).map_err(error::to_ruby)?;
595
+ let handle = block_on(microsandbox::Sandbox::get(&name))?.map_err(error::to_ruby)?;
591
596
  Ok(SbHandle::from_inner(handle))
592
597
  }
593
598
 
@@ -607,7 +612,7 @@ impl Sandbox {
607
612
  /// First page of sandboxes (default page size), as a page Hash of
608
613
  /// controllable handles.
609
614
  fn list() -> Result<RHash, Error> {
610
- let page = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
615
+ let page = block_on(microsandbox::Sandbox::list())?.map_err(error::to_ruby)?;
611
616
  Self::page_to_hash(page)
612
617
  }
613
618
 
@@ -626,44 +631,48 @@ impl Sandbox {
626
631
  b = b.cursor(cursor);
627
632
  }
628
633
  b.labels(labels)
629
- }))
634
+ }))?
630
635
  .map_err(error::to_ruby)?;
631
636
  Self::page_to_hash(page)
632
637
  }
633
638
 
634
639
  /// Remove a (stopped) sandbox by name.
635
640
  fn remove(name: String) -> Result<(), Error> {
636
- block_on(microsandbox::Sandbox::remove(&name)).map_err(error::to_ruby)
641
+ block_on(microsandbox::Sandbox::remove(&name))?.map_err(error::to_ruby)
637
642
  }
638
643
 
639
644
  //----------------------------------------------------------------------
640
645
  // Instance methods
641
646
  //----------------------------------------------------------------------
642
647
 
648
+ /// Plain data, so (like `id`) it stays readable in a forked child, which
649
+ /// needs the name to open a handle of its own (`ForkGuarded::peek`).
643
650
  fn name(&self) -> String {
644
- self.inner.name().to_string()
651
+ self.inner.peek().name().to_string()
645
652
  }
646
653
 
647
654
  /// The opaque, backend-assigned identity of this persisted sandbox
648
655
  /// (v0.6.16). Stable for the sandbox's lifetime, and different once the
649
656
  /// same name is removed and recreated.
650
657
  fn id(&self) -> String {
651
- self.inner.id().to_string()
658
+ self.inner.peek().id().to_string()
652
659
  }
653
660
 
654
661
  /// Run a command (no shell). `args` is an Array of strings; `opts` is a
655
662
  /// string-keyed Hash (cwd, user, env, timeout, tty, stdin).
656
663
  fn exec(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<RHash, Error> {
664
+ let inner = self.inner.get()?;
657
665
  let parsed = ExecOpts::parse(args, opts)?;
658
- let output = block_on(self.inner.exec_with(cmd, move |b| parsed.apply(b)))
659
- .map_err(error::to_ruby)?;
666
+ let output =
667
+ block_on(inner.exec_with(cmd, move |b| parsed.apply(b)))?.map_err(error::to_ruby)?;
660
668
  exec_output_to_hash(output)
661
669
  }
662
670
 
663
671
  /// Run a shell script (pipes/redirects allowed).
664
672
  fn shell(&self, script: String, opts: RHash) -> Result<RHash, Error> {
673
+ let inner = self.inner.get()?;
665
674
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
666
- let output = block_on(self.inner.shell_with(script, move |b| parsed.apply(b)))
675
+ let output = block_on(inner.shell_with(script, move |b| parsed.apply(b)))?
667
676
  .map_err(error::to_ruby)?;
668
677
  exec_output_to_hash(output)
669
678
  }
@@ -675,8 +684,9 @@ impl Sandbox {
675
684
  args: Vec<String>,
676
685
  opts: RHash,
677
686
  ) -> Result<ExecHandle, Error> {
687
+ let inner = self.inner.get()?;
678
688
  let parsed = ExecOpts::parse(args, opts)?;
679
- let handle = block_on(self.inner.exec_stream_with(cmd, move |b| parsed.apply(b)))
689
+ let handle = block_on(inner.exec_stream_with(cmd, move |b| parsed.apply(b)))?
680
690
  .map_err(error::to_ruby)?;
681
691
  Ok(ExecHandle::from_core(handle))
682
692
  }
@@ -687,31 +697,28 @@ impl Sandbox {
687
697
  /// the command: cwd, user, env, timeout, tty, stdin, rlimits. Raises
688
698
  /// NoDefaultCommandError when the image resolves no executable command.
689
699
  fn exec_default(&self, opts: RHash) -> Result<RHash, Error> {
700
+ let inner = self.inner.get()?;
690
701
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
691
- let output = block_on(self.inner.exec_default_with(move |b| parsed.apply(b)))
692
- .map_err(error::to_ruby)?;
702
+ let output =
703
+ block_on(inner.exec_default_with(move |b| parsed.apply(b)))?.map_err(error::to_ruby)?;
693
704
  exec_output_to_hash(output)
694
705
  }
695
706
 
696
707
  /// Streaming default-workload execution. Returns an ExecHandle.
697
708
  fn exec_default_stream(&self, opts: RHash) -> Result<ExecHandle, Error> {
709
+ let inner = self.inner.get()?;
698
710
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
699
- let handle = block_on(
700
- self.inner
701
- .exec_default_stream_with(move |b| parsed.apply(b)),
702
- )
703
- .map_err(error::to_ruby)?;
711
+ let handle = block_on(inner.exec_default_stream_with(move |b| parsed.apply(b)))?
712
+ .map_err(error::to_ruby)?;
704
713
  Ok(ExecHandle::from_core(handle))
705
714
  }
706
715
 
707
716
  /// Streaming shell execution.
708
717
  fn shell_stream(&self, script: String, opts: RHash) -> Result<ExecHandle, Error> {
718
+ let inner = self.inner.get()?;
709
719
  let parsed = ExecOpts::parse(Vec::new(), opts)?;
710
- let handle = block_on(
711
- self.inner
712
- .shell_stream_with(script, move |b| parsed.apply(b)),
713
- )
714
- .map_err(error::to_ruby)?;
720
+ let handle = block_on(inner.shell_stream_with(script, move |b| parsed.apply(b)))?
721
+ .map_err(error::to_ruby)?;
715
722
  Ok(ExecHandle::from_core(handle))
716
723
  }
717
724
 
@@ -731,29 +738,34 @@ impl Sandbox {
731
738
  /// Fine-grained control — a custom timeout or fire-and-return `request_*` —
732
739
  /// lives on `SandboxHandle`, obtained via `Sandbox.get`.
733
740
  fn stop(&self) -> Result<(), Error> {
734
- block_on(self.inner.stop()).map_err(error::to_ruby)
741
+ let inner = self.inner.get()?;
742
+ block_on(inner.stop())?.map_err(error::to_ruby)
735
743
  }
736
744
 
737
745
  /// Graceful stop, then wait for the process to exit. Returns an exit-status
738
746
  /// Hash (`exit_code`, `success`). Local backend only.
739
747
  fn stop_and_wait(&self) -> Result<RHash, Error> {
740
- let status = block_on(self.inner.stop_and_wait()).map_err(error::to_ruby)?;
748
+ let inner = self.inner.get()?;
749
+ let status = block_on(inner.stop_and_wait())?.map_err(error::to_ruby)?;
741
750
  Ok(exit_status_to_hash(status))
742
751
  }
743
752
 
744
753
  /// Force kill (SIGKILL).
745
754
  fn kill(&self) -> Result<(), Error> {
746
- block_on(self.inner.kill()).map_err(error::to_ruby)
755
+ let inner = self.inner.get()?;
756
+ block_on(inner.kill())?.map_err(error::to_ruby)
747
757
  }
748
758
 
749
759
  /// Trigger a graceful drain (SIGUSR1 on local).
750
760
  fn drain(&self) -> Result<(), Error> {
751
- block_on(self.inner.drain()).map_err(error::to_ruby)
761
+ let inner = self.inner.get()?;
762
+ block_on(inner.drain())?.map_err(error::to_ruby)
752
763
  }
753
764
 
754
765
  /// Wait for the process to exit. Returns an exit-status Hash. Local only.
755
766
  fn wait(&self) -> Result<RHash, Error> {
756
- let status = block_on(self.inner.wait()).map_err(error::to_ruby)?;
767
+ let inner = self.inner.get()?;
768
+ let status = block_on(inner.wait())?.map_err(error::to_ruby)?;
757
769
  Ok(exit_status_to_hash(status))
758
770
  }
759
771
 
@@ -761,35 +773,42 @@ impl Sandbox {
761
773
  /// returning a fresh handle. A same-name replacement raises
762
774
  /// `SandboxReplacedError` instead of silently redirecting the wait.
763
775
  fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
776
+ let inner = self.inner.get()?;
764
777
  let status = sandbox_status_from_str(&status)?;
765
- let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
778
+ let handle = block_on(inner.wait_for_status(status))?.map_err(error::to_ruby)?;
766
779
  Ok(SbHandle::from_inner(handle))
767
780
  }
768
781
 
769
782
  /// Stop and start this exact sandbox, returning the new live sandbox.
770
783
  /// `opts` carries `force`/`timeout`/`detached`.
771
784
  fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
785
+ let inner = self.inner.get()?;
772
786
  let options = restart_options(opts)?;
773
- let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
787
+ let inner = block_on(inner.restart_with(options))?.map_err(error::to_ruby)?;
774
788
  Ok(Sandbox::from_inner(inner))
775
789
  }
776
790
 
777
791
  /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
778
792
  fn destroy(&self, opts: RHash) -> Result<(), Error> {
793
+ let inner = self.inner.get()?;
779
794
  let options = destroy_options(opts)?;
780
- block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
795
+ block_on(inner.destroy_with(options))?.map_err(error::to_ruby)
781
796
  }
782
797
 
783
798
  /// Live status fetched from the backend (a round-trip per call).
784
799
  fn status(&self) -> Result<String, Error> {
785
- let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
800
+ let inner = self.inner.get()?;
801
+ let status = block_on(inner.status())?.map_err(error::to_ruby)?;
786
802
  Ok(sandbox_status_str(status).to_string())
787
803
  }
788
804
 
789
805
  /// Whether this handle owns the sandbox process lifecycle (a synchronous,
790
- /// local predicate — no runtime round-trip).
791
- fn owns_lifecycle(&self) -> bool {
792
- self.inner.owns_lifecycle()
806
+ /// local predicate — no runtime round-trip). Process-checked even though
807
+ /// it only reads memory: in a forked child the inherited answer would be
808
+ /// wrong (the parent owns the sandbox), so it raises like the lifecycle
809
+ /// calls it guards.
810
+ fn owns_lifecycle(&self) -> Result<bool, Error> {
811
+ Ok(self.inner.get()?.owns_lifecycle())
793
812
  }
794
813
 
795
814
  /// Disarm the SIGTERM safety net so the sandbox keeps running after this
@@ -797,14 +816,15 @@ impl Sandbox {
797
816
  /// `Sandbox` is `Clone` (Arc-backed, sharing the same process handle), so
798
817
  /// detaching a clone disarms the shared handle just the same.
799
818
  fn detach(&self) -> Result<(), Error> {
800
- let inner = self.inner.clone();
801
- block_on(inner.detach());
819
+ let inner = self.inner.get()?.clone();
820
+ block_on(inner.detach())?;
802
821
  Ok(())
803
822
  }
804
823
 
805
824
  /// Latest metrics snapshot as a Hash.
806
825
  fn metrics(&self) -> Result<RHash, Error> {
807
- let m = block_on(self.inner.metrics()).map_err(error::to_ruby)?;
826
+ let inner = self.inner.get()?;
827
+ let m = block_on(inner.metrics())?.map_err(error::to_ruby)?;
808
828
  Ok(metrics_to_hash(&m))
809
829
  }
810
830
 
@@ -816,14 +836,16 @@ impl Sandbox {
816
836
  /// `{ name, latency_secs }` (round-trip latency as f64 seconds; the Ruby
817
837
  /// layer exposes both seconds and milliseconds).
818
838
  fn ping(&self) -> Result<RHash, Error> {
819
- let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
839
+ let inner = self.inner.get()?;
840
+ let result = block_on(inner.ping())?.map_err(error::to_ruby)?;
820
841
  Ok(ping_result_to_hash(&result))
821
842
  }
822
843
 
823
844
  /// Explicitly refresh the idle-activity timer. Returns
824
845
  /// `{ name, activity_seq }`.
825
846
  fn touch(&self) -> Result<RHash, Error> {
826
- let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
847
+ let inner = self.inner.get()?;
848
+ let result = block_on(inner.touch())?.map_err(error::to_ruby)?;
827
849
  Ok(touch_result_to_hash(&result))
828
850
  }
829
851
 
@@ -833,20 +855,23 @@ impl Sandbox {
833
855
  /// into a `ModificationPlan`. Mirrors the Node native binding (native returns
834
856
  /// JSON, the wrapper shapes it).
835
857
  fn modify(&self, opts: RHash) -> Result<String, Error> {
836
- run_modify(self.inner.modify(), opts)
858
+ let inner = self.inner.get()?;
859
+ run_modify(inner.modify(), opts)
837
860
  }
838
861
 
839
862
  /// Read captured logs as an Array of Hashes. `opts`: tail, since_ms,
840
863
  /// until_ms, sources (Array of "stdout"/"stderr"/"output"/"system"/"all").
841
864
  fn logs(&self, opts: RHash) -> Result<RArray, Error> {
865
+ let inner = self.inner.get()?;
842
866
  let log_opts = parse_log_options(opts)?;
843
- let entries = block_on(self.inner.logs(&log_opts)).map_err(error::to_ruby)?;
867
+ let entries = block_on(inner.logs(&log_opts))?.map_err(error::to_ruby)?;
844
868
  rhash_array(entries.iter().map(log_entry_to_hash))
845
869
  }
846
870
 
847
871
  /// Stream metrics snapshots at `interval` seconds. Returns a MetricsStream
848
872
  /// to pull snapshots from.
849
873
  fn metrics_stream(&self, interval: f64) -> Result<MetricsStream, Error> {
874
+ let inner = self.inner.get()?;
850
875
  // `interval <= 0.0` (0 or negative) keeps the prior 1s-default behavior;
851
876
  // NaN (for which `<= 0.0` is false) and finite-but-out-of-range values
852
877
  // fall through to `secs_to_duration`, which errors cleanly rather than
@@ -860,15 +885,16 @@ impl Sandbox {
860
885
  // which panics ("no reactor running") unless constructed inside the
861
886
  // runtime context — so build it under `block_on`. (`log_stream` is async
862
887
  // and already runs inside `block_on`, so it needs no such wrapper.)
863
- let stream = block_on(async { self.inner.metrics_stream(dur) });
888
+ let stream = block_on(async { inner.metrics_stream(dur) })?;
864
889
  Ok(MetricsStream::from_stream(stream))
865
890
  }
866
891
 
867
892
  /// Stream captured logs as they appear. `opts`: sources, since_ms,
868
893
  /// from_cursor, until_ms, follow. Returns a LogStream.
869
894
  fn log_stream(&self, opts: RHash) -> Result<LogStream, Error> {
895
+ let inner = self.inner.get()?;
870
896
  let log_opts = parse_log_stream_options(opts)?;
871
- let stream = block_on(self.inner.log_stream(&log_opts)).map_err(error::to_ruby)?;
897
+ let stream = block_on(inner.log_stream(&log_opts))?.map_err(error::to_ruby)?;
872
898
  Ok(LogStream::from_stream(stream))
873
899
  }
874
900
 
@@ -877,87 +903,102 @@ impl Sandbox {
877
903
  //----------------------------------------------------------------------
878
904
 
879
905
  fn fs_read(&self, path: String) -> Result<RString, Error> {
880
- let fs = self.inner.fs();
881
- let bytes = block_on(fs.read(&path)).map_err(error::to_ruby)?;
906
+ let inner = self.inner.get()?;
907
+ let fs = inner.fs();
908
+ let bytes = block_on(fs.read(&path))?.map_err(error::to_ruby)?;
882
909
  Ok(ruby().str_from_slice(bytes.as_ref()))
883
910
  }
884
911
 
885
912
  fn fs_read_text(&self, path: String) -> Result<String, Error> {
886
- let fs = self.inner.fs();
887
- block_on(fs.read_to_string(&path)).map_err(error::to_ruby)
913
+ let inner = self.inner.get()?;
914
+ let fs = inner.fs();
915
+ block_on(fs.read_to_string(&path))?.map_err(error::to_ruby)
888
916
  }
889
917
 
890
918
  fn fs_write(&self, path: String, data: RString) -> Result<(), Error> {
919
+ let inner = self.inner.get()?;
891
920
  // Copy out of the Ruby string while we still hold the GVL: the buffer
892
921
  // could be moved/freed by GC.compact once block_on releases it.
893
922
  let bytes = unsafe { data.as_slice() }.to_vec();
894
- let fs = self.inner.fs();
895
- block_on(fs.write(&path, &bytes)).map_err(error::to_ruby)
923
+ let fs = inner.fs();
924
+ block_on(fs.write(&path, &bytes))?.map_err(error::to_ruby)
896
925
  }
897
926
 
898
927
  fn fs_list(&self, path: String) -> Result<RArray, Error> {
899
- let fs = self.inner.fs();
900
- let entries = block_on(fs.list(&path)).map_err(error::to_ruby)?;
928
+ let inner = self.inner.get()?;
929
+ let fs = inner.fs();
930
+ let entries = block_on(fs.list(&path))?.map_err(error::to_ruby)?;
901
931
  rhash_array(entries.iter().map(fs_entry_to_hash))
902
932
  }
903
933
 
904
934
  fn fs_mkdir(&self, path: String) -> Result<(), Error> {
905
- let fs = self.inner.fs();
906
- block_on(fs.mkdir(&path)).map_err(error::to_ruby)
935
+ let inner = self.inner.get()?;
936
+ let fs = inner.fs();
937
+ block_on(fs.mkdir(&path))?.map_err(error::to_ruby)
907
938
  }
908
939
 
909
940
  fn fs_remove(&self, path: String) -> Result<(), Error> {
910
- let fs = self.inner.fs();
911
- block_on(fs.remove(&path)).map_err(error::to_ruby)
941
+ let inner = self.inner.get()?;
942
+ let fs = inner.fs();
943
+ block_on(fs.remove(&path))?.map_err(error::to_ruby)
912
944
  }
913
945
 
914
946
  fn fs_remove_dir(&self, path: String) -> Result<(), Error> {
915
- let fs = self.inner.fs();
916
- block_on(fs.remove_dir(&path)).map_err(error::to_ruby)
947
+ let inner = self.inner.get()?;
948
+ let fs = inner.fs();
949
+ block_on(fs.remove_dir(&path))?.map_err(error::to_ruby)
917
950
  }
918
951
 
919
952
  fn fs_copy(&self, src: String, dst: String) -> Result<(), Error> {
920
- let fs = self.inner.fs();
921
- block_on(fs.copy(&src, &dst)).map_err(error::to_ruby)
953
+ let inner = self.inner.get()?;
954
+ let fs = inner.fs();
955
+ block_on(fs.copy(&src, &dst))?.map_err(error::to_ruby)
922
956
  }
923
957
 
924
958
  fn fs_rename(&self, src: String, dst: String) -> Result<(), Error> {
925
- let fs = self.inner.fs();
926
- block_on(fs.rename(&src, &dst)).map_err(error::to_ruby)
959
+ let inner = self.inner.get()?;
960
+ let fs = inner.fs();
961
+ block_on(fs.rename(&src, &dst))?.map_err(error::to_ruby)
927
962
  }
928
963
 
929
964
  fn fs_exists(&self, path: String) -> Result<bool, Error> {
930
- let fs = self.inner.fs();
931
- block_on(fs.exists(&path)).map_err(error::to_ruby)
965
+ let inner = self.inner.get()?;
966
+ let fs = inner.fs();
967
+ block_on(fs.exists(&path))?.map_err(error::to_ruby)
932
968
  }
933
969
 
934
970
  fn fs_stat(&self, path: String) -> Result<RHash, Error> {
935
- let fs = self.inner.fs();
936
- let meta = block_on(fs.stat(&path)).map_err(error::to_ruby)?;
971
+ let inner = self.inner.get()?;
972
+ let fs = inner.fs();
973
+ let meta = block_on(fs.stat(&path))?.map_err(error::to_ruby)?;
937
974
  Ok(fs_metadata_to_hash(&meta))
938
975
  }
939
976
 
940
977
  fn fs_copy_from_host(&self, host_path: String, guest_path: String) -> Result<(), Error> {
941
- let fs = self.inner.fs();
942
- block_on(fs.copy_from_host(&host_path, &guest_path)).map_err(error::to_ruby)
978
+ let inner = self.inner.get()?;
979
+ let fs = inner.fs();
980
+ block_on(fs.copy_from_host(&host_path, &guest_path))?.map_err(error::to_ruby)
943
981
  }
944
982
 
945
983
  fn fs_copy_to_host(&self, guest_path: String, host_path: String) -> Result<(), Error> {
946
- let fs = self.inner.fs();
947
- block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
984
+ let inner = self.inner.get()?;
985
+ let fs = inner.fs();
986
+ block_on(fs.copy_to_host(&guest_path, &host_path))?.map_err(error::to_ruby)
948
987
  }
949
988
 
950
989
  /// Open a streaming reader over a guest file (for files too large to buffer).
951
990
  fn fs_read_stream(&self, path: String) -> Result<crate::fs_stream::FsReadStreamHandle, Error> {
952
- let fs = self.inner.fs();
953
- let stream = block_on(fs.read_stream(&path)).map_err(error::to_ruby)?;
991
+ let inner = self.inner.get()?;
992
+ let fs = inner.fs();
993
+ let stream = block_on(fs.read_stream(&path))?.map_err(error::to_ruby)?;
954
994
  Ok(crate::fs_stream::FsReadStreamHandle::new(stream))
955
995
  }
956
996
 
957
997
  /// Open a streaming writer to a guest file.
958
998
  fn fs_write_stream(&self, path: String) -> Result<crate::fs_stream::FsWriteSinkHandle, Error> {
959
- let fs = self.inner.fs();
960
- let sink = block_on(fs.write_stream(&path)).map_err(error::to_ruby)?;
999
+ let inner = self.inner.get()?;
1000
+ let fs = inner.fs();
1001
+ let sink = block_on(fs.write_stream(&path))?.map_err(error::to_ruby)?;
961
1002
  Ok(crate::fs_stream::FsWriteSinkHandle::new(sink))
962
1003
  }
963
1004
 
@@ -968,13 +1009,14 @@ impl Sandbox {
968
1009
  /// Open a native in-process SSH client to this sandbox. `opts`: user, term,
969
1010
  /// sftp (bool, default true), inactivity_timeout (seconds, f64; 0 disables).
970
1011
  fn ssh_open_client(&self, opts: RHash) -> Result<crate::ssh::SshClient, Error> {
1012
+ let inner = self.inner.get()?;
971
1013
  let user = conv::opt_string(opts, "user")?;
972
1014
  let term = conv::opt_string(opts, "term")?;
973
1015
  let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
974
1016
  let inactivity_timeout = conv::opt_f64(opts, "inactivity_timeout")?
975
1017
  .map(secs_to_duration)
976
1018
  .transpose()?;
977
- let ssh = self.inner.ssh();
1019
+ let ssh = inner.ssh();
978
1020
  let client = block_on(ssh.open_client_with(move |mut b| {
979
1021
  if let Some(u) = user {
980
1022
  b = b.user(u);
@@ -986,7 +1028,7 @@ impl Sandbox {
986
1028
  b = b.inactivity_timeout(t);
987
1029
  }
988
1030
  b.sftp(sftp)
989
- }))
1031
+ }))?
990
1032
  .map_err(error::to_ruby)?;
991
1033
  Ok(crate::ssh::SshClient::from_core(client))
992
1034
  }
@@ -995,6 +1037,7 @@ impl Sandbox {
995
1037
  /// authorized_keys_path, user, sftp (bool, default true),
996
1038
  /// inactivity_timeout (seconds, f64; 0 disables).
997
1039
  fn ssh_prepare_server(&self, opts: RHash) -> Result<crate::ssh::SshServer, Error> {
1040
+ let inner = self.inner.get()?;
998
1041
  let host_key_path = conv::opt_string(opts, "host_key_path")?;
999
1042
  let authorized_keys_path = conv::opt_string(opts, "authorized_keys_path")?;
1000
1043
  let user = conv::opt_string(opts, "user")?;
@@ -1002,7 +1045,7 @@ impl Sandbox {
1002
1045
  let inactivity_timeout = conv::opt_f64(opts, "inactivity_timeout")?
1003
1046
  .map(secs_to_duration)
1004
1047
  .transpose()?;
1005
- let ssh = self.inner.ssh();
1048
+ let ssh = inner.ssh();
1006
1049
  let server = block_on(ssh.prepare_server_with(move |mut b| {
1007
1050
  if let Some(p) = host_key_path {
1008
1051
  b = b.host_key_path(p);
@@ -1017,7 +1060,7 @@ impl Sandbox {
1017
1060
  b = b.inactivity_timeout(t);
1018
1061
  }
1019
1062
  b.sftp(sftp)
1020
- }))
1063
+ }))?
1021
1064
  .map_err(error::to_ruby)?;
1022
1065
  Ok(crate::ssh::SshServer::from_core(server))
1023
1066
  }
@@ -1031,21 +1074,24 @@ impl Sandbox {
1031
1074
  /// blocks until the command exits or the detach sequence is typed. `opts`:
1032
1075
  /// cwd, user, env, detach_keys, rlimits.
1033
1076
  fn attach(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<i32, Error> {
1077
+ let inner = self.inner.get()?;
1034
1078
  let parsed = AttachOpts::parse(args, opts)?;
1035
- block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b))).map_err(error::to_ruby)
1079
+ block_on(inner.attach_with(cmd, move |b| parsed.apply(b)))?.map_err(error::to_ruby)
1036
1080
  }
1037
1081
 
1038
1082
  /// Attach an interactive terminal running the sandbox's default shell.
1039
1083
  fn attach_shell(&self) -> Result<i32, Error> {
1040
- block_on(self.inner.attach_shell()).map_err(error::to_ruby)
1084
+ let inner = self.inner.get()?;
1085
+ block_on(inner.attach_shell())?.map_err(error::to_ruby)
1041
1086
  }
1042
1087
 
1043
1088
  /// Attach an interactive terminal to the image's resolved OCI ENTRYPOINT
1044
1089
  /// and CMD (the default workload, v0.6.9); returns its exit code. `opts`:
1045
1090
  /// cwd, user, env, detach_keys, rlimits.
1046
1091
  fn attach_default(&self, opts: RHash) -> Result<i32, Error> {
1092
+ let inner = self.inner.get()?;
1047
1093
  let parsed = AttachOpts::parse(Vec::new(), opts)?;
1048
- block_on(self.inner.attach_default_with(move |b| parsed.apply(b))).map_err(error::to_ruby)
1094
+ block_on(inner.attach_default_with(move |b| parsed.apply(b)))?.map_err(error::to_ruby)
1049
1095
  }
1050
1096
  }
1051
1097
 
@@ -2373,7 +2419,7 @@ fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String
2373
2419
  } else {
2374
2420
  builder.apply().await
2375
2421
  }
2376
- })
2422
+ })?
2377
2423
  .map_err(error::to_ruby)?;
2378
2424
  serde_json::to_string(&plan)
2379
2425
  .map_err(|e| error::base_error(format!("failed to serialize modification plan: {e}")))
@@ -2583,6 +2629,12 @@ fn pull_progress_to_hash(p: &PullProgress) -> RHash {
2583
2629
  /// handle is consumed once, so both sit behind a `tokio::Mutex`.
2584
2630
  #[magnus::wrap(class = "Microsandbox::Native::PullSession", free_immediately, size)]
2585
2631
  pub struct PullSession {
2632
+ inner: ForkGuarded<PullParts>,
2633
+ }
2634
+
2635
+ /// The two halves of a pull session; both belong to the creating process's
2636
+ /// runtime (a channel fed by, and the join handle of, a task spawned on it).
2637
+ struct PullParts {
2586
2638
  progress: Arc<Mutex<PullProgressHandle>>,
2587
2639
  join: Arc<Mutex<Option<JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>>>>,
2588
2640
  }
@@ -2593,25 +2645,41 @@ impl PullSession {
2593
2645
  join: JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>,
2594
2646
  ) -> Self {
2595
2647
  Self {
2596
- progress: Arc::new(Mutex::new(progress)),
2597
- join: Arc::new(Mutex::new(Some(join))),
2648
+ inner: ForkGuarded::new(
2649
+ "Microsandbox::PullSession",
2650
+ PullParts {
2651
+ progress: Arc::new(Mutex::new(progress)),
2652
+ join: Arc::new(Mutex::new(Some(join))),
2653
+ },
2654
+ ),
2598
2655
  }
2599
2656
  }
2600
2657
 
2601
2658
  /// Next progress event as a Hash, or nil when the pull is finished.
2602
2659
  fn recv(&self) -> Result<Option<RHash>, Error> {
2603
- let progress = Arc::clone(&self.progress);
2604
- let event = block_on(async move { progress.lock().await.recv().await });
2660
+ let progress = Arc::clone(&self.inner.get()?.progress);
2661
+ let event = block_on(async move { progress.lock().await.recv().await })?;
2605
2662
  Ok(event.map(|p| pull_progress_to_hash(&p)))
2606
2663
  }
2607
2664
 
2608
2665
  /// Await the booted sandbox. Call after draining `recv` (it joins the
2609
2666
  /// create task). Consumes the join handle, so it is callable only once.
2610
2667
  fn result(&self) -> Result<Sandbox, Error> {
2611
- let join = Arc::clone(&self.join);
2612
- let taken = block_on(async move { join.lock().await.take() });
2613
- let handle = taken.ok_or_else(|| error::base_error("pull session result already taken"))?;
2614
- let inner = block_on(handle)
2668
+ let join = Arc::clone(&self.inner.get()?.join);
2669
+ // Take the join handle AND await it in ONE `block_on`. `block_on`
2670
+ // returns `Err` when an interrupt is delivered before it starts; as two
2671
+ // calls, an interrupt between them would drop the taken `JoinHandle`
2672
+ // (which detaches the create task rather than cancelling it) and with
2673
+ // it the only way to reach the sandbox that task boots.
2674
+ let joined = block_on(async move {
2675
+ let taken = join.lock().await.take();
2676
+ match taken {
2677
+ Some(handle) => Some(handle.await),
2678
+ None => None,
2679
+ }
2680
+ })?;
2681
+ let inner = joined
2682
+ .ok_or_else(|| error::base_error("pull session result already taken"))?
2615
2683
  .map_err(|e| error::base_error(format!("sandbox creation task failed: {e}")))?
2616
2684
  .map_err(error::to_ruby)?;
2617
2685
  Ok(Sandbox::from_inner(inner))
@@ -2629,85 +2697,104 @@ impl PullSession {
2629
2697
  /// a synchronous snapshot read off the handle (no round-trip).
2630
2698
  #[magnus::wrap(class = "Microsandbox::Native::SandboxHandle", free_immediately, size)]
2631
2699
  pub struct SbHandle {
2632
- inner: SandboxHandle,
2700
+ inner: ForkGuarded<SandboxHandle>,
2633
2701
  }
2634
2702
 
2635
2703
  impl SbHandle {
2636
2704
  fn from_inner(inner: SandboxHandle) -> Self {
2637
- Self { inner }
2705
+ Self {
2706
+ inner: ForkGuarded::new("Microsandbox::SandboxHandle", inner),
2707
+ }
2638
2708
  }
2639
2709
 
2710
+ /// Plain data — like every other snapshot accessor here (`id`, `status`,
2711
+ /// the timestamps, `config_json`) it stays readable in a forked child
2712
+ /// (`ForkGuarded::peek`); the lifecycle operations do not.
2640
2713
  fn name(&self) -> String {
2641
- self.inner.name().to_string()
2714
+ self.inner.peek().name().to_string()
2642
2715
  }
2643
2716
 
2644
2717
  /// The opaque, backend-assigned identity of the persisted sandbox this
2645
2718
  /// handle is bound to (v0.6.16). The lifecycle operations below refuse to
2646
2719
  /// act on a same-name replacement by comparing against it.
2647
2720
  fn id(&self) -> String {
2648
- self.inner.id().to_string()
2721
+ self.inner.peek().id().to_string()
2649
2722
  }
2650
2723
 
2651
2724
  /// Status snapshot captured when the handle was fetched (synchronous).
2652
2725
  fn status(&self) -> String {
2653
- sandbox_status_str(self.inner.status_snapshot()).to_string()
2726
+ sandbox_status_str(self.inner.peek().status_snapshot()).to_string()
2654
2727
  }
2655
2728
 
2656
2729
  fn created_at_ms(&self) -> Option<i64> {
2657
- self.inner.created_at().map(|dt| dt.timestamp_millis())
2730
+ self.inner
2731
+ .peek()
2732
+ .created_at()
2733
+ .map(|dt| dt.timestamp_millis())
2658
2734
  }
2659
2735
 
2660
2736
  fn updated_at_ms(&self) -> Option<i64> {
2661
- self.inner.updated_at().map(|dt| dt.timestamp_millis())
2737
+ self.inner
2738
+ .peek()
2739
+ .updated_at()
2740
+ .map(|dt| dt.timestamp_millis())
2662
2741
  }
2663
2742
 
2664
2743
  /// Graceful stop (SIGTERM→SIGKILL escalation, 10s default) and wait.
2665
2744
  fn stop(&self) -> Result<(), Error> {
2666
- block_on(self.inner.stop()).map_err(error::to_ruby)
2745
+ let inner = self.inner.get()?;
2746
+ block_on(inner.stop())?.map_err(error::to_ruby)
2667
2747
  }
2668
2748
 
2669
2749
  /// Graceful stop with a custom escalation timeout (seconds).
2670
2750
  fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
2671
- block_on(self.inner.stop_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
2751
+ let inner = self.inner.get()?;
2752
+ block_on(inner.stop_with_timeout(secs_to_duration(secs)?))?.map_err(error::to_ruby)
2672
2753
  }
2673
2754
 
2674
2755
  /// Force kill (SIGKILL) and wait.
2675
2756
  fn kill(&self) -> Result<(), Error> {
2676
- block_on(self.inner.kill()).map_err(error::to_ruby)
2757
+ let inner = self.inner.get()?;
2758
+ block_on(inner.kill())?.map_err(error::to_ruby)
2677
2759
  }
2678
2760
 
2679
2761
  /// Force kill, waiting up to `secs` for the process to disappear.
2680
2762
  fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
2681
- block_on(self.inner.kill_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
2763
+ let inner = self.inner.get()?;
2764
+ block_on(inner.kill_with_timeout(secs_to_duration(secs)?))?.map_err(error::to_ruby)
2682
2765
  }
2683
2766
 
2684
2767
  /// Send the graceful-shutdown request and return without waiting.
2685
2768
  fn request_stop(&self) -> Result<(), Error> {
2686
- block_on(self.inner.request_stop()).map_err(error::to_ruby)
2769
+ let inner = self.inner.get()?;
2770
+ block_on(inner.request_stop())?.map_err(error::to_ruby)
2687
2771
  }
2688
2772
 
2689
2773
  /// Send the force-kill request and return without waiting.
2690
2774
  fn request_kill(&self) -> Result<(), Error> {
2691
- block_on(self.inner.request_kill()).map_err(error::to_ruby)
2775
+ let inner = self.inner.get()?;
2776
+ block_on(inner.request_kill())?.map_err(error::to_ruby)
2692
2777
  }
2693
2778
 
2694
2779
  /// Send the drain request (SIGUSR1) and return without waiting.
2695
2780
  fn request_drain(&self) -> Result<(), Error> {
2696
- block_on(self.inner.request_drain()).map_err(error::to_ruby)
2781
+ let inner = self.inner.get()?;
2782
+ block_on(inner.request_drain())?.map_err(error::to_ruby)
2697
2783
  }
2698
2784
 
2699
2785
  /// Connect when this exact sandbox is running, wait while it is starting,
2700
2786
  /// or start it when it is created/stopped/crashed. `opts` carries
2701
2787
  /// `detached`, which applies only when a start is actually required.
2702
2788
  fn connect_or_start(&self, opts: RHash) -> Result<Sandbox, Error> {
2789
+ let inner = self.inner.get()?;
2703
2790
  let detached = conv::opt_bool(opts, "detached")?;
2704
2791
  let inner = block_on(async {
2705
2792
  if detached {
2706
- self.inner.connect_or_start_detached().await
2793
+ inner.connect_or_start_detached().await
2707
2794
  } else {
2708
- self.inner.connect_or_start().await
2795
+ inner.connect_or_start().await
2709
2796
  }
2710
- })
2797
+ })?
2711
2798
  .map_err(error::to_ruby)?;
2712
2799
  Ok(Sandbox::from_inner(inner))
2713
2800
  }
@@ -2716,29 +2803,33 @@ impl SbHandle {
2716
2803
  /// returning a fresh handle. A same-name replacement raises
2717
2804
  /// `SandboxReplacedError` instead of silently redirecting the wait.
2718
2805
  fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2806
+ let inner = self.inner.get()?;
2719
2807
  let status = sandbox_status_from_str(&status)?;
2720
- let handle = block_on(self.inner.wait_for_status(status)).map_err(error::to_ruby)?;
2808
+ let handle = block_on(inner.wait_for_status(status))?.map_err(error::to_ruby)?;
2721
2809
  Ok(SbHandle::from_inner(handle))
2722
2810
  }
2723
2811
 
2724
2812
  /// Stop and start this exact sandbox, returning the new live sandbox.
2725
2813
  /// `opts` carries `force`/`timeout`/`detached`.
2726
2814
  fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2815
+ let inner = self.inner.get()?;
2727
2816
  let options = restart_options(opts)?;
2728
- let inner = block_on(self.inner.restart_with(options)).map_err(error::to_ruby)?;
2817
+ let inner = block_on(inner.restart_with(options))?.map_err(error::to_ruby)?;
2729
2818
  Ok(Sandbox::from_inner(inner))
2730
2819
  }
2731
2820
 
2732
2821
  /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2733
2822
  fn destroy(&self, opts: RHash) -> Result<(), Error> {
2823
+ let inner = self.inner.get()?;
2734
2824
  let options = destroy_options(opts)?;
2735
- block_on(self.inner.destroy_with(options)).map_err(error::to_ruby)
2825
+ block_on(inner.destroy_with(options))?.map_err(error::to_ruby)
2736
2826
  }
2737
2827
 
2738
2828
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2739
2829
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2740
2830
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
2741
- let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
2831
+ let inner = self.inner.get()?;
2832
+ let result = block_on(inner.wait_until_stopped())?.map_err(error::to_ruby)?;
2742
2833
  Ok(stop_result_to_hash(&result))
2743
2834
  }
2744
2835
 
@@ -2746,7 +2837,7 @@ impl SbHandle {
2746
2837
  /// handle already carries it, no runtime round-trip). The Ruby layer parses
2747
2838
  /// it into a Hash for `#config`. Mirrors the Python/Node `config_json`.
2748
2839
  fn config_json(&self) -> String {
2749
- self.inner.config_json().to_string()
2840
+ self.inner.peek().config_json().to_string()
2750
2841
  }
2751
2842
 
2752
2843
  /// Snapshot this (stopped) sandbox under a bare name (resolved under the
@@ -2755,7 +2846,8 @@ impl SbHandle {
2755
2846
  /// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
2756
2847
  /// with `dest_dir:` for explicit placement.)
2757
2848
  fn snapshot(&self, name: String) -> Result<RHash, Error> {
2758
- let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
2849
+ let inner = self.inner.get()?;
2850
+ let snap = block_on(inner.snapshot(&name))?.map_err(error::to_ruby)?;
2759
2851
  Ok(crate::snapshot::snapshot_to_hash(&snap))
2760
2852
  }
2761
2853
 
@@ -2763,21 +2855,24 @@ impl SbHandle {
2763
2855
  /// sandbox raises `SandboxNotRunningError` (the handle does not start it
2764
2856
  /// implicitly). Returns `{ name, latency_secs }`.
2765
2857
  fn ping(&self) -> Result<RHash, Error> {
2766
- let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
2858
+ let inner = self.inner.get()?;
2859
+ let result = block_on(inner.ping())?.map_err(error::to_ruby)?;
2767
2860
  Ok(ping_result_to_hash(&result))
2768
2861
  }
2769
2862
 
2770
2863
  /// Explicitly refresh the idle-activity timer. A stopped sandbox raises
2771
2864
  /// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
2772
2865
  fn touch(&self) -> Result<RHash, Error> {
2773
- let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
2866
+ let inner = self.inner.get()?;
2867
+ let result = block_on(inner.touch())?.map_err(error::to_ruby)?;
2774
2868
  Ok(touch_result_to_hash(&result))
2775
2869
  }
2776
2870
 
2777
2871
  /// Plan or apply a live sandbox modification (see `Sandbox::modify`). Returns
2778
2872
  /// the `SandboxModificationPlan` as a JSON string.
2779
2873
  fn modify(&self, opts: RHash) -> Result<String, Error> {
2780
- run_modify(self.inner.modify(), opts)
2874
+ let inner = self.inner.get()?;
2875
+ run_modify(inner.modify(), opts)
2781
2876
  }
2782
2877
  }
2783
2878
 
@@ -2866,6 +2961,8 @@ pub(crate) fn log_entry_to_hash(entry: &LogEntry) -> RHash {
2866
2961
 
2867
2962
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2868
2963
  let class = native.define_class("Sandbox", ruby.class_object())?;
2964
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
2965
+ crate::runtime::preload_class::<Sandbox>(ruby);
2869
2966
 
2870
2967
  class.define_singleton_method("create", function!(Sandbox::create, 2))?;
2871
2968
  class.define_singleton_method(
@@ -2939,6 +3036,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2939
3036
  class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
2940
3037
 
2941
3038
  let handle = native.define_class("SandboxHandle", ruby.class_object())?;
3039
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
3040
+ crate::runtime::preload_class::<SbHandle>(ruby);
2942
3041
  handle.define_method("name", method!(SbHandle::name, 0))?;
2943
3042
  handle.define_method("id", method!(SbHandle::id, 0))?;
2944
3043
  handle.define_method("status", method!(SbHandle::status, 0))?;
@@ -2966,6 +3065,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2966
3065
  handle.define_method("modify", method!(SbHandle::modify, 1))?;
2967
3066
 
2968
3067
  let session = native.define_class("PullSession", ruby.class_object())?;
3068
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
3069
+ crate::runtime::preload_class::<PullSession>(ruby);
2969
3070
  session.define_method("recv", method!(PullSession::recv, 0))?;
2970
3071
  session.define_method("result", method!(PullSession::result, 0))?;
2971
3072