microsandbox-rb 0.17.1 → 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.
data/docs/surface.md CHANGED
@@ -23,8 +23,10 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
23
23
  `write_stream`.
24
24
  - **Observability** — per-sandbox `metrics`, `Microsandbox.all_sandbox_metrics`,
25
25
  streaming `metrics_stream`/`log_stream`, `logs`, `ping`/`touch`.
26
- - **Live modification** — `modify` with `dry_run:`, `policy:`, CPU/memory
27
- resize, root-disk growth, env/labels/workdir, secret rotation.
26
+ - **Live modification** — `modify` with `dry_run:` and `policy:`: CPU/memory
27
+ resize (`cpus`/`max_cpus`/`memory`/`max_memory`), root-disk growth
28
+ (`root_disk_size`, MiB), `env`/`remove_env`, `labels`/`remove_labels`,
29
+ `workdir`, and secret rotation (`secrets`/`remove_secrets`).
28
30
  - **OCI image cache** — `Image.get`/`list`/`inspect`/`remove`/`prune`, image
29
31
  archives (`Image.load`/`Image.save`), streaming pull progress
30
32
  (`Sandbox.create_with_progress` → `PullSession`).
@@ -35,14 +37,16 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
35
37
  - **Rootfs patches** — `Microsandbox::Patch` (text/file/append/copy_file/
36
38
  copy_dir/symlink/mkdir/remove) via `create(patches:)`.
37
39
  - **Root disk** — structured `root_disk:` (managed / tmpfs / disk / flat via
38
- `Microsandbox::RootDisk`), `follow_root_symlinks:`.
40
+ `Microsandbox::RootDisk`), `follow_root_symlinks:`; `oci_upper_size:` is the
41
+ deprecated alias for a managed `root_disk:` size.
39
42
  - **Network configuration** — composable profiles, custom per-rule
40
- `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, DNS, TLS interception,
41
- IPv4/IPv6 pools, `max_connections`, `trust_host_cas`, `strict` hostname mode,
43
+ `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, custom DNS (`dns:`),
44
+ TLS-interception tuning (`tls:`), guest address pools (`ipv4_pool:`/
45
+ `ipv6_pool:`), `max_connections`, `trust_host_cas`, `strict` hostname mode,
42
46
  outbound SOCKS4/SOCKS5 `proxy:` (`OutboundProxy`/`SecretSource`),
43
47
  `rate_limiter:`, `vsock:`.
44
48
  - **Secrets** — multi-host / wildcard allow-lists, injection toggles,
45
- per-secret and sandbox-level violation policy.
49
+ per-secret `on_violation:` and sandbox-level `on_secret_violation:` policy.
46
50
  - **SSH** — `Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`, with
47
51
  `inactivity_timeout:`.
48
52
  - **Raw agent client** — `Microsandbox::AgentClient` → `AgentStream`/
@@ -55,9 +59,10 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
55
59
  - **Errors** — the typed hierarchy in [errors.md](errors.md).
56
60
 
57
61
  Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
58
- `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, `init`/`ephemeral`,
62
+ `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, named `scripts`, the image
63
+ `entrypoint`/`cmd` overrides, `init`/`ephemeral`,
59
64
  disk-image `fstype`, `ports`/`ports_udp`, network policy + config,
