mxc 0.1.0

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/Cargo.toml ADDED
@@ -0,0 +1,6 @@
1
+ [workspace]
2
+ members = ["./ext/mxc"]
3
+ resolver = "2"
4
+
5
+ [profile.release]
6
+ strip = "debuginfo"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carmine Paolino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NOTICE ADDED
@@ -0,0 +1,34 @@
1
+ MXC for Ruby
2
+ Copyright (c) 2026 Carmine Paolino
3
+
4
+ This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
5
+ endorsed by Microsoft.
6
+
7
+ The native extension compiles and links the mxc-sdk crate
8
+ (https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk),
9
+ which is distributed under the MIT License:
10
+
11
+ MIT License
12
+
13
+ Copyright (c) Microsoft Corporation.
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+
33
+ The native extension also links other open source Rust crates, listed with
34
+ their versions in Cargo.lock. Their licenses are available from crates.io.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ <div align="center">
2
+
3
+ # MXC for Ruby
4
+
5
+ <strong>Run untrusted commands in an OS sandbox, from Ruby</strong>
6
+
7
+ [Documentation](https://crmne.github.io/mxc/) · [Getting Started](https://crmne.github.io/mxc/getting-started/) · [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
8
+
9
+ [![Gem Version](https://badge.fury.io/rb/mxc.svg)](https://badge.fury.io/rb/mxc)
10
+ [![CI](https://github.com/crmne/mxc/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/mxc/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+
13
+ </div>
14
+
15
+ ---
16
+
17
+ Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, and how long they get.
18
+
19
+ ```ruby
20
+ result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
21
+ result.stdout
22
+ result.success?
23
+ ```
24
+
25
+ It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft.
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ bundle add mxc
31
+ ```
32
+
33
+ Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel and Apple Silicon), and Windows (x64 and ARM64). On Linux, install Bubblewrap:
34
+
35
+ ```sh
36
+ sudo apt-get install bubblewrap
37
+ ```
38
+
39
+ Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
40
+
41
+ ## Usage
42
+
43
+ Run a command. It sees the system's programs read-only, nothing of your home directory or project, and no network:
44
+
45
+ ```ruby
46
+ MXC.run("python3 -c 'print(6 * 7)'").stdout # => "42\n"
47
+ ```
48
+
49
+ One String runs through the shell, like `Kernel#system`. Pass the program and its arguments separately when part of the command comes from users, so each one reaches the program unchanged:
50
+
51
+ ```ruby
52
+ MXC.run("python3", "convert.py", params[:filename])
53
+ ```
54
+
55
+ Build a policy with chainable setters:
56
+
57
+ ```ruby
58
+ sandbox = MXC.sandbox
59
+ .with_readable("app/skills")
60
+ .with_writable(Rails.root.join("tmp/skills"))
61
+ .with_denied("app/skills/secrets")
62
+ .with_network(allow: ["pypi.org", "*.pythonhosted.org"])
63
+ .with_env("LANG" => "C.UTF-8")
64
+ .with_directory("app/skills")
65
+ .with_timeout(30)
66
+
67
+ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
68
+ result.blocked # => hosts the allow-list refused
69
+ ```
70
+
71
+ Stream output as it arrives:
72
+
73
+ ```ruby
74
+ sandbox.run("python3 train.py") { |chunk| print chunk.content }
75
+ ```
76
+
77
+ Drive an interactive process:
78
+
79
+ ```ruby
80
+ process = sandbox.spawn("python3 -i -q")
81
+ process.write("print(6 * 7)\n")
82
+ process.close_write
83
+ process.each { |chunk| print chunk.content }
84
+ process.wait # => #<MXC::Result exit_code=0 timed_out=false backend=:bubblewrap>
85
+ ```
86
+
87
+ Name a policy you use in several places:
88
+
89
+ ```ruby
90
+ class SkillSandbox < MXC::Sandbox
91
+ readable "app/skills"
92
+ writable "tmp/skills"
93
+ network false
94
+ timeout 30
95
+ end
96
+
97
+ SkillSandbox.new.run("python3 app/skills/build.py")
98
+ ```
99
+
100
+ Check the machine before you rely on it:
101
+
102
+ ```ruby
103
+ MXC.supported? # starts a real sandbox once and caches the answer
104
+ MXC.availability.remediation # what to change when it cannot
105
+ MXC.supports?(:network_policy) # host allow-lists work here
106
+ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
107
+ ```
108
+
109
+ ## Platforms
110
+
111
+ | Platform | Backend | 0.1.0 |
112
+ | --- | --- | --- |
113
+ | Linux | Bubblewrap | Supported and tested |
114
+ | macOS | Seatbelt | Supported and tested |
115
+ | Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
116
+
117
+ On Windows, a single String runs through `cmd.exe` instead of `sh`. See [Running Commands](https://crmne.github.io/mxc/running-commands/#windows) and [Policies](https://crmne.github.io/mxc/policies/#windows) for what else differs.
118
+
119
+ Docker blocks the namespaces Bubblewrap needs by default. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows the smallest set of changes that makes it work, with a seccomp profile and a Kamal configuration for a job role.
120
+
121
+ ## Documentation
122
+
123
+ * [Getting Started](https://crmne.github.io/mxc/getting-started/)
124
+ * [Running Commands](https://crmne.github.io/mxc/running-commands/)
125
+ * [Policies](https://crmne.github.io/mxc/policies/)
126
+ * [Checking the Host](https://crmne.github.io/mxc/checking-the-host/)
127
+ * [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ bin/setup # bundle install and compile the extension
133
+ bundle exec rake # compile, tests, Rust unit tests, RuboCop
134
+ ```
135
+
136
+ Tests that start sandboxes skip on machines that cannot. See [CONTRIBUTING.md](CONTRIBUTING.md).
137
+
138
+ ## License
139
+
140
+ MIT. The native extension bundles Microsoft's MXC, also MIT; see [NOTICE](NOTICE).
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "mxc"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ authors = ["Carmine Paolino <carmine@paolino.me>"]
6
+ license = "MIT"
7
+ publish = false
8
+
9
+ [lib]
10
+ crate-type = ["cdylib"]
11
+
12
+ [dependencies]
13
+ magnus = "0.8.2"
14
+ mxc-sdk = "=1.0.0"
15
+ rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
16
+
17
+ [build-dependencies]
18
+ rb-sys-env = "0.2.2"
data/ext/mxc/build.rs ADDED
@@ -0,0 +1,28 @@
1
+ use std::env;
2
+ use std::fs;
3
+ use std::path::PathBuf;
4
+
5
+ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
6
+ let _ = rb_sys_env::activate()?;
7
+
8
+ if env::var("CARGO_CFG_TARGET_OS")? == "windows" && env::var("CARGO_CFG_TARGET_ENV")? != "msvc"
9
+ {
10
+ stand_in_for_onecore_apiset()?;
11
+ }
12
+
13
+ Ok(())
14
+ }
15
+
16
+ // tracelogging, which mxc-sdk uses on Windows, links its ETW functions from
17
+ // OneCore_apiset. The MinGW cross-compilers that build the release gems either
18
+ // lack that import library or name it in lowercase, which a linker on Linux
19
+ // does not match. advapi32 exports the same functions, so an empty archive
20
+ // satisfies the -lOneCore_apiset request and advapi32, linked last, provides
21
+ // them.
22
+ fn stand_in_for_onecore_apiset() -> Result<(), Box<dyn std::error::Error>> {
23
+ let out_dir = PathBuf::from(env::var("OUT_DIR")?);
24
+ fs::write(out_dir.join("libOneCore_apiset.a"), b"!<arch>\n")?;
25
+ println!("cargo:rustc-link-search=native={}", out_dir.display());
26
+ println!("cargo:rustc-link-arg=-ladvapi32");
27
+ Ok(())
28
+ }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'rb_sys/mkmf'
5
+
6
+ create_rust_makefile('mxc/mxc') do |r|
7
+ if Gem.win_platform?
8
+ # RubyInstaller builds Ruby with MinGW, so the extension must use Rust's GNU
9
+ # target even when rustup's default host is MSVC.
10
+ unless ENV.key?('CARGO_BUILD_TARGET') || ENV.key?('RUST_TARGET')
11
+ r.target = RbConfig::CONFIG['host_cpu'] == 'aarch64' ? 'aarch64-pc-windows-gnullvm' : 'x86_64-pc-windows-gnu'
12
+ end
13
+
14
+ # mxc-sdk's build script compiles version resources for its own Windows
15
+ # executables with rc.exe, which it finds only for MSVC targets. This gem
16
+ # never builds those executables, so a resource compiler that does nothing
17
+ # lets the extension build without the Windows SDK.
18
+ unless ENV.key?('RC_PATH')
19
+ stub = File.expand_path('rc-stub.cmd')
20
+ File.write(stub, "@exit /b 0\r\n")
21
+ r.env = { 'RC_PATH' => stub }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,384 @@
1
+ use std::ffi::c_void;
2
+ use std::io::{Read, Write};
3
+ use std::sync::Mutex;
4
+ use std::time::{Duration, Instant};
5
+
6
+ use magnus::{
7
+ function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby, Symbol,
8
+ };
9
+ use mxc_sdk::v1::{
10
+ self,
11
+ configs::{ProcessContainerConfig, SeatbeltConfig},
12
+ Containment, ContainerRequest, FilesystemPolicy, MxcProcess, NetworkAction,
13
+ NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig, StreamCloser, WaitResult,
14
+ };
15
+
16
+ mod proxy;
17
+
18
+ use proxy::{Allowlist, EgressProxy};
19
+
20
+ const MAX_POLL: Duration = Duration::from_millis(50);
21
+
22
+ fn without_gvl<F, R>(f: F) -> R
23
+ where
24
+ F: FnOnce() -> R,
25
+ {
26
+ struct Payload<F, R> {
27
+ f: Option<F>,
28
+ r: Option<std::thread::Result<R>>,
29
+ }
30
+ extern "C" fn call<F: FnOnce() -> R, R>(data: *mut c_void) -> *mut c_void {
31
+ let payload = unsafe { &mut *(data as *mut Payload<F, R>) };
32
+ let f = payload.f.take().unwrap();
33
+ payload.r = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
34
+ std::ptr::null_mut()
35
+ }
36
+ let mut payload = Payload { f: Some(f), r: None };
37
+ unsafe {
38
+ rb_sys::rb_thread_call_without_gvl(
39
+ Some(call::<F, R>),
40
+ &mut payload as *mut _ as *mut c_void,
41
+ None,
42
+ std::ptr::null_mut(),
43
+ );
44
+ }
45
+ match payload.r.unwrap() {
46
+ Ok(r) => r,
47
+ Err(panic) => std::panic::resume_unwind(panic),
48
+ }
49
+ }
50
+
51
+ fn native_module(ruby: &Ruby) -> Result<RModule, Error> {
52
+ let mxc: RModule = ruby.class_object().const_get("MXC")?;
53
+ mxc.const_get("Native")
54
+ }
55
+
56
+ fn mxc_error(ruby: &Ruby, e: v1::Error) -> Error {
57
+ let build = || -> Result<magnus::Exception, Error> {
58
+ let kw = ruby.hash_new();
59
+ kw.aset(ruby.to_symbol("code"), ruby.to_symbol(e.code.as_str()))?;
60
+ kw.aset(ruby.to_symbol("remediation"), e.remediation.clone())?;
61
+ kw.aset(ruby.to_symbol("operation"), e.operation.clone())?;
62
+ kw.aset(ruby.to_symbol("native_code"), e.native_code.clone())?;
63
+ native_module(ruby)?.funcall("error", (e.message.clone(), magnus::KwArgs(kw)))
64
+ };
65
+ match build() {
66
+ Ok(exc) => Error::from(exc),
67
+ Err(err) => err,
68
+ }
69
+ }
70
+
71
+ fn io_error(ruby: &Ruby, e: std::io::Error) -> Error {
72
+ Error::new(ruby.exception_io_error(), e.to_string())
73
+ }
74
+
75
+ fn arg_error(ruby: &Ruby, message: String) -> Error {
76
+ Error::new(ruby.exception_arg_error(), message)
77
+ }
78
+
79
+ fn opt<T: magnus::TryConvert>(ruby: &Ruby, h: RHash, key: &str) -> Result<Option<T>, Error> {
80
+ h.lookup::<_, Option<T>>(ruby.to_symbol(key))
81
+ }
82
+
83
+ fn deny_all() -> NetworkEgressPolicy {
84
+ NetworkEgressPolicy {
85
+ default: Some(NetworkAction::Deny),
86
+ ..Default::default()
87
+ }
88
+ }
89
+
90
+ fn build_request(
91
+ ruby: &Ruby,
92
+ command: String,
93
+ opts: RHash,
94
+ ) -> Result<(ContainerRequest, Option<EgressProxy>), Error> {
95
+ let mut req = ContainerRequest::new(command);
96
+ req.timeout_ms = opt::<u32>(ruby, opts, "timeout_ms")?;
97
+ req.working_directory = opt::<String>(ruby, opts, "working_directory")?;
98
+ if let Some(env) = opt::<RHash>(ruby, opts, "environment")? {
99
+ let mut pairs = Vec::new();
100
+ env.foreach(|k: String, v: String| {
101
+ pairs.push((k, v));
102
+ Ok(magnus::r_hash::ForEach::Continue)
103
+ })?;
104
+ req.environment = Some(pairs);
105
+ }
106
+ req.filesystem = Some(FilesystemPolicy {
107
+ readonly_paths: opt::<Vec<String>>(ruby, opts, "readonly")?.unwrap_or_default(),
108
+ readwrite_paths: opt::<Vec<String>>(ruby, opts, "readwrite")?.unwrap_or_default(),
109
+ denied_paths: opt::<Vec<String>>(ruby, opts, "denied")?.unwrap_or_default(),
110
+ ..Default::default()
111
+ });
112
+
113
+ let mut egress_proxy = None;
114
+ let egress = match opt::<Vec<String>>(ruby, opts, "allow")? {
115
+ Some(hosts) => {
116
+ let allowlist = Allowlist::parse(&hosts).map_err(|m| arg_error(ruby, m))?;
117
+ let started = EgressProxy::start(allowlist).map_err(|e| io_error(ruby, e))?;
118
+ let runtime_config = NetworkRuntimeConfig {
119
+ network_proxy: Some(started.url()),
120
+ };
121
+ egress_proxy = Some(started);
122
+ NetworkPolicy {
123
+ egress: Some(deny_all()),
124
+ runtime_config: Some(runtime_config),
125
+ ..Default::default()
126
+ }
127
+ }
128
+ None if opt::<bool>(ruby, opts, "network")?.unwrap_or(false) => NetworkPolicy {
129
+ egress: Some(NetworkEgressPolicy {
130
+ default: Some(NetworkAction::Allow),
131
+ ..Default::default()
132
+ }),
133
+ ..Default::default()
134
+ },
135
+ None => NetworkPolicy {
136
+ egress: Some(deny_all()),
137
+ ..Default::default()
138
+ },
139
+ };
140
+ req.network = Some(egress);
141
+
142
+ if let Some(backend) = opt::<Symbol>(ruby, opts, "backend")? {
143
+ req.containment = match backend.name()?.as_ref() {
144
+ "auto" => Containment::Process,
145
+ "bubblewrap" => Containment::Bubblewrap,
146
+ "seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
147
+ "process_container" => {
148
+ Containment::ProcessContainer(ProcessContainerConfig::default())
149
+ }
150
+ other => return Err(arg_error(ruby, format!("unknown backend :{other}"))),
151
+ };
152
+ }
153
+ Ok((req, egress_proxy))
154
+ }
155
+
156
+ fn outcome_hash(ruby: &Ruby, outcome: WaitResult) -> Result<RHash, Error> {
157
+ let h = ruby.hash_new();
158
+ match outcome {
159
+ WaitResult::Exited(code) => {
160
+ h.aset(ruby.to_symbol("timed_out"), false)?;
161
+ h.aset(ruby.to_symbol("exit_code"), code)?;
162
+ }
163
+ WaitResult::TimedOut => {
164
+ h.aset(ruby.to_symbol("timed_out"), true)?;
165
+ h.aset(ruby.to_symbol("exit_code"), ruby.qnil())?;
166
+ }
167
+ }
168
+ Ok(h)
169
+ }
170
+
171
+ fn snake_case(name: &str) -> String {
172
+ let mut out = String::new();
173
+ for (i, c) in name.chars().enumerate() {
174
+ if c.is_ascii_uppercase() {
175
+ if i > 0 {
176
+ out.push('_');
177
+ }
178
+ out.push(c.to_ascii_lowercase());
179
+ } else {
180
+ out.push(c);
181
+ }
182
+ }
183
+ out
184
+ }
185
+
186
+ fn backends(ruby: &Ruby) -> Result<RArray, Error> {
187
+ let found = without_gvl(v1::available_backends);
188
+ let arr = ruby.ary_new();
189
+ for b in found {
190
+ let h = ruby.hash_new();
191
+ h.aset(ruby.to_symbol("name"), ruby.to_symbol(&b.backend))?;
192
+ h.aset(ruby.to_symbol("tier"), b.tier)?;
193
+ let capabilities = ruby.ary_new();
194
+ for c in &b.capabilities {
195
+ capabilities.push(ruby.to_symbol(snake_case(&format!("{c:?}"))))?;
196
+ }
197
+ h.aset(ruby.to_symbol("capabilities"), capabilities)?;
198
+ h.aset(ruby.to_symbol("warnings"), b.warnings)?;
199
+ arr.push(h)?;
200
+ }
201
+ Ok(arr)
202
+ }
203
+
204
+ fn run(ruby: &Ruby, command: String, opts: RHash) -> Result<RHash, Error> {
205
+ let (req, mut egress_proxy) = build_request(ruby, command, opts)?;
206
+ let result = without_gvl(move || v1::run(req, Default::default()));
207
+ let blocked = egress_proxy.as_mut().map(|p| {
208
+ p.stop();
209
+ p.blocked()
210
+ });
211
+ let r = result.map_err(|e| mxc_error(ruby, e))?;
212
+ let h = outcome_hash(ruby, r.outcome)?;
213
+ h.aset(ruby.to_symbol("stdout"), ruby.str_from_slice(&r.stdout))?;
214
+ h.aset(ruby.to_symbol("stderr"), ruby.str_from_slice(&r.stderr))?;
215
+ h.aset(ruby.to_symbol("warnings"), r.warnings)?;
216
+ h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
217
+ Ok(h)
218
+ }
219
+
220
+ type Reader = Mutex<Option<Box<dyn Read + Send>>>;
221
+
222
+ #[magnus::wrap(class = "MXC::Native::Process", free_immediately, size)]
223
+ struct Process {
224
+ pid: u32,
225
+ deadline: Option<Instant>,
226
+ child: Mutex<Option<MxcProcess>>,
227
+ stdin: Mutex<Option<Box<dyn Write + Send>>>,
228
+ stdout: Reader,
229
+ stderr: Reader,
230
+ stdout_closer: Option<StreamCloser>,
231
+ stderr_closer: Option<StreamCloser>,
232
+ egress_proxy: Mutex<Option<EgressProxy>>,
233
+ }
234
+
235
+ fn spawn(ruby: &Ruby, command: String, opts: RHash) -> Result<Process, Error> {
236
+ let (req, egress_proxy) = build_request(ruby, command, opts)?;
237
+ let timeout = req.timeout_ms.map(|ms| Duration::from_millis(ms.into()));
238
+ let mut child = without_gvl(move || v1::spawn(req, Default::default()))
239
+ .map_err(|e| mxc_error(ruby, e))?;
240
+ Ok(Process {
241
+ pid: child.id(),
242
+ deadline: timeout.map(|t| Instant::now() + t),
243
+ stdin: Mutex::new(child.take_stdin()),
244
+ stdout: Mutex::new(child.take_stdout()),
245
+ stderr: Mutex::new(child.take_stderr()),
246
+ stdout_closer: child.stdout_closer(),
247
+ stderr_closer: child.stderr_closer(),
248
+ child: Mutex::new(Some(child)),
249
+ egress_proxy: Mutex::new(egress_proxy),
250
+ })
251
+ }
252
+
253
+ fn read_from(ruby: &Ruby, stream: &Reader, max: usize) -> Result<Option<RString>, Error> {
254
+ let res = without_gvl(|| {
255
+ let mut guard = stream.lock().unwrap();
256
+ match guard.as_mut() {
257
+ None => Ok(None),
258
+ Some(r) => {
259
+ let mut buf = vec![0u8; max];
260
+ let n = r.read(&mut buf)?;
261
+ buf.truncate(n);
262
+ if n == 0 {
263
+ guard.take();
264
+ }
265
+ Ok(Some(buf))
266
+ }
267
+ }
268
+ });
269
+ match res {
270
+ Ok(Some(buf)) if !buf.is_empty() => Ok(Some(ruby.str_from_slice(&buf))),
271
+ Ok(_) => Ok(None),
272
+ Err(e) => Err(io_error(ruby, e)),
273
+ }
274
+ }
275
+
276
+ impl Process {
277
+ fn pid(&self) -> u32 {
278
+ self.pid
279
+ }
280
+
281
+ fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
282
+ read_from(ruby, &rb_self.stdout, max)
283
+ }
284
+
285
+ fn read_stderr(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
286
+ read_from(ruby, &rb_self.stderr, max)
287
+ }
288
+
289
+ fn write(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<usize, Error> {
290
+ let bytes = unsafe { data.as_slice() }.to_vec();
291
+ let res = without_gvl(|| {
292
+ let mut guard = rb_self.stdin.lock().unwrap();
293
+ match guard.as_mut() {
294
+ None => Err(std::io::Error::other("stdin is closed")),
295
+ Some(w) => w
296
+ .write_all(&bytes)
297
+ .and_then(|_| w.flush())
298
+ .map(|_| bytes.len()),
299
+ }
300
+ });
301
+ res.map_err(|e| io_error(ruby, e))
302
+ }
303
+
304
+ fn close_stdin(&self) {
305
+ self.stdin.lock().unwrap().take();
306
+ }
307
+
308
+ fn kill(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
309
+ let mut guard = rb_self.child.lock().unwrap();
310
+ match guard.as_mut() {
311
+ Some(c) => c.kill().map_err(|e| io_error(ruby, e)),
312
+ None => Ok(()),
313
+ }
314
+ }
315
+
316
+ // MxcProcess::kill and ::wait both need the handle exclusively, so waiting
317
+ // polls and releases the lock between polls to let kill in.
318
+ fn await_exit(&self) -> std::io::Result<(WaitResult, Vec<String>)> {
319
+ let mut nap = Duration::from_millis(1);
320
+ loop {
321
+ {
322
+ let mut guard = self.child.lock().unwrap();
323
+ let child = guard
324
+ .as_mut()
325
+ .ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
326
+ let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
327
+ if expired || child.try_wait()?.is_some() {
328
+ let outcome = child.wait()?;
329
+ let warnings = child.warnings();
330
+ guard.take();
331
+ return Ok((outcome, warnings));
332
+ }
333
+ }
334
+ let mut sleep = nap;
335
+ if let Some(deadline) = self.deadline {
336
+ sleep = sleep.min(deadline.saturating_duration_since(Instant::now()));
337
+ }
338
+ std::thread::sleep(sleep);
339
+ nap = (nap * 2).min(MAX_POLL);
340
+ }
341
+ }
342
+
343
+ fn wait(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
344
+ let res = without_gvl(|| rb_self.await_exit());
345
+ let blocked = match rb_self.egress_proxy.lock().unwrap().take() {
346
+ Some(mut p) => {
347
+ p.stop();
348
+ p.blocked()
349
+ }
350
+ None => Vec::new(),
351
+ };
352
+ let (outcome, warnings) = res.map_err(|e| io_error(ruby, e))?;
353
+ if !matches!(outcome, WaitResult::Exited(_)) {
354
+ if let Some(c) = &rb_self.stdout_closer {
355
+ c.close();
356
+ }
357
+ if let Some(c) = &rb_self.stderr_closer {
358
+ c.close();
359
+ }
360
+ }
361
+ let h = outcome_hash(ruby, outcome)?;
362
+ h.aset(ruby.to_symbol("warnings"), warnings)?;
363
+ h.aset(ruby.to_symbol("blocked"), blocked)?;
364
+ Ok(h)
365
+ }
366
+ }
367
+
368
+ #[magnus::init]
369
+ fn init(ruby: &Ruby) -> Result<(), Error> {
370
+ let mxc = ruby.define_module("MXC")?;
371
+ let native = mxc.define_module("Native")?;
372
+ native.define_singleton_method("backends", function!(backends, 0))?;
373
+ native.define_singleton_method("run", function!(run, 2))?;
374
+ native.define_singleton_method("spawn", function!(spawn, 2))?;
375
+ let process = native.define_class("Process", ruby.class_object())?;
376
+ process.define_method("pid", method!(Process::pid, 0))?;
377
+ process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
378
+ process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
379
+ process.define_method("write", method!(Process::write, 1))?;
380
+ process.define_method("close_stdin", method!(Process::close_stdin, 0))?;
381
+ process.define_method("kill", method!(Process::kill, 0))?;
382
+ process.define_method("wait", method!(Process::wait, 0))?;
383
+ Ok(())
384
+ }