microsandbox-rb 0.17.2 → 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")? {
@@ -640,30 +645,34 @@ impl Sandbox {
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();
819
+ let inner = self.inner.get()?.clone();
801
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,86 +903,101 @@ impl Sandbox {
877
903
  //----------------------------------------------------------------------
878
904
 
879
905
  fn fs_read(&self, path: String) -> Result<RString, Error> {
880
- let fs = self.inner.fs();
906
+ let inner = self.inner.get()?;
907
+ let fs = inner.fs();
881
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();
913
+ let inner = self.inner.get()?;
914
+ let fs = inner.fs();
887
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();
923
+ let fs = inner.fs();
895
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();
928
+ let inner = self.inner.get()?;
929
+ let fs = inner.fs();
900
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();
935
+ let inner = self.inner.get()?;
936
+ let fs = inner.fs();
906
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();
941
+ let inner = self.inner.get()?;
942
+ let fs = inner.fs();
911
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();
947
+ let inner = self.inner.get()?;
948
+ let fs = inner.fs();
916
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();
953
+ let inner = self.inner.get()?;
954
+ let fs = inner.fs();
921
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();
959
+ let inner = self.inner.get()?;
960
+ let fs = inner.fs();
926
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();
965
+ let inner = self.inner.get()?;
966
+ let fs = inner.fs();
931
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();
971
+ let inner = self.inner.get()?;
972
+ let fs = inner.fs();
936
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();
978
+ let inner = self.inner.get()?;
979
+ let fs = inner.fs();
942
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();
984
+ let inner = self.inner.get()?;
985
+ let fs = inner.fs();
947
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();
991
+ let inner = self.inner.get()?;
992
+ let fs = inner.fs();
953
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();
999
+ let inner = self.inner.get()?;
1000
+ let fs = inner.fs();
960
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
  }
@@ -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);
@@ -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);
@@ -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
 
@@ -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,14 +2645,19 @@ 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);
2660
+ let progress = Arc::clone(&self.inner.get()?.progress);
2604
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
  }
@@ -2608,7 +2665,7 @@ impl PullSession {
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);
2668
+ let join = Arc::clone(&self.inner.get()?.join);
2612
2669
  // Take the join handle AND await it in ONE `block_on`. `block_on`
2613
2670
  // returns `Err` when an interrupt is delivered before it starts; as two
2614
2671
  // calls, an interrupt between them would drop the taken `JoinHandle`
@@ -2640,83 +2697,102 @@ impl PullSession {
2640
2697
  /// a synchronous snapshot read off the handle (no round-trip).
2641
2698
  #[magnus::wrap(class = "Microsandbox::Native::SandboxHandle", free_immediately, size)]
2642
2699
  pub struct SbHandle {
2643
- inner: SandboxHandle,
2700
+ inner: ForkGuarded<SandboxHandle>,
2644
2701
  }
2645
2702
 
2646
2703
  impl SbHandle {
2647
2704
  fn from_inner(inner: SandboxHandle) -> Self {
2648
- Self { inner }
2705
+ Self {
2706
+ inner: ForkGuarded::new("Microsandbox::SandboxHandle", inner),
2707
+ }
2649
2708
  }
2650
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.
2651
2713
  fn name(&self) -> String {
2652
- self.inner.name().to_string()
2714
+ self.inner.peek().name().to_string()
2653
2715
  }
2654
2716
 
2655
2717
  /// The opaque, backend-assigned identity of the persisted sandbox this
2656
2718
  /// handle is bound to (v0.6.16). The lifecycle operations below refuse to
2657
2719
  /// act on a same-name replacement by comparing against it.
2658
2720
  fn id(&self) -> String {
2659
- self.inner.id().to_string()
2721
+ self.inner.peek().id().to_string()
2660
2722
  }
2661
2723
 
2662
2724
  /// Status snapshot captured when the handle was fetched (synchronous).
2663
2725
  fn status(&self) -> String {
2664
- sandbox_status_str(self.inner.status_snapshot()).to_string()
2726
+ sandbox_status_str(self.inner.peek().status_snapshot()).to_string()
2665
2727
  }
2666
2728
 
2667
2729
  fn created_at_ms(&self) -> Option<i64> {
2668
- self.inner.created_at().map(|dt| dt.timestamp_millis())
2730
+ self.inner
2731
+ .peek()
2732
+ .created_at()
2733
+ .map(|dt| dt.timestamp_millis())
2669
2734
  }
2670
2735
 
2671
2736
  fn updated_at_ms(&self) -> Option<i64> {
2672
- self.inner.updated_at().map(|dt| dt.timestamp_millis())
2737
+ self.inner
2738
+ .peek()
2739
+ .updated_at()
2740
+ .map(|dt| dt.timestamp_millis())
2673
2741
  }
2674
2742
 
2675
2743
  /// Graceful stop (SIGTERM→SIGKILL escalation, 10s default) and wait.
2676
2744
  fn stop(&self) -> Result<(), Error> {
2677
- 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)
2678
2747
  }
2679
2748
 
2680
2749
  /// Graceful stop with a custom escalation timeout (seconds).
2681
2750
  fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
2682
- 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)
2683
2753
  }
2684
2754
 
2685
2755
  /// Force kill (SIGKILL) and wait.
2686
2756
  fn kill(&self) -> Result<(), Error> {
2687
- 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)
2688
2759
  }
2689
2760
 
2690
2761
  /// Force kill, waiting up to `secs` for the process to disappear.
2691
2762
  fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
2692
- 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)
2693
2765
  }
2694
2766
 