60
- `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
65
+ `log_level`/`quiet_logs`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
61
66
  (bind/named/tmpfs/disk with mount policies, `uid`/`gid`, `quota_mib`),
62
67
  `idle_timeout`/`max_duration`, `from_snapshot`, `detached`, and
63
68
  `replace`/`replace_with_timeout`.
@@ -6,8 +6,9 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
- # The core-crate dependency below stays pinned at its own tag (v0.6.16).
10
- version = "0.17.1"
9
+ # The core-crate dependencies below stay on their own tag, which is
10
+ # Microsandbox::RUNTIME_VERSION and moves independently of this version.
11
+ version = "0.17.2"
11
12
  authors = ["Super Rad Company <development@superrad.company>"]
12
13
  repository = "https://github.com/superradcompany/microsandbox"
13
14
  license = "Apache-2.0"
@@ -22,9 +23,12 @@ name = "microsandbox_rb"
22
23
  crate-type = ["cdylib"]
23
24
 
24
25
  [dependencies]
25
- magnus = "0.8"
26
- # Low-level Ruby C API — used directly for rb_thread_call_without_gvl (GVL release
27
- # during blocking sandbox calls). Kept in lock-step with magnus's own rb-sys.
26
+ # "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
27
+ # uses to check interrupts without letting Ruby longjmp over Rust frames.
28
+ magnus = { version = "0.8", features = ["rb-sys"] }
29
+ # Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
30
+ # release during blocking sandbox calls) and rb_thread_check_ints. Kept in
31
+ # lock-step with magnus's own rb-sys.
28
32
  rb-sys = "0.9"
29
33
 
30
34
  # The microsandbox core runtime, wrapped via FFI exactly like the official
@@ -41,8 +41,8 @@ impl AgentClient {
41
41
  /// Connect to a running sandbox by name. `timeout` is optional seconds.
42
42
  fn connect_sandbox(name: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
43
43
  let bridge = match dur(timeout)? {
44
- Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t)),
45
- None => block_on(AgentBridge::connect_sandbox(&name)),
44
+ Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t))?,
45
+ None => block_on(AgentBridge::connect_sandbox(&name))?,
46
46
  }
47
47
  .map_err(to_ruby_agent)?;
48
48
  Ok(AgentClient::from_bridge(bridge))
@@ -51,8 +51,8 @@ impl AgentClient {
51
51
  /// Connect to an agentd relay socket by path. `timeout` is optional seconds.
52
52
  fn connect_path(path: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
53
53
  let bridge = match dur(timeout)? {
54
- Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t)),
55
- None => block_on(AgentBridge::connect_path(&path)),
54
+ Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t))?,
55
+ None => block_on(AgentBridge::connect_path(&path))?,
56
56
  }
57
57
  .map_err(to_ruby_agent)?;
58
58
  Ok(AgentClient::from_bridge(bridge))
@@ -73,7 +73,7 @@ impl AgentClient {
73
73
  let body = unsafe { body.as_slice() }.to_vec();
74
74
  let inner = Arc::clone(&self.inner);
75
75
  let frame =
76
- block_on(async move { inner.request(flags, body).await }).map_err(to_ruby_agent)?;
76
+ block_on(async move { inner.request(flags, body).await })?.map_err(to_ruby_agent)?;
77
77
  Ok(frame_to_hash(frame))
78
78
  }
79
79
 
@@ -81,8 +81,8 @@ impl AgentClient {
81
81
  fn stream_open(&self, flags: u8, body: RString) -> Result<RHash, Error> {
82
82
  let body = unsafe { body.as_slice() }.to_vec();
83
83
  let inner = Arc::clone(&self.inner);
84
- let (id, handle) =
85
- block_on(async move { inner.stream_open(flags, body).await }).map_err(to_ruby_agent)?;
84
+ let (id, handle) = block_on(async move { inner.stream_open(flags, body).await })?
85
+ .map_err(to_ruby_agent)?;
86
86
  let hash = ruby().hash_new();
87
87
  hash.aset("id", id)?;
88
88
  hash.aset("handle", handle)?;
@@ -93,14 +93,14 @@ impl AgentClient {
93
93
  fn stream_next(&self, handle: u64) -> Result<Option<RHash>, Error> {
94
94
  let inner = Arc::clone(&self.inner);
95
95
  let frame =
96
- block_on(async move { inner.stream_next(handle).await }).map_err(to_ruby_agent)?;
96
+ block_on(async move { inner.stream_next(handle).await })?.map_err(to_ruby_agent)?;
97
97
  Ok(frame.map(frame_to_hash))
98
98
  }
99
99
 
100
100
  /// Close a stream handle. Idempotent.
101
101
  fn stream_close(&self, handle: u64) -> Result<(), Error> {
102
102
  let inner = Arc::clone(&self.inner);
103
- block_on(async move { inner.stream_close(handle).await });
103
+ block_on(async move { inner.stream_close(handle).await })?;
104
104
  Ok(())
105
105
  }
106
106
 
@@ -108,7 +108,7 @@ impl AgentClient {
108
108
  fn send(&self, id: u32, flags: u8, body: RString) -> Result<(), Error> {
109
109
  let body = unsafe { body.as_slice() }.to_vec();
110
110
  let inner = Arc::clone(&self.inner);
111
- block_on(async move { inner.send(id, flags, body).await }).map_err(to_ruby_agent)
111
+ block_on(async move { inner.send(id, flags, body).await })?.map_err(to_ruby_agent)
112
112
  }
113
113
 
114
114
  /// Cached handshake `core.ready` frame body bytes (CBOR).
@@ -120,7 +120,7 @@ impl AgentClient {
120
120
  /// Close the connection. Idempotent.
121
121
  fn close(&self) -> Result<(), Error> {
122
122
  let inner = Arc::clone(&self.inner);
123
- block_on(async move { inner.close().await });
123
+ block_on(async move { inner.close().await })?;
124
124
  Ok(())
125
125
  }
126
126
  }
@@ -157,6 +157,8 @@ fn frame_to_hash(frame: BridgeFrame) -> RHash {
157
157
 
158
158
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
159
159
  let class = native.define_class("AgentClient", ruby.class_object())?;
160
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
161
+ crate::runtime::preload_class::<AgentClient>(ruby);
160
162
 
161
163
  class.define_singleton_method(
162
164
  "connect_sandbox",
@@ -59,7 +59,7 @@ pub fn with_local_backend<T>(
59
59
  .as_local()
60
60
  .expect("local_backend() guarantees a local backend");
61
61
  op(local).await
62
- })
62
+ })?
63
63
  .map_err(error::to_ruby)
64
64
  }
65
65
 
@@ -43,7 +43,7 @@ impl ExecHandle {
43
43
  /// Next event as a Hash, or nil when the stream ends.
44
44
  fn recv(&self) -> Result<Option<RHash>, Error> {
45
45
  let inner = Arc::clone(&self.inner);
46
- let event = block_on(async move { inner.lock().await.recv().await });
46
+ let event = block_on(async move { inner.lock().await.recv().await })?;
47
47
  Ok(event.map(exec_event_to_hash))
48
48
  }
49
49
 
@@ -51,7 +51,7 @@ impl ExecHandle {
51
51
  fn wait(&self) -> Result<RHash, Error> {
52
52
  let inner = Arc::clone(&self.inner);
53
53
  let status =
54
- block_on(async move { inner.lock().await.wait().await }).map_err(error::to_ruby)?;
54
+ block_on(async move { inner.lock().await.wait().await })?.map_err(error::to_ruby)?;
55
55
  let hash = ruby().hash_new();
56
56
  hash.aset("exit_code", status.code)?;
57
57
  hash.aset("success", status.success)?;
@@ -62,20 +62,20 @@ impl ExecHandle {
62
62
  fn collect(&self) -> Result<RHash, Error> {
63
63
  let inner = Arc::clone(&self.inner);
64
64
  let output =
65
- block_on(async move { inner.lock().await.collect().await }).map_err(error::to_ruby)?;
65
+ block_on(async move { inner.lock().await.collect().await })?.map_err(error::to_ruby)?;
66
66
  crate::sandbox::exec_output_to_hash(output)
67
67
  }
68
68
 
69
69
  fn signal(&self, sig: i32) -> Result<(), Error> {
70
- block_on(self.control.signal(sig)).map_err(error::to_ruby)
70
+ block_on(self.control.signal(sig))?.map_err(error::to_ruby)
71
71
  }
72
72
 
73
73
  fn kill(&self) -> Result<(), Error> {
74
- block_on(self.control.kill()).map_err(error::to_ruby)
74
+ block_on(self.control.kill())?.map_err(error::to_ruby)
75
75
  }
76
76
 
77
77
  fn resize(&self, rows: u16, cols: u16) -> Result<(), Error> {
78
- block_on(self.control.resize(rows, cols)).map_err(error::to_ruby)
78
+ block_on(self.control.resize(rows, cols))?.map_err(error::to_ruby)
79
79
  }
80
80
 
81
81
  /// The stdin sink (only the first call returns it; nil afterwards or if
@@ -95,11 +95,11 @@ impl ExecSink {
95
95
  fn write(&self, data: RString) -> Result<(), Error> {
96
96
  // Copy out while holding the GVL (see sandbox::fs_write for the rationale).
97
97
  let bytes = unsafe { data.as_slice() }.to_vec();
98
- block_on(self.inner.write(&bytes)).map_err(error::to_ruby)
98
+ block_on(self.inner.write(&bytes))?.map_err(error::to_ruby)
99
99
  }
100
100
 
101
101
  fn close(&self) -> Result<(), Error> {
102
- block_on(self.inner.close()).map_err(error::to_ruby)
102
+ block_on(self.inner.close())?.map_err(error::to_ruby)
103
103
  }
104
104
  }
105
105
 
@@ -141,6 +141,8 @@ fn exec_event_to_hash(event: microsandbox::ExecEvent) -> RHash {
141
141
 
142
142
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
143
143
  let handle = native.define_class("ExecHandle", ruby.class_object())?;
144
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
145
+ crate::runtime::preload_class::<ExecHandle>(ruby);
144
146
  handle.define_method("id", method!(ExecHandle::id, 0))?;
145
147
  handle.define_method("recv", method!(ExecHandle::recv, 0))?;
146
148
  handle.define_method("wait", method!(ExecHandle::wait, 0))?;
@@ -151,6 +153,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
151
153
  handle.define_method("take_stdin", method!(ExecHandle::take_stdin, 0))?;
152
154
 
153
155
  let sink = native.define_class("ExecSink", ruby.class_object())?;
156
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
157
+ crate::runtime::preload_class::<ExecSink>(ruby);
154
158
  sink.define_method("write", method!(ExecSink::write, 1))?;
155
159
  sink.define_method("close", method!(ExecSink::close, 0))?;
156
160
 
@@ -33,7 +33,7 @@ impl FsReadStreamHandle {
33
33
  /// Next chunk of bytes (ASCII-8BIT), or nil at end of stream.
34
34
  fn recv(&self) -> Result<Option<RString>, Error> {
35
35
  let inner = Arc::clone(&self.inner);
36
- match block_on(async move { inner.lock().await.recv().await }).map_err(error::to_ruby)? {
36
+ match block_on(async move { inner.lock().await.recv().await })?.map_err(error::to_ruby)? {
37
37
  Some(bytes) => Ok(Some(ruby().str_from_slice(bytes.as_ref()))),
38
38
  None => Ok(None),
39
39
  }
@@ -63,7 +63,7 @@ impl FsWriteSinkHandle {
63
63
  Some(sink) => Some(sink.write(&bytes).await),
64
64
  None => None,
65
65
  }
66
- });
66
+ })?;
67
67
  match result {
68
68
  Some(r) => r.map_err(error::to_ruby),
69
69
  None => Err(error::base_error("write to a closed FsWriteSink")),
@@ -73,19 +73,31 @@ impl FsWriteSinkHandle {
73
73
  /// Flush and close the sink. Idempotent.
74
74
  fn close(&self) -> Result<(), Error> {
75
75
  let inner = Arc::clone(&self.inner);
76
- let taken = block_on(async move { inner.lock().await.take() });
77
- match taken {
78
- Some(sink) => block_on(sink.close()).map_err(error::to_ruby),
79
- None => Ok(()),
80
- }
76
+ // Take AND close in ONE `block_on`. `block_on` returns `Err` when an
77
+ // interrupt is delivered before it starts; as two calls, an interrupt
78
+ // between them would drop the sink the first one took without ever
79
+ // sending its EOF (the guest file is left unfinished, and the handle
80
+ // can no longer close it).
81
+ block_on(async move {
82
+ let taken = inner.lock().await.take();
83
+ match taken {
84
+ Some(sink) => sink.close().await,
85
+ None => Ok(()),
86
+ }
87
+ })?
88
+ .map_err(error::to_ruby)
81
89
  }
82
90
  }
83
91
 
84
92
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
85
93
  let rs = native.define_class("FsReadStream", ruby.class_object())?;
94
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
95
+ crate::runtime::preload_class::<FsReadStreamHandle>(ruby);
86
96
  rs.define_method("recv", method!(FsReadStreamHandle::recv, 0))?;
87
97
 
88
98
  let ws = native.define_class("FsWriteSink", ruby.class_object())?;
99
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
100
+ crate::runtime::preload_class::<FsWriteSinkHandle>(ruby);
89
101
  ws.define_method("write", method!(FsWriteSinkHandle::write, 1))?;
90
102
  ws.define_method("close", method!(FsWriteSinkHandle::close, 0))?;
91
103
  Ok(())
@@ -48,8 +48,8 @@ fn detail_to_hash(detail: ImageDetail) -> RHash {
48
48
  let _ = c.aset("user", config.user);
49
49
  // OCI config labels: a free-form JSON object (or nil). Converted to a
50
50
  // Ruby Hash so `ImageDetail#config["labels"]` matches the Python/Node
