microsandbox-rb 0.17.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +229 -8
- data/Cargo.lock +45 -46
- data/DESIGN.md +90 -72
- data/README.md +178 -669
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +108 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +141 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +117 -0
- data/docs/runtime.md +154 -0
- data/docs/surface.md +75 -0
- data/ext/microsandbox/Cargo.toml +9 -5
- data/ext/microsandbox/src/agent.rs +13 -11
- data/ext/microsandbox/src/backend.rs +1 -1
- data/ext/microsandbox/src/exec.rs +12 -8
- data/ext/microsandbox/src/fs_stream.rs +19 -7
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/image.rs +2 -2
- data/ext/microsandbox/src/lib.rs +3 -2
- data/ext/microsandbox/src/runtime.rs +189 -13
- data/ext/microsandbox/src/sandbox.rs +149 -102
- data/ext/microsandbox/src/snapshot.rs +17 -11
- data/ext/microsandbox/src/ssh.rs +22 -16
- data/ext/microsandbox/src/stream.rs +6 -2
- data/ext/microsandbox/src/volume.rs +50 -17
- data/lib/microsandbox/fs.rb +4 -8
- data/lib/microsandbox/image.rb +2 -2
- data/lib/microsandbox/log_entry.rb +1 -1
- data/lib/microsandbox/metrics.rb +1 -1
- data/lib/microsandbox/outbound_proxy.rb +8 -0
- data/lib/microsandbox/sandbox.rb +128 -13
- data/lib/microsandbox/snapshot.rb +1 -1
- data/lib/microsandbox/ssh.rb +2 -10
- data/lib/microsandbox/streams.rb +8 -5
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +88 -31
- data/sig/microsandbox.rbs +24 -0
- metadata +14 -1
|
@@ -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 })
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//! Pin caller-supplied host paths when a sandbox is created (GHSA-mcw9-9rf6-p4rm).
|
|
2
|
+
//!
|
|
3
|
+
//! The pinned core stores a relative host path exactly as written and resolves
|
|
4
|
+
//! it against the working directory of whichever process later starts the
|
|
5
|
+
//! sandbox, so restarting from another directory mounts a different host
|
|
6
|
+
//! directory than the one the caller meant. Upstream fixed that in v0.7.6 by
|
|
7
|
+
//! resolving host paths once, at the local-backend boundary, before the
|
|
8
|
+
//! configuration is persisted. This module applies the same resolution in the
|
|
9
|
+
//! binding so it also holds on the runtime this gem pins.
|
|
10
|
+
//!
|
|
11
|
+
//! Rules, matching upstream's `resolve_host_paths`:
|
|
12
|
+
//!
|
|
13
|
+
//! - Local backend only: on a cloud backend the paths belong to the remote
|
|
14
|
+
//! host and are forwarded untouched.
|
|
15
|
+
//! - `std::path::absolute`, never `canonicalize`: anchoring must not follow
|
|
16
|
+
//! symlinks (that would defeat the default refusal to follow a symlinked
|
|
17
|
+
//! bind-mount root) and must not require the target to exist.
|
|
18
|
+
//! - Only create-time input is resolved. A sandbox that was already persisted
|
|
19
|
+
//! with a relative path keeps it; nothing here rewrites saved state.
|
|
20
|
+
//!
|
|
21
|
+
//! A vsock `host_socket` is deliberately not routed through here: the core
|
|
22
|
+
//! refuses a relative one outright ("vsock host path must be absolute") before
|
|
23
|
+
//! anything is persisted, so there is no stored relative path to pin.
|
|
24
|
+
|
|
25
|
+
use std::path::PathBuf;
|
|
26
|
+
|
|
27
|
+
use magnus::Error;
|
|
28
|
+
use microsandbox::sandbox::{IntoImage, RootfsSource};
|
|
29
|
+
use microsandbox::{BackendKind, MicrosandboxError};
|
|
30
|
+
|
|
31
|
+
use crate::error;
|
|
32
|
+
|
|
33
|
+
/// How the `image:` text should be handed to the builder.
|
|
34
|
+
pub enum Image {
|
|
35
|
+
/// An OCI reference, an already-absolute path, or text the core rejects —
|
|
36
|
+
/// passed through unchanged so the core classifies (or reports) it.
|
|
37
|
+
Text(String),
|
|
38
|
+
/// A relative local rootfs path, anchored to the current directory.
|
|
39
|
+
Path(PathBuf),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Resolves host paths for one create call.
|
|
43
|
+
#[derive(Clone, Copy)]
|
|
44
|
+
pub struct HostPaths {
|
|
45
|
+
local: bool,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl HostPaths {
|
|
49
|
+
/// Capture which backend the create call is about to dispatch to.
|
|
50
|
+
pub fn for_default_backend() -> Self {
|
|
51
|
+
Self {
|
|
52
|
+
local: matches!(microsandbox::default_backend().kind(), BackendKind::Local),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Anchor a relative host path to the current directory. Absolute paths
|
|
57
|
+
/// and the empty string (left for the core to reject) pass through.
|
|
58
|
+
pub fn pin(self, raw: String, label: &str) -> Result<PathBuf, Error> {
|
|
59
|
+
let path = PathBuf::from(raw);
|
|
60
|
+
if !self.local || path.as_os_str().is_empty() || path.is_absolute() {
|
|
61
|
+
return Ok(path);
|
|
62
|
+
}
|
|
63
|
+
std::path::absolute(&path).map_err(|e| {
|
|
64
|
+
error::to_ruby(MicrosandboxError::InvalidConfig(format!(
|
|
65
|
+
"cannot resolve {label} {}: {e}",
|
|
66
|
+
path.display()
|
|
67
|
+
)))
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Classify `image:` text the way the core does and pin it when it names a
|
|
72
|
+
/// relative local path (a directory rootfs or a disk image).
|
|
73
|
+
pub fn pin_image(self, text: String) -> Result<Image, Error> {
|
|
74
|
+
if !self.local {
|
|
75
|
+
return Ok(Image::Text(text));
|
|
76
|
+
}
|
|
77
|
+
let relative_local = matches!(
|
|
78
|
+
text.as_str().into_rootfs_source(),
|
|
79
|
+
Ok(RootfsSource::Bind { ref path, .. } | RootfsSource::DiskImage { ref path, .. })
|
|
80
|
+
if path.is_relative()
|
|
81
|
+
);
|
|
82
|
+
if relative_local {
|
|
83
|
+
self.pin(text, "rootfs path").map(Image::Path)
|
|
84
|
+
} else {
|
|
85
|
+
Ok(Image::Text(text))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Pin a `from_snapshot:` value when it is a path rather than a snapshot
|
|
90
|
+
/// name or digest. Uses the core's own name-vs-path split: a value that
|
|
91
|
+
/// contains `/` or starts with `.` or `~` is a path. (The core does not
|
|
92
|
+
/// expand `~`; like any other relative path it is anchored as written.)
|
|
93
|
+
pub fn pin_snapshot(self, raw: String) -> Result<String, Error> {
|
|
94
|
+
let path_like = raw.contains('/') || raw.starts_with('.') || raw.starts_with('~');
|
|
95
|
+
if !self.local || !path_like {
|
|
96
|
+
return Ok(raw);
|
|
97
|
+
}
|
|
98
|
+
self.pin(raw, "snapshot path")?
|
|
99
|
+
.into_os_string()
|
|
100
|
+
.into_string()
|
|
101
|
+
.map_err(|path| {
|
|
102
|
+
error::to_ruby(MicrosandboxError::InvalidConfig(format!(
|
|
103
|
+
"cannot resolve snapshot path {}: the current directory is not valid UTF-8",
|
|
104
|
+
PathBuf::from(path).display()
|
|
105
|
+
)))
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -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
|
|
52
|
-
//
|
|
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);
|
data/ext/microsandbox/src/lib.rs
CHANGED
|
@@ -14,6 +14,7 @@ mod conv;
|
|
|
14
14
|
mod error;
|
|
15
15
|
mod exec;
|
|
16
16
|
mod fs_stream;
|
|
17
|
+
mod host_path;
|
|
17
18
|
mod image;
|
|
18
19
|
mod runtime;
|
|
19
20
|
mod sandbox;
|
|
@@ -46,7 +47,7 @@ fn all_sandbox_metrics() -> Result<RHash, Error> {
|
|
|
46
47
|
|
|
47
48
|
/// Download and install the `msb` runtime + `libkrunfw` into `~/.microsandbox`.
|
|
48
49
|
fn install() -> Result<(), Error> {
|
|
49
|
-
runtime::block_on(microsandbox::setup::install())
|
|
50
|
+
runtime::block_on(microsandbox::setup::install())?.map_err(error::to_ruby)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/// Customizable install via the core `Setup` builder. `opts`: base_dir (install
|
|
@@ -83,7 +84,7 @@ fn setup(opts: RHash) -> Result<(), Error> {
|
|
|
83
84
|
.force(force)
|
|
84
85
|
.build(),
|
|
85
86
|
};
|
|
86
|
-
runtime::block_on(setup.install())
|
|
87
|
+
runtime::block_on(setup.install())?.map_err(error::to_ruby)
|
|
87
88
|
}
|
|
88
89
|
|
|
89
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
|
-
|
|
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 (
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|