2695
2767
  /// Send the graceful-shutdown request and return without waiting.
2696
2768
  fn request_stop(&self) -> Result<(), Error> {
2697
- 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)
2698
2771
  }
2699
2772
 
2700
2773
  /// Send the force-kill request and return without waiting.
2701
2774
  fn request_kill(&self) -> Result<(), Error> {
2702
- 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)
2703
2777
  }
2704
2778
 
2705
2779
  /// Send the drain request (SIGUSR1) and return without waiting.
2706
2780
  fn request_drain(&self) -> Result<(), Error> {
2707
- 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)
2708
2783
  }
2709
2784
 
2710
2785
  /// Connect when this exact sandbox is running, wait while it is starting,
2711
2786
  /// or start it when it is created/stopped/crashed. `opts` carries
2712
2787
  /// `detached`, which applies only when a start is actually required.
2713
2788
  fn connect_or_start(&self, opts: RHash) -> Result<Sandbox, Error> {
2789
+ let inner = self.inner.get()?;
2714
2790
  let detached = conv::opt_bool(opts, "detached")?;
2715
2791
  let inner = block_on(async {
2716
2792
  if detached {
2717
- self.inner.connect_or_start_detached().await
2793
+ inner.connect_or_start_detached().await
2718
2794
  } else {
2719
- self.inner.connect_or_start().await
2795
+ inner.connect_or_start().await
2720
2796
  }
2721
2797
  })?
2722
2798
  .map_err(error::to_ruby)?;
@@ -2727,29 +2803,33 @@ impl SbHandle {
2727
2803
  /// returning a fresh handle. A same-name replacement raises
2728
2804
  /// `SandboxReplacedError` instead of silently redirecting the wait.
2729
2805
  fn wait_for_status(&self, status: String) -> Result<SbHandle, Error> {
2806
+ let inner = self.inner.get()?;
2730
2807
  let status = sandbox_status_from_str(&status)?;
2731
- 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)?;
2732
2809
  Ok(SbHandle::from_inner(handle))
2733
2810
  }
2734
2811
 
2735
2812
  /// Stop and start this exact sandbox, returning the new live sandbox.
2736
2813
  /// `opts` carries `force`/`timeout`/`detached`.
2737
2814
  fn restart(&self, opts: RHash) -> Result<Sandbox, Error> {
2815
+ let inner = self.inner.get()?;
2738
2816
  let options = restart_options(opts)?;
2739
- 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)?;
2740
2818
  Ok(Sandbox::from_inner(inner))
2741
2819
  }
2742
2820
 
2743
2821
  /// Stop and remove this exact sandbox. `opts` carries `force`/`timeout`.
2744
2822
  fn destroy(&self, opts: RHash) -> Result<(), Error> {
2823
+ let inner = self.inner.get()?;
2745
2824
  let options = destroy_options(opts)?;
2746
- block_on(self.inner.destroy_with(options))?.map_err(error::to_ruby)
2825
+ block_on(inner.destroy_with(options))?.map_err(error::to_ruby)
2747
2826
  }
2748
2827
 
2749
2828
  /// Block until the sandbox reaches a terminal state; returns a stop-result
2750
2829
  /// Hash (name, status, exit_code, signal, observed_at_ms, source).
2751
2830
  fn wait_until_stopped(&self) -> Result<RHash, Error> {
2752
- 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)?;
2753
2833
  Ok(stop_result_to_hash(&result))
2754
2834
  }
2755
2835
 
@@ -2757,7 +2837,7 @@ impl SbHandle {
2757
2837
  /// handle already carries it, no runtime round-trip). The Ruby layer parses
2758
2838
  /// it into a Hash for `#config`. Mirrors the Python/Node `config_json`.
2759
2839
  fn config_json(&self) -> String {
2760
- self.inner.config_json().to_string()
2840
+ self.inner.peek().config_json().to_string()
2761
2841
  }
2762
2842
 
2763
2843
  /// Snapshot this (stopped) sandbox under a bare name (resolved under the
@@ -2766,7 +2846,8 @@ impl SbHandle {
2766
2846
  /// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
2767
2847
  /// with `dest_dir:` for explicit placement.)
2768
2848
  fn snapshot(&self, name: String) -> Result<RHash, Error> {
2769
- 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)?;
2770
2851
  Ok(crate::snapshot::snapshot_to_hash(&snap))
2771
2852
  }
2772
2853
 
@@ -2774,21 +2855,24 @@ impl SbHandle {
2774
2855
  /// sandbox raises `SandboxNotRunningError` (the handle does not start it
2775
2856
  /// implicitly). Returns `{ name, latency_secs }`.
2776
2857
  fn ping(&self) -> Result<RHash, Error> {
2777
- 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)?;
2778
2860
  Ok(ping_result_to_hash(&result))
2779
2861
  }
2780
2862
 
2781
2863
  /// Explicitly refresh the idle-activity timer. A stopped sandbox raises
2782
2864
  /// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
2783
2865
  fn touch(&self) -> Result<RHash, Error> {
2784
- 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)?;
2785
2868
  Ok(touch_result_to_hash(&result))
2786
2869
  }
2787
2870
 
2788
2871
  /// Plan or apply a live sandbox modification (see `Sandbox::modify`). Returns
2789
2872
  /// the `SandboxModificationPlan` as a JSON string.
2790
2873
  fn modify(&self, opts: RHash) -> Result<String, Error> {
2791
- run_modify(self.inner.modify(), opts)
2874
+ let inner = self.inner.get()?;
2875
+ run_modify(inner.modify(), opts)
2792
2876
  }
2793
2877
  }
2794
2878