51
- // `config.labels` dict. (The pinned v0.5.8 runtime persists this as
52
- // null today; the key exists for forward-compatibility/parity.)
51
+ // `config.labels` dict. (The runtime persisted this as null when the
52
+ // key was added, at v0.5.8; it exists for forward-compatibility/parity.)
53
53
  let _ = c.aset("labels", config.labels.as_ref().map(conv::json_to_ruby));
54
54
  let _ = c.aset("stop_signal", config.stop_signal);
55
55
  let _ = hash.aset("config", c);
@@ -47,7 +47,7 @@ fn all_sandbox_metrics() -> Result<RHash, Error> {
47
47
 
48
48
  /// Download and install the `msb` runtime + `libkrunfw` into `~/.microsandbox`.
49
49
  fn install() -> Result<(), Error> {
50
- runtime::block_on(microsandbox::setup::install()).map_err(error::to_ruby)
50
+ runtime::block_on(microsandbox::setup::install())?.map_err(error::to_ruby)
51
51
  }
52
52
 
53
53
  /// Customizable install via the core `Setup` builder. `opts`: base_dir (install
@@ -84,7 +84,7 @@ fn setup(opts: RHash) -> Result<(), Error> {
84
84
  .force(force)
85
85
  .build(),
86
86
  };
87
- runtime::block_on(setup.install()).map_err(error::to_ruby)
87
+ runtime::block_on(setup.install())?.map_err(error::to_ruby)
88
88
  }
