microsandbox-rb 0.5.10 → 0.5.12
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 +29 -0
- data/Cargo.lock +1 -1
- data/ext/microsandbox/Cargo.toml +1 -1
- data/ext/microsandbox/src/runtime.rs +54 -10
- data/ext/microsandbox/src/sandbox.rs +36 -3
- data/lib/microsandbox/sandbox.rb +31 -9
- data/lib/microsandbox/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82e3bca57a41c8b7a0b03e92baf14dc84f413e89e438616a0ea48c95ea237063
|
|
4
|
+
data.tar.gz: 4b3498e04b1ea1ff2f03e706bcc401bd9a216f622b38c9bf6e2e33f96a9dfaf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cfed8fb4ae35f54dbb099fb306899c0e669f6ac7a607c283480637125f57575707534607a92604be78b8ca0135b6e1ec15869c369cfb75b809f14a70847f42d7
|
|
7
|
+
data.tar.gz: 1583a6b01336edb76312cde96b32bca2e124f2f2c815ddb26f5273930fe5e0e40f3b761e44e763a440a0d8ff368ca30cee30ee65991c1518110ad938b23ef6ba
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ upstream microsandbox runtime.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.12] - 2026-06-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **fork-safe tokio runtime.** The process-wide multi-threaded runtime is now
|
|
14
|
+
tagged with the pid it was built under and rebuilt automatically after a
|
|
15
|
+
`fork(2)`. A forking host (Solid Queue / Resque job servers, clustered Puma)
|
|
16
|
+
used to inherit a runtime whose worker + I/O-driver threads do not survive the
|
|
17
|
+
fork — `block_on` could still drive the calling thread, but background I/O (e.g.
|
|
18
|
+
the agent-relay connection that streams `exec_stream` output) never ran, so
|
|
19
|
+
long-lived operations stalled or the connection dropped mid-stream in the child.
|
|
20
|
+
`runtime()` now detects the pid change and builds a fresh runtime for the child
|
|
21
|
+
(the stale one is leaked, never dropped — dropping a runtime whose threads
|
|
22
|
+
vanished across fork can hang on the shutdown join). No API change.
|
|
23
|
+
|
|
24
|
+
## [0.5.11] - 2026-06-23
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Read-only / mount-option passthrough for volumes.** A volume spec Hash may
|
|
29
|
+
now carry `ro:`/`readonly:`, `noexec:`, `nosuid:`, `nodev:`, or an explicit
|
|
30
|
+
`options:` array, e.g. `volumes: { "/repos" => { bind: "/host/repos", ro: true } }`.
|
|
31
|
+
The Ruby layer appends a 4th comma-joined options element to the normalized
|
|
32
|
+
mount triple and the native ext applies the matching `MountBuilder` flags. RO is
|
|
33
|
+
enforced both host-side (virtiofs rejects writes) and guest-side (kernel returns
|
|
34
|
+
`EROFS`). Previously the gem could only request read-write mounts, so callers had
|
|
35
|
+
to fake read-only with host `chmod -R a-w`. Backward compatible: String specs and
|
|
36
|
+
option-less Hash specs serialize to the exact same 3-element triple as before.
|
|
37
|
+
|
|
9
38
|
## [0.5.10] - 2026-06-22
|
|
10
39
|
|
|
11
40
|
### Added
|
data/Cargo.lock
CHANGED
data/ext/microsandbox/Cargo.toml
CHANGED
|
@@ -7,7 +7,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
|
|
|
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
9
|
# The core-crate dependency below stays pinned at its own tag (v0.5.8).
|
|
10
|
-
version = "0.5.
|
|
10
|
+
version = "0.5.12"
|
|
11
11
|
authors = ["Super Rad Company <development@superrad.company>"]
|
|
12
12
|
repository = "https://github.com/superradcompany/microsandbox"
|
|
13
13
|
license = "Apache-2.0"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
use std::ffi::c_void;
|
|
10
10
|
use std::future::Future;
|
|
11
11
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
12
|
-
use std::sync::
|
|
12
|
+
use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
|
|
13
|
+
use std::sync::Mutex;
|
|
13
14
|
|
|
14
15
|
use magnus::Ruby;
|
|
15
16
|
use tokio::runtime::Runtime;
|
|
@@ -21,17 +22,60 @@ pub fn ruby() -> Ruby {
|
|
|
21
22
|
Ruby::get().expect("microsandbox: not on a Ruby thread")
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// The process-wide tokio runtime, guarded by the pid it was built under. A
|
|
26
|
+
// multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
|
|
27
|
+
// ONLY the calling thread — so a child that inherits this runtime has a runtime
|
|
28
|
+
// whose threads are gone: `block_on` can still drive the current thread, but the
|
|
29
|
+
// background I/O that keeps e.g. the agent-relay connection alive never runs, so
|
|
30
|
+
// connections stall/drop mid-use. This is hit in practice by forking job servers
|
|
31
|
+
// (Solid Queue, Resque) and clustered web servers (Puma workers).
|
|
32
|
+
//
|
|
33
|
+
// Fix: store the runtime behind a leaked raw pointer tagged with the building
|
|
34
|
+
// pid. If `runtime()` is called in a process whose pid differs (we forked), build
|
|
35
|
+
// a FRESH runtime for this process and swap the pointer. The stale runtime is
|
|
36
|
+
// LEAKED, never dropped — dropping a tokio runtime whose worker threads vanished
|
|
37
|
+
// across fork can hang on the shutdown thread-join. One runtime is leaked per
|
|
38
|
+
// process that uses the SDK (bounded; equivalent to the old set-once behavior in
|
|
39
|
+
// the common no-fork case).
|
|
40
|
+
static RUNTIME_PTR: AtomicPtr<Runtime> = AtomicPtr::new(std::ptr::null_mut());
|
|
41
|
+
static RUNTIME_PID: AtomicU32 = AtomicU32::new(0);
|
|
42
|
+
static RUNTIME_LOCK: Mutex<()> = Mutex::new(());
|
|
25
43
|
|
|
26
|
-
|
|
44
|
+
fn build_runtime() -> Runtime {
|
|
45
|
+
tokio::runtime::Builder::new_multi_thread()
|
|
46
|
+
.enable_all()
|
|
47
|
+
.thread_name("microsandbox-rb")
|
|
48
|
+
.build()
|
|
49
|
+
.expect("microsandbox: failed to build tokio runtime")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// The multi-threaded tokio runtime for THIS process, built on first use and
|
|
53
|
+
/// rebuilt after a `fork(2)` (fork-safe — see the note above).
|
|
27
54
|
pub fn runtime() -> &'static Runtime {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
55
|
+
let cur = std::process::id();
|
|
56
|
+
let ptr = RUNTIME_PTR.load(Ordering::Acquire);
|
|
57
|
+
if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
|
|
58
|
+
// SAFETY: ptr was produced by Box::leak (valid for 'static) and matches
|
|
59
|
+
// this process's pid, so its runtime threads are live in this process.
|
|
60
|
+
return unsafe { &*ptr };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let _guard = RUNTIME_LOCK
|
|
64
|
+
.lock()
|
|
65
|
+
.expect("microsandbox: runtime lock poisoned");
|
|
66
|
+
// Re-check under the lock (another thread may have just built it).
|
|
67
|
+
let ptr = RUNTIME_PTR.load(Ordering::Acquire);
|
|
68
|
+
if !ptr.is_null() && RUNTIME_PID.load(Ordering::Acquire) == cur {
|
|
69
|
+
return unsafe { &*ptr };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build a fresh runtime for this process and publish it. The previous pointer
|
|
73
|
+
// (if any) belonged to a parent process and is intentionally leaked, not
|
|
74
|
+
// dropped (its threads are gone post-fork; Drop would block on join).
|
|
75
|
+
let rt: &'static Runtime = Box::leak(Box::new(build_runtime()));
|
|
76
|
+
RUNTIME_PTR.store(rt as *const Runtime as *mut Runtime, Ordering::Release);
|
|
77
|
+
RUNTIME_PID.store(cur, Ordering::Release);
|
|
78
|
+
rt
|
|
35
79
|
}
|
|
36
80
|
|
|
37
81
|
/// Run `f` with the Ruby GVL released.
|
|
@@ -89,9 +89,11 @@ impl Sandbox {
|
|
|
89
89
|
for (host, guest) in conv::opt_port_map(opts, "ports")? {
|
|
90
90
|
b = b.port(host, guest);
|
|
91
91
|
}
|
|
92
|
-
// volumes: normalized by the Ruby layer to [guest, kind, source] triples
|
|
92
|
+
// volumes: normalized by the Ruby layer to [guest, kind, source] triples,
|
|
93
|
+
// or [guest, kind, source, options] quads where options is a comma-joined
|
|
94
|
+
// list (ro/readonly, rw, noexec, nosuid, nodev).
|
|
93
95
|
for spec in conv::opt::<Vec<Vec<String>>>(opts, "volumes")?.unwrap_or_default() {
|
|
94
|
-
if spec.len()
|
|
96
|
+
if spec.len() < 3 || spec.len() > 4 {
|
|
95
97
|
return Err(error::base_error("invalid volume mount spec"));
|
|
96
98
|
}
|
|
97
99
|
let (guest, kind, source) = (spec[0].clone(), spec[1].clone(), spec[2].clone());
|
|
@@ -103,12 +105,43 @@ impl Sandbox {
|
|
|
103
105
|
)))
|
|
104
106
|
}
|
|
105
107
|
}
|
|
108
|
+
// Validate options up front (the volume closure cannot return an error).
|
|
109
|
+
let mount_opts: Vec<String> = spec
|
|
110
|
+
.get(3)
|
|
111
|
+
.map(|s| {
|
|
112
|
+
s.split(',')
|
|
113
|
+
.map(|o| o.trim().to_string())
|
|
114
|
+
.filter(|o| !o.is_empty())
|
|
115
|
+
.collect()
|
|
116
|
+
})
|
|
117
|
+
.unwrap_or_default();
|
|
118
|
+
for opt in &mount_opts {
|
|
119
|
+
match opt.as_str() {
|
|
120
|
+
"ro" | "readonly" | "rw" | "noexec" | "nosuid" | "nodev" => {}
|
|
121
|
+
other => {
|
|
122
|
+
return Err(error::base_error(format!(
|
|
123
|
+
"unknown volume mount option {other:?} \
|
|
124
|
+
(expected ro/rw/noexec/nosuid/nodev)"
|
|
125
|
+
)))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
106
129
|
b = b.volume(guest, move |m| {
|
|
107
|
-
if kind == "named" {
|
|
130
|
+
let mut m = if kind == "named" {
|
|
108
131
|
m.named(source)
|
|
109
132
|
} else {
|
|
110
133
|
m.bind(source)
|
|
134
|
+
};
|
|
135
|
+
for opt in &mount_opts {
|
|
136
|
+
m = match opt.as_str() {
|
|
137
|
+
"ro" | "readonly" => m.readonly(),
|
|
138
|
+
"noexec" => m.noexec(),
|
|
139
|
+
"nosuid" => m.nosuid(),
|
|
140
|
+
"nodev" => m.nodev(),
|
|
141
|
+
_ => m, // "rw" — default; already validated above
|
|
142
|
+
};
|
|
111
143
|
}
|
|
144
|
+
m
|
|
112
145
|
});
|
|
113
146
|
}
|
|
114
147
|
// patches: rootfs modifications applied before boot. The Ruby layer
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -387,8 +387,10 @@ module Microsandbox
|
|
|
387
387
|
end
|
|
388
388
|
|
|
389
389
|
# Normalize volumes (Hash of guest_path => spec) into [guest, kind, source]
|
|
390
|
-
# triples for the native layer. A
|
|
391
|
-
#
|
|
390
|
+
# triples (or [guest, kind, source, options] quads) for the native layer. A
|
|
391
|
+
# spec is a host path String (read-write bind mount), or a Hash
|
|
392
|
+
# { bind: "/host" } / { named: "volume-name" } optionally carrying mount
|
|
393
|
+
# options: { bind: "/host", ro: true } / { named: "v", options: %w[ro noexec] }.
|
|
392
394
|
def normalize_volumes(volumes)
|
|
393
395
|
volumes.map do |guest, spec|
|
|
394
396
|
guest = guest.to_s
|
|
@@ -396,19 +398,39 @@ module Microsandbox
|
|
|
396
398
|
when String
|
|
397
399
|
[guest, "bind", spec]
|
|
398
400
|
when Hash
|
|
399
|
-
|
|
400
|
-
[
|
|
401
|
-
|
|
402
|
-
[
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
401
|
+
triple =
|
|
402
|
+
if (named = spec[:named] || spec["named"])
|
|
403
|
+
[guest, "named", named.to_s]
|
|
404
|
+
elsif (bind = spec[:bind] || spec["bind"])
|
|
405
|
+
[guest, "bind", bind.to_s]
|
|
406
|
+
else
|
|
407
|
+
raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
|
|
408
|
+
end
|
|
409
|
+
# Optional 4th element: comma-joined mount options. Omitted when empty
|
|
410
|
+
# so existing [guest,kind,source] consumers and the common read-write
|
|
411
|
+
# case are byte-for-byte unchanged.
|
|
412
|
+
opts = mount_options(spec)
|
|
413
|
+
opts.empty? ? triple : triple + [opts.join(",")]
|
|
406
414
|
else
|
|
407
415
|
raise ArgumentError, "invalid volume spec for #{guest.inspect}: #{spec.inspect}"
|
|
408
416
|
end
|
|
409
417
|
end
|
|
410
418
|
end
|
|
411
419
|
|
|
420
|
+
# Collect mount options from a volume spec Hash. `ro:`/`readonly:` makes the
|
|
421
|
+
# mount read-only (host virtiofs rejects writes + guest kernel returns EROFS);
|
|
422
|
+
# `noexec:`/`nosuid:`/`nodev:` set the matching flags; an explicit `options:`
|
|
423
|
+
# array passes through verbatim. Unknown options are rejected natively.
|
|
424
|
+
def mount_options(spec)
|
|
425
|
+
opts = []
|
|
426
|
+
opts << "ro" if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
|
|
427
|
+
opts << "noexec" if spec[:noexec] || spec["noexec"]
|
|
428
|
+
opts << "nosuid" if spec[:nosuid] || spec["nosuid"]
|
|
429
|
+
opts << "nodev" if spec[:nodev] || spec["nodev"]
|
|
430
|
+
Array(spec[:options] || spec["options"]).each { |o| opts << o.to_s }
|
|
431
|
+
opts.uniq
|
|
432
|
+
end
|
|
433
|
+
|
|
412
434
|
# Normalize a list of patches (each a Hash from the {Patch} factory, or a
|
|
413
435
|
# plain Hash) into string-keyed Hashes for the native layer. Values are
|
|
414
436
|
# passed through unchanged (mode stays Integer, content stays String).
|
data/lib/microsandbox/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Microsandbox
|
|
|
5
5
|
# the pinned core-crate tag); the patch segment advances for gem-only revisions
|
|
6
6
|
# that add bindings atop the same core. Must equal the native ext's Cargo crate
|
|
7
7
|
# version (`Native.version`), enforced by spec/unit/version_spec.rb.
|
|
8
|
-
VERSION = "0.5.
|
|
8
|
+
VERSION = "0.5.12"
|
|
9
9
|
end
|