89
89
 
90
90
  /// Whether the `msb` runtime + `libkrunfw` are installed and resolvable.
@@ -5,6 +5,39 @@
5
5
  //! tokio runtime. Crucially, the blocking call happens inside [`nogvl`], which
6
6
  //! releases Ruby's Global VM Lock for the duration so other Ruby threads keep
7
7
  //! running while a (potentially long) sandbox operation is in flight.
8
+ //!
9
+ //! # Interrupts must not lose a result
10
+ //!
11
+ //! A native call cannot be interrupted while it runs (see `DESIGN.md`), so a
12
+ //! `Thread#raise` (what `Timeout.timeout` uses), a `Thread#kill` or the main
13
+ //! thread's SIGINT that arrives meanwhile is delivered *after* it. Ruby delivers
14
+ //! those by `longjmp`, which skips Rust frames without running destructors: if
15
+ //! that happened while the value the call just produced still lived only in a
16
+ //! Rust frame, the value — a booted sandbox, a guest exec session, megabytes of
17
+ //! output — would be neither returned to Ruby nor dropped. Two rules keep that
18
+ //! from happening; every bound method relies on both:
19
+ //!
20
+ //! 1. **[`nogvl`] never lets Ruby jump out of it.** It checks interrupts only
21
+ //! *before* the work starts, under `rb_protect`, and reports one as an
22
+ //! ordinary `Err` that unwinds the Rust frames normally. After the work it
23
+ //! does not check at all (see the function for the CRuby details).
24
+ //! 2. **Between [`block_on`] returning and the bound method returning, the code
25
+ //! must not run Ruby code or check interrupts**: no `funcall` (CRuby's
26
+ //! `vm_call0_body` runs `RUBY_VM_CHECK_INTS` after *every* call it makes, C
27
+ //! functions included), no `rb_thread_check_ints`, nothing that can enter a
28
+ //! Ruby method body. Only C-level construction is allowed there — `rb_hash_*`,
29
+ //! `rb_ary_*`, `rb_str_*`, number boxing, and `TypedData` wrapping. The one
30
+ //! `funcall` magnus would otherwise make at that point — `#[magnus::wrap]`
31
+ //! resolves its class with `const_get` the first time a value is wrapped — is
32
+ //! moved to extension load by [`preload_class`]. Error construction
33
+ //! (`error::to_ruby`) may call Ruby: on that path there is no value to lose.
34
+ //!
35
+ //! Together: after `block_on` returns `Ok`, the next interrupt checkpoint is
36
+ //! the Ruby VM's own, reached only once the bound method has handed its value
37
+ //! to Ruby (the first one is `vm_pop_frame` popping the native method's frame,
38
+ //! in `vm_call_cfunc_with_frame_`). From then on the value is an ordinary Ruby
39
+ //! object — returned to the caller, or garbage whose `free` drops the Rust
40
+ //! value. It is never abandoned.
8
41
 
9
42
  use std::ffi::c_void;
10
43
  use std::future::Future;
@@ -12,9 +45,11 @@ use std::panic::{catch_unwind, AssertUnwindSafe};
12
45
  use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
13
46
  use std::sync::Mutex;
14
47
 
15
- use magnus::Ruby;
48
+ use magnus::{Error, Ruby, TypedData};
16
49
  use tokio::runtime::Runtime;
17
50
 
51
+ use crate::error;
52
+
18
53
  /// The current Ruby handle. Safe to call from any bound method or value
19
54
  /// conversion: we always hold the GVL there (conversions run after `block_on`
20
55
  /// returns and re-acquires it).
@@ -78,6 +113,52 @@ pub fn runtime() -> &'static Runtime {
78
113
  rt
79
114
  }
80
115
 
116
+ /// Resolve the Ruby class of a `#[magnus::wrap]` type now, at extension load.
117
+ ///
118
+ /// The `wrap` macro looks its class up lazily, the first time a value of the
119
+ /// type is handed to Ruby, with `funcall("const_get", ...)` — and a `funcall`
120
+ /// is an interrupt checkpoint (rule 2 in the module docs). Left lazy, the first
121
+ /// `Sandbox.create` of a process that an interrupt raced would hit that
122
+ /// checkpoint while the freshly booted sandbox was still a plain Rust value.
123
+ /// Every module's `define` calls this for each type it wraps, right after
124
+ /// defining the class, so wrapping a value afterwards is `TypedData::class`
125
+ /// reading a resolved static plus `rb_data_typed_object_wrap`.
126
+ pub fn preload_class<T: TypedData>(ruby: &Ruby) {
127
+ let _ = T::class(ruby);
128
+ }
129
+
130
+ /// How often [`nogvl`] lets Ruby process interrupts before the work starts
131
+ /// until it gives up. See the comment in the loop: this is a defensive bound,
132
+ /// not a path a healthy process takes.
133
+ const MAX_ENTRY_ATTEMPTS: u32 = 64;
134
+
135
+ /// Let Ruby deliver whatever interrupts are pending for this thread, turning a
136
+ /// raised exception or any other non-local exit (e.g. the `TAG_FATAL` jump of a
137
+ /// `Thread#kill`) into an `Err` instead of letting it `longjmp` through us.
138
+ ///
139
+ /// `rb_thread_check_ints` is `rb_ec_check_ints` → `RUBY_VM_CHECK_INTS_BLOCKING`
140
+ /// → `vm_check_ints_blocking` → `rb_threadptr_execute_interrupts(th, 1)`
141
+ /// (thread.c): it runs trap handlers, may switch threads on a timer tick, and
142
+ /// raises queued `Thread#raise` exceptions — with *blocking* timing, so it also
143
+ /// raises the ones a caller deferred with
144
+ /// `Thread.handle_interrupt(X => :on_blocking)`.
145
+ ///
146
+ /// (All CRuby references in this file were read from the Ruby 3.4.5 sources;
147
+ /// the functions named behave the same in 3.1–3.3, the other versions this gem
148
+ /// supports.)
149
+ /// `magnus::rb_sys::protect` is `rb_protect`; magnus re-raises (or re-jumps)
150
+ /// the returned `Error` once it reaches the method boundary, after every Rust
151
+ /// frame in between has unwound normally.
152
+ fn check_interrupts() -> Result<(), Error> {
153
+ magnus::rb_sys::protect(|| {
154
+ // SAFETY: called on a Ruby thread holding the GVL (every caller of
155
+ // `nogvl` is a bound method), inside `rb_protect`.
156
+ unsafe { rb_sys::rb_thread_check_ints() };
157
+ rb_sys::Qnil as rb_sys::VALUE
158
+ })
159
+ .map(|_| ())
160
+ }
161
+
81
162
  /// Run `f` with the Ruby GVL released.
82
163
  ///
83
164
  /// `f` runs on the *same* OS thread (the C call blocks until it returns), so no
@@ -85,7 +166,12 @@ pub fn runtime() -> &'static Runtime {
85
166
  /// the GVL is released. Panics are caught and re-raised after the GVL is
86
167
  /// re-acquired, because unwinding across the C frame would be undefined
87
168
  /// behaviour.
88
- pub fn nogvl<F, R>(f: F) -> R
169
+ ///
170
+ /// Returns `Err` only when `f` did NOT run: an interrupt was delivered before
171
+ /// the work started (a pending `Thread#raise`/`Thread#kill`/SIGINT), so there
172
+ /// is nothing to lose and the caller just propagates it with `?`. Once `f` has
173
+ /// run, this returns `Ok` with its value and Ruby is not consulted again.
174
+ pub fn nogvl<F, R>(f: F) -> Result<R, Error>
89
175
  where
90
176
  F: FnOnce() -> R,
91
177
  {
@@ -99,7 +185,7 @@ where
99
185
  F: FnOnce() -> R,
100
186
  {
101
187
  // SAFETY: `arg` points to the `State` on the caller's stack, which
102
- // outlives this call (rb_thread_call_without_gvl blocks until return).
188
+ // outlives this call (rb_thread_call_without_gvl2 blocks until return).
103
189
  let state = unsafe { &mut *(arg as *mut State<F, R>) };
104
190
  let f = state.f.take().expect("nogvl callback invoked twice");
105
191
  state.out = Some(catch_unwind(AssertUnwindSafe(f)));
@@ -111,24 +197,114 @@ where
111
197
  out: None,
112
198
  };
113
199
 
114
- unsafe {
115
- rb_sys::rb_thread_call_without_gvl(
116
- Some(call::<F, R>),
117
- &mut state as *mut _ as *mut c_void,
118
- None,
119
- std::ptr::null_mut(),
120
- );
200
+ // Why not the plain `rb_thread_call_without_gvl`: `rb_nogvl` (thread.c)
201
+ // ends it with `RUBY_VM_CHECK_INTS_BLOCKING(ec)` — step (5) "Check
202
+ // interrupts" of the contract in its doc comment — AFTER the callback has
203
+ // returned. An exception raised there leaves by `longjmp` past this frame,
204
+ // `block_on`'s and the bound method's, with the callback's result sitting
205
+ // in `state.out`: never returned, never dropped. That exit check is the bug
206
+ // this function exists to avoid, so the plain call must not come back here
207
+ // in any form, not even as a fallback.
208
+ //
209
+ // `rb_thread_call_without_gvl2` is `rb_nogvl(..., RB_NOGVL_INTR_FAIL)`:
210
+ // * at entry `unblock_function_set(.., fail_if_interrupted = 1)` returns
211
+ // FALSE when `RUBY_VM_INTERRUPTED_ANY(ec)` — any unmasked bit of
212
+ // `ec->interrupt_flag` — is set, and `BLOCKING_REGION` then skips the
213
+ // callback altogether;
214
+ // * at exit the `RUBY_VM_CHECK_INTS_BLOCKING` is skipped
215
+ // (`if ((flags & RB_NOGVL_INTR_FAIL) == 0)`).
216
+ // So it never jumps, but it can decline to run. We do the checking
217
+ // ourselves, only at points where nothing has happened yet.
218
+
219
+ // (i) One protected check before we start. The plain call also checked at
220
+ // entry (`unblock_function_set` → `RUBY_VM_CHECK_INTS`), so an interrupt
221
+ // that is already pending keeps being delivered before any work is
222
+ // done. One difference, on purpose: the plain call's entry check used
223
+ // non-blocking timing and its EXIT check was the one that delivered
224
+ // exceptions deferred with `Thread.handle_interrupt(X => :on_blocking)`
225
+ // — i.e. at exactly the point that lost the result. This check uses
226
+ // blocking timing (see `check_interrupts`), so such an exception is now
227
+ // raised here, before the call, if it is already queued; one that
228
+ // arrives during the call waits for the thread's next blocking point.
229
+ // Nothing has happened yet, so an `Err` unwinds our frames normally and
230
+ // drops `f` (and the future it owns).
231
+ check_interrupts()?;
232
+
233
+ let mut attempts = 0;
234
+ loop {
235
+ // SAFETY: `call` matches the expected signature, `state` outlives the
236
+ // call, and a null unblock function is allowed (the call is simply not
237
+ // cancellable, as before).
238
+ unsafe {
239
+ rb_sys::rb_thread_call_without_gvl2(
240
+ Some(call::<F, R>),
241
+ &mut state as *mut _ as *mut c_void,
242
+ None,
243
+ std::ptr::null_mut(),
244
+ );
245
+ }
246
+ if state.out.is_some() {
247
+ // The callback ran. Do NOT check interrupts: see the invariant below.
248
+ break;
249
+ }
250
+
251
+ // (ii) An interrupt bit was set at entry, so the callback did NOT run
252
+ // and nothing has happened. This is routine, not exotic:
253
+ // `RUBY_VM_INTERRUPTED_ANY` also covers the timer tick, postponed
254
+ // jobs and the VM barrier, and a `Thread#raise` can land in the few
255
+ // instructions since the check above. Let Ruby process it under
256
+ // protection, then retry.
257
+ //
258
+ // The retry cannot spin: `rb_threadptr_execute_interrupts` loops on
259
+ // `threadptr_get_interrupts`, which clears every unmasked bit of
260
+ // `ec->interrupt_flag` (the `ATOMIC_CAS` to `interrupt &
261
+ // ec->interrupt_mask`) before dispatching, until none is left. That
262
+ // holds even when the queued exception stays masked
263
+ // (`handle_interrupt(X => :never)`):
264
+ // `rb_threadptr_pending_interrupt_deque` then returns `Qundef` and
265
+ // sets `pending_interrupt_queue_checked`, and although
266
+ // `vm_check_ints_blocking` re-sets the pending bit on each call, the
267
+ // same call consumes it again. We hold the GVL from the return of
268
+ // the check to the entry test of the next attempt, so only a
269
+ // non-Ruby source (timer thread, signal) can set a bit in between.
270
+ check_interrupts()?;
271
+
272
+ // The one case in which the bits are NOT consumed is `ec->raised_flag`:
273
+ // `rb_threadptr_execute_interrupts` returns at once while CRuby is in
274
+ // the middle of raising (`setup_exception` in eval.c — an overridden
275
+ // `Exception#backtrace`, a `TracePoint` `:raise` hook). A native call
276
+ // made from there with an interrupt pending can never start under
277
+ // `_gvl2`. Fail it with a normal error rather than loop forever (or
278
+ // reach for the plain call); nothing has run, so nothing is lost.
279
+ attempts += 1;
280
+ if attempts >= MAX_ENTRY_ATTEMPTS {
281
+ return Err(error::base_error(
282
+ "native call could not start: interrupt check did not settle",
283
+ ));
284
+ }
121
285
  }
122
286
 
287
+ // INVARIANT: from here until the bound method returns its value to Ruby,
288
+ // nothing checks interrupts (rule 2 in the module docs). The next
289
+ // checkpoint is the Ruby VM's own, AFTER the value has become a Ruby
290
+ // object, so the value is either returned or dropped by Rust — never
291
+ // abandoned in this frame by a `longjmp`.
123
292
  match state.out.take() {
124
- Some(Ok(value)) => value,
293
+ Some(Ok(value)) => Ok(value),
125
294
  Some(Err(panic)) => std::panic::resume_unwind(panic),
126
- None => unreachable!("nogvl callback did not run"),
295
+ // Not reachable: the loop above only breaks once `state.out` is `Some`.
296
+ // Kept as an error instead of a panic so a future edit to the loop
297
+ // cannot turn into an abort across the FFI boundary.
298
+ None => Err(error::base_error("native call did not run")),
127
299
  }
128
300
  }
129
301
 
130
302
  /// Drive a future to completion on the shared runtime with the GVL released.
131
- pub fn block_on<F>(fut: F) -> F::Output
303
+ ///
304
+ /// `Err` means the future was never polled (see [`nogvl`]); propagate it with
305
+ /// `?` before looking at the future's own output:
306
+ /// `block_on(fut)?.map_err(error::to_ruby)?`.
307
+ pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
132
308
  where
133
309
  F: Future,
134
310
  {