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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fbc8d09d8029bef1ec00d128bf554b037c937e51ebe8b9b8a6014043fce178e
4
- data.tar.gz: 626313c0471102e108793f3880bcd1f1e30ba9f48470274bf53495a86cfadff7
3
+ metadata.gz: f76670a6753a6503a0764ed173db3c33a1eff9a62de7a7d9343bc76f8143c1ba
4
+ data.tar.gz: 80d4d4f1c2bbb3f47dd8f714602e6b01a841d62971b70b5682d3ad0c7dcff904
5
5
  SHA512:
6
- metadata.gz: 49461a4a9ca44f5af82d62fe30086bd4b153d3885267274fce7de8328b071cb418055f202b3b2feea3f149d76572a2a78874596c707ac094d62f7cc5171269aa
7
- data.tar.gz: 929b0a19b88eb839d371899191e6a0229eded4eb616100dbd8d316352eac3bf68dcfefa91fb04c4ce41ad540deaa41233c1533d2471222bbf4772d8708451be6
6
+ metadata.gz: 74429f29417c1b2c2e6aeee35948d3c5201d5ac9d001beba4efceca484dbea53f0041e84f0f7fa0fef5933a89f3e317737fb134d6329267eb5e239fe56d7e297
7
+ data.tar.gz: d4d5072d1a68079a8bfe199e8c53e0d92e7614515e1326e81333f857c1b53b681575f8df2162abb167ea6228841ffba2631f4d2b8df38552040c946966223c2c
data/CHANGELOG.md CHANGED
@@ -8,6 +8,81 @@ wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.17.2] - 2026-10-12
12
+
13
+ Gem-only fix and release-pipeline hardening release; the embedded runtime
14
+ stays at **`v0.6.18`**. One behaviour change is visible: under
15
+ `Thread.handle_interrupt(X => :on_blocking)` a native call now raises an
16
+ already-deferred exception before it starts instead of after it finishes,
17
+ except in the four sandbox-acquisition methods, which deliver it after the
18
+ acquisition (see the first Fixed entry).
19
+
20
+ ### Added
21
+
22
+ - Two contract specs keep the hand-maintained parts honest:
23
+ `spec/unit/rbs_contract_spec.rb` compares `sig/microsandbox.rbs` with the
24
+ loaded gem, and `spec/unit/docs_contract_spec.rb` checks the docs against
25
+ the code (version table, CHANGELOG links, error table, option list,
26
+ examples, release checklist). CI also runs `rbs -I sig validate`.
27
+
28
+ ### Changed
29
+
30
+ - **Release pipeline hardened** (no change to the gem's code or API). A tag
31
+ now publishes only after a preflight passes — the tag equals the three
32
+ version constants, its commit is on `main`, and CI is green for that exact
33
+ commit — and after the companion `microsandbox-rb-binaries` gems have built
34
+ (a companion build failure now blocks the SDK publish; a companion push
35
+ failure still needs a re-run of `publish-binaries`). The published gem is built
36
+ with plain `gem build`, every workflow action is pinned to a commit SHA
37
+ (kept current by Dependabot), and CI checks `Cargo.lock` against the RustSec
38
+ advisory database with `cargo deny`. See [docs/releasing.md](docs/releasing.md).
39
+ - **The precompiled-extension release job is removed.** The manual-dispatch
40
+ `cross-gems` job never produced a gem. Precompiled extension gems are not
41
+ built or published; every platform installs the source gem (compiled via
42
+ `rb_sys`), exactly as before. The `microsandbox-rb-binaries` gems are
43
+ unaffected.
44
+
45
+ ### Fixed
46
+
47
+ - **An interrupted call no longer leaks what it produced.** A `Timeout.timeout`
48
+ (or any `Thread#raise`), a `Thread#kill` or a Ctrl-C that arrived while a
49
+ native call was in flight used to discard the call's result without
50
+ releasing it: `Timeout.timeout { Sandbox.create(...) }` raised
51
+ `Timeout::Error` and left the sandbox running with no Ruby object owning it
52
+ until the process exited, an interrupted `exec_stream` left its guest
53
+ session open with nothing able to kill it, and an interrupted `exec` kept
54
+ its collected output in memory. The result now always reaches Ruby, and
55
+ what happens to it depends on the interrupt:
56
+ - **`Timeout`/`Thread#raise` and `Thread#kill`** during `Sandbox.create`,
57
+ `Sandbox.connect_or_create`, `Sandbox.start` or `PullSession#sandbox`: the
58
+ sandbox is stopped before the exception propagates — only one the call
59
+ owns; a sandbox `connect_or_create` merely connected to is left running.
60
+ The stop is best-effort: if it fails, the interrupt is still what you
61
+ see, and the sandbox is torn down when its object is garbage-collected.
62
+ - **Ctrl-C (`SIGINT`)** during those four, and **any interrupt** during
63
+ every other call (`exec_stream`, streams, SSH clients, …): nothing is
64
+ stopped at once; the result is released — and a sandbox torn down — when
65
+ its unreferenced object is garbage-collected.
66
+
67
+ Native calls are still uninterruptible, so the exception is raised once the
68
+ call has finished, and in the first case only after the cleanup stop as well
69
+ (a timed-out create therefore returns later than before). One visible
70
+ difference for `Thread.handle_interrupt(X => :on_blocking)`: a native call
71
+ now raises an exception that is already deferred *before* it starts, where
72
+ it used to raise it after finishing. The four acquisition methods above are
73
+ the exception — they hold every queued interrupt back until the sandbox is
74
+ in the caller's hands, so there a deferred exception is delivered after the
75
+ acquisition (at the thread's next blocking operation), not before it.
76
+ - **RBS: `Klass.new` now type-checks with its real arguments.** 24 classes
77
+ whose constructor takes arguments had no `initialize` declaration, so RBS
78
+ synthesized a zero-argument `new` for them.
79
+ - **Docs:** `docs/surface.md` now lists every `create`/`modify` keyword;
80
+ `DESIGN.md` no longer names a runtime tag the gem stopped pinning long ago;
81
+ the `with_backend` example in `docs/runtime.md` is valid Ruby. `0.9.3`
82
+ (never tagged or published) is folded into `0.10.0`, both in this file and
83
+ in the README's Versioning table, and this file now defines the compare
84
+ links for `0.9.1`–`0.13.0`.
85
+
11
86
  ## [0.17.1] - 2026-10-12
12
87
 
13
88
  Gem-only security and hardening release; the embedded runtime stays at
@@ -659,8 +734,10 @@ could make a later CLI copy-out overwrite an arbitrary host file — the SDK
659
734
 
660
735
  Ruby bindings for the additive `v0.6.6` SDK surface (upstream #1099 / #1128),
661
736
  bringing the gem to parity with the Python and Node SDKs, which already ship
662
- this API. The embedded runtime tag is unchanged (`v0.6.6`); this is a pure
663
- binding addition with no breaking change.
737
+ this API. This release also adopts the upstream runtime **`v0.6.3` →
738
+ `v0.6.6`**: that work was prepared as `0.9.3`, which was never tagged or
739
+ published, so the runtime bump and the bindings for its new surface first
740
+ shipped together here. Neither part is a breaking change.
664
741
 
665
742
  ### Added
666
743
 
@@ -693,16 +770,18 @@ Deliberately **not** exposed, matching the Python and Node SDKs (both surface
693
770
  these only through the CLI): `modify`'s `oci_upper_size` overlay-grow field, and
694
771
  the `SandboxMetricsReport` / per-sandbox metrics-report helpers.
695
772
 
696
- ## [0.9.3] - 2026-07-09
773
+ ### Runtime adoption v0.6.3 → v0.6.6 (prepared as 0.9.3, first released in 0.10.0)
774
+
775
+ `0.9.3` was never tagged or published; everything below shipped in `0.10.0`.
697
776
 
698
777
  Adopts upstream runtime **`v0.6.3` → `v0.6.6`** (spanning upstream `v0.6.4` and
699
778
  `v0.6.6`; `v0.6.5` was yanked upstream — its GitHub release was pulled and the
700
779
  type-model refactor it shipped, #1014, was reverted in `v0.6.6`, #1143). The
701
780
  upstream SDK crate's public API grew only *additive* surface (live
702
- modify/resize, ping/touch — Ruby bindings for these land in the next minor),
703
- so this is a pure runtime bump with no Ruby API change.
781
+ modify/resize, ping/touch). The Ruby bindings for it are the `### Added`
782
+ entries above.
704
783
 
705
- ### Fixed
784
+ #### Fixed
706
785
 
707
786
  - **Snapshot restore survives upstream tag republishes** (upstream #1130):
708
787
  restore now pulls the OCI image by its pinned manifest digest
@@ -722,7 +801,7 @@ so this is a pure runtime bump with no Ruby API change.
722
801
  - **Filesystem snapshot `readdir` streams entries** (upstream #1133), fixing
723
802
  unbounded memory growth (RSS leak) when listing very large directories.
724
803
 
725
- ### Changed
804
+ #### Changed
726
805
 
727
806
  - **Embedded runtime is now `v0.6.6`** (`Microsandbox::RUNTIME_VERSION` /
728
807
  `Microsandbox.runtime_version`). Also inherited:
@@ -1391,12 +1470,19 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
1391
1470
  core crate has Apple-native deps). Until precompiled gems are published,
1392
1471
  installing from source requires a Rust toolchain (stable >= 1.91).
1393
1472
 
1394
- [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...HEAD
1473
+ [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.2...HEAD
1474
+ [0.17.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...v0.17.2
1395
1475
  [0.17.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.0...v0.17.1
1396
1476
  [0.17.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.16.0...v0.17.0
1397
1477
  [0.16.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.15.0...v0.16.0
1398
1478
  [0.15.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.14.0...v0.15.0
1399
1479
  [0.14.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.13.0...v0.14.0
1480
+ [0.13.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.12.0...v0.13.0
1481
+ [0.12.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.11.0...v0.12.0
1482
+ [0.11.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.10.0...v0.11.0
1483
+ [0.10.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.2...v0.10.0
1484
+ [0.9.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.1...v0.9.2
1485
+ [0.9.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.0...v0.9.1
1400
1486
  [0.9.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.2...v0.9.0
1401
1487
  [0.8.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.1...v0.8.2
1402
1488
  [0.8.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.0...v0.8.1
data/Cargo.lock CHANGED
@@ -3660,7 +3660,7 @@ dependencies = [
3660
3660
 
3661
3661
  [[package]]
3662
3662
  name = "microsandbox_rb"
3663
- version = "0.17.1"
3663
+ version = "0.17.2"
3664
3664
  dependencies = [
3665
3665
  "chrono",
3666
3666
  "futures",
data/DESIGN.md CHANGED
@@ -50,7 +50,7 @@ multi-threaded tokio runtime via `runtime.block_on(fut)`.
50
50
 
51
51
  ### GVL release
52
52
 
53
- `block_on` is wrapped in `nogvl` (`rb_thread_call_without_gvl`) so the Ruby
53
+ `block_on` is wrapped in `nogvl` (`rb_thread_call_without_gvl2`) so the Ruby
54
54
  Global VM Lock is **released** while a sandbox operation is in flight. Without
55
55
  this, a long-running `exec` would freeze every other Ruby thread/fiber in the
56
56
  process (a real problem under Rails/Puma). The `nogvl` closure runs pure Rust
@@ -59,21 +59,81 @@ captured and re-raised *after* the GVL is re-acquired rather than unwinding
59
59
  across the C frame (which would be UB).
60
60
 
61
61
  **The *calling* thread is not preemptible during the call.** `nogvl` passes a
62
- null unblock-function to `rb_thread_call_without_gvl`, and Ruby only checks
63
- pending interrupts *before* and *after* the GVL-released region — so while a
64
- native call blocks, the thread that issued it cannot be interrupted:
65
- `Timeout::timeout` (which relies on `Thread#raise`), `Thread#kill`, and `SIGINT`
66
- (Ctrl-C) are all deferred until the call returns on its own. The GVL release
67
- keeps *other* threads live; it does **not** make the calling thread
68
- cancelable. This is harmless for the bounded calls (`exec`/`shell` with a
69
- `timeout:`, `AgentClient` with a `timeout:`) because the deadline fires inside
70
- the future, but the unbounded/streaming paths — `exec`/`shell` with
71
- `timeout: nil`, `ExecHandle#recv`/`#wait`, `log_stream`/`metrics_stream` `recv`
72
- (especially `follow: true`), `Sandbox#wait`/`SandboxHandle#wait_until_stopped`,
73
- and `AgentClient#request` with no timeout — can block their caller indefinitely
74
- if the guest wedges or a relay drops. **Bound such calls with the explicit
75
- `timeout:` knobs rather than wrapping them in `Timeout::timeout`**, which will
76
- not fire while the native call is in flight.
62
+ null unblock-function, so while a native call blocks, the thread that issued it
63
+ cannot be interrupted: `Timeout::timeout` (which relies on `Thread#raise`),
64
+ `Thread#kill`, and `SIGINT` (Ctrl-C) are all deferred until the call returns on
65
+ its own. The GVL release keeps *other* threads live; it does **not** make the
66
+ calling thread cancelable. This is harmless for the bounded calls
67
+ (`exec`/`shell` with a `timeout:`, `AgentClient` with a `timeout:`) because the
68
+ deadline fires inside the future, but the unbounded/streaming paths —
69
+ `exec`/`shell` with `timeout: nil`, `ExecHandle#recv`/`#wait`,
70
+ `log_stream`/`metrics_stream` `recv` (especially `follow: true`),
71
+ `Sandbox#wait`/`SandboxHandle#wait_until_stopped`, and `AgentClient#request`
72
+ with no timeout — can block their caller indefinitely if the guest wedges or a
73
+ relay drops. **Bound such calls with the explicit `timeout:` knobs rather than
74
+ wrapping them in `Timeout::timeout`**, which will not fire while the native
75
+ call is in flight.
76
+
77
+ ### Interrupts never lose a result
78
+
79
+ An interrupt that arrives during a native call is delivered **after the call
80
+ returns**, and the call's result is not lost. Ruby delivers `Thread#raise`,
81
+ `Thread#kill` and `SIGINT` with a `longjmp`, which skips Rust frames without
82
+ running destructors; if that happened while the value a call had just produced
83
+ (a booted sandbox, a guest exec session, megabytes of output) lived only in a
84
+ Rust frame, it would be neither returned to Ruby nor dropped. The plain
85
+ `rb_thread_call_without_gvl` does exactly that — it checks interrupts once more
86
+ after the callback, before returning — which is why it is not used. Two rules
87
+ in `runtime.rs` replace it:
88
+
89
+ 1. `nogvl` checks interrupts itself, under `rb_protect`, **only before the work
90
+ starts**: once up front, and again whenever `rb_thread_call_without_gvl2`
91
+ declined to run the callback because an interrupt was pending at entry. A
92
+ raise or kill there is an ordinary `Err` that unwinds the Rust frames
93
+ normally — nothing has happened yet. After the callback has run, nothing is
94
+ checked. (`nogvl` and `block_on` therefore return `Result`, and every call
95
+ site propagates it with `?`. A bound method that needs two awaits does both
96
+ in one `block_on`, so no value sits between two entry checks.)
97
+ 2. Between `block_on` returning and the bound method returning its value, the
98
+ binding does not call into Ruby: no `funcall` (the VM checks interrupts
99
+ after every one), only C-level Hash/Array/String construction and
100
+ `TypedData` wrapping. The class lookup magnus would otherwise do with
101
+ `const_get` on a type's first wrap is resolved at extension load instead.
102
+
103
+ So the next interrupt checkpoint after a native call is the Ruby VM's own,
104
+ reached once the result is a Ruby object. What happens to it then:
105
+
106
+ - **`Sandbox.create`, `Sandbox.connect_or_create`, `Sandbox.start`, and
107
+ `PullSession#sandbox`** mask interrupts (`Thread.handle_interrupt(Object =>
108
+ :never)`) around the native call plus the wrap, and an `ensure` **stops the
109
+ sandbox** when an exception or a kill cuts in before the caller has the
110
+ object — but only a sandbox the call owns (`Sandbox#owns_lifecycle?`, the
111
+ same predicate as `connect_or_create`'s block form): one that was already
112
+ running for someone else is left running. A `SIGINT` cannot be masked this
113
+ way; it can arrive before the wrapper is assigned, and then the next case
114
+ applies.
115
+ - **Every other wrapper** (`ExecHandle`, streams, SSH clients, write sinks, …)
116
+ and every plain result simply becomes garbage; GC frees the wrapper and the
117
+ Rust value's `Drop` closes the session or connection.
118
+
119
+ The calls themselves remain uninterruptible, so `Timeout.timeout { Sandbox.create(...) }`
120
+ still waits for the create to finish — and now also for the stop that cleans
121
+ it up — before `Timeout::Error` surfaces. That stop is best-effort (a failure
122
+ is swallowed so the interrupt is what the caller sees; GC then tears the
123
+ sandbox down).
124
+
125
+ `Thread.handle_interrupt(X => :on_blocking)` treats a native call as the
126
+ blocking point at its *start*: the bridge raises an exception that is already
127
+ deferred before the call begins, and one that arrives during the call waits
128
+ for the thread's next blocking operation. The four acquisition methods are
129
+ the exception: their own `Object => :never` mask sits on top of the caller's,
130
+ so a deferred exception is not raised at the native call's entry there — the
131
+ sandbox is returned and the exception is delivered at the next blocking
132
+ operation after the acquisition.
133
+
134
+ `spec/fake_runtime/interrupt_safety_spec.rb` pins all of this against the real
135
+ extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
136
+ log shows whether a sandbox was stopped, dropped, or left attached.
77
137
 
78
138
  ## Error mapping
79
139
 
@@ -185,7 +245,7 @@ extension.
185
245
  `ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
186
246
  (`microsandbox` / `microsandbox-network`, pinned to the same tag as
187
247
  `Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
188
- — CI, `rake-compiler-dock` release containers, and end-user source installs —
248
+ — CI and end-user source installs —
189
249
  without an adjacent checkout. For fast local development against a sibling
190
250
  microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
191
251
  (gitignored); its `paths` override builds against the local crates instead of
@@ -195,25 +255,13 @@ git. The override must never be committed — it would break container builds.
195
255
 
196
256
  * **Source gem**: compiles the extension via `extconf.rb` (rb-sys
197
257
  `create_rust_makefile`); requires a Rust toolchain (MSRV below).
198
- * **Precompiled platform gems**: built best-effort by the `cross-gems` job
199
- (`.github/workflows/release.yml`) with `oxidize-rb/cross-gem-action`
200
- (`rake-compiler-dock`) per `Gem::Platform`, shipping multi-ABI
201
- `lib/microsandbox/<ruby_abi>/` native artifacts — the same model Node uses with
202
- per-platform packages. End users then install with no Rust toolchain, and the
203
- host runtime comes from the `microsandbox-rb-binaries` gem — or, without it, is
204
- fetched on first use (see above). The guest `agentd` is baked into
205
- the extension by *target* arch (`filesystem/build.rs` uses
206
- `CARGO_CFG_TARGET_ARCH` + `include_bytes!`), so it cross-compiles correctly;
207
- the real cross work is linking the *target* native libs — `libcap-ng` on Linux
208
- (via Debian multiarch for `aarch64-linux`) and the Hypervisor + Security
209
- frameworks on macOS (via osxcross; `arm64-darwin` is the platform still to
210
- confirm — if osxcross can't link them, move it to a native `macos-14` runner).
211
- The job is gated to `workflow_dispatch` and **not** auto-published on tags:
212
- since CI can't boot a microVM to prove a built gem actually works, gems are
213
- promoted to the publish path manually after per-platform validation. Published
214
- to RubyGems via Trusted Publishing (OIDC). See [docs/releasing.md](docs/releasing.md).
258
+ * **Precompiled platform gems**: not built or published. Every platform
259
+ installs the source gem, which compiles the extension via `rb_sys` at install
260
+ time; the host runtime comes from the `microsandbox-rb-binaries` gem — or,
261
+ without it, is fetched on first use (see above). The companion binaries gems
262
+ below are unaffected. See [docs/releasing.md](docs/releasing.md).
215
263
  * **Runtime binaries gems** (`microsandbox-rb-binaries`): a distinct artifact
216
- from the precompiled *extension* gems above — no Ruby code beyond a small
264
+ from the SDK gem — no Ruby code beyond a small
217
265
  locator module, just the verified upstream `msb` + `libkrunfw` for one
218
266
  platform. CI's `binaries` job vendors and builds all three platforms on every
219
267
  run and smoke-tests the host one; the `package` job installs the source gem
@@ -269,7 +317,8 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
269
317
  host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
270
318
 
271
319
  A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
272
- upstream-gated — they exist at the pinned `v0.6.2` runtime): per-published-port host
320
+ upstream-gated — they exist at the pinned runtime,
321
+ `Microsandbox::RUNTIME_VERSION`): per-published-port host
273
322
  **bind address** (ports always bind loopback), network **interface overrides**,
274
323
  and inline **named-volume create-mode** (pre-create with `Volume.create`, then
275
324
  mount with `{ named: }`). These slot in module-by-module exactly as the existing
data/README.md CHANGED
@@ -40,7 +40,7 @@ gem compiles the Rust extension, so the first install takes a few minutes.
40
40
  - Linux with KVM enabled, or macOS on Apple Silicon. Cloud-only users
41
41
  (`MSB_BACKEND=cloud`) need neither.
42
42
  - A stable Rust toolchain (1.91 or newer) on `PATH` to compile the source gem.
43
- Precompiled extension gems require no Rust but are not yet auto-published; see
43
+ Precompiled extension gems are not published; see
44
44
  [docs/releasing.md](docs/releasing.md).
45
45
  - The `msb` runtime and `libkrunfw` firmware, from the companion gem above or
46
46
  downloaded on first use. Provision ahead of time (a Docker layer, an
@@ -170,7 +170,7 @@ See [docs/observability.md](docs/observability.md).
170
170
  | Runtime binaries gem, `msb` resolution order, backends, environment variables | [docs/runtime.md](docs/runtime.md) |
171
171
  | Error classes and stable codes | [docs/errors.md](docs/errors.md) |
172
172
  | Complete supported surface and what is not yet exposed | [docs/surface.md](docs/surface.md) |
173
- | Release process, precompiled and binaries gems | [docs/releasing.md](docs/releasing.md) |
173
+ | Release process, release preflight, binaries gems | [docs/releasing.md](docs/releasing.md) |
174
174
  | Architecture: native extension, sync bridge, GVL, error mapping | [DESIGN.md](DESIGN.md) |
175
175
  | Companion runtime gem internals | [binaries/README.md](https://github.com/ya-luotao/microsandbox-rb/blob/main/binaries/README.md) |
176
176
 
@@ -209,7 +209,7 @@ share the upstream tag, but the numbers have diverged since and the gem version
209
209
  is **not** an indicator of the embedded runtime. Ask the build instead:
210
210
 
211
211
  ```ruby
212
- Microsandbox::VERSION # => "0.17.1" (the gem's own version)
212
+ Microsandbox::VERSION # => "0.17.2" (the gem's own version)
213
213
  Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
214
214
  ```
215
215
 
@@ -237,8 +237,7 @@ constants against this gem's. Every release records its runtime in
237
237
  | `0.9.0` | `v0.6.1` | adopts upstream `v0.6.0`+`v0.6.1` (zombie-runtime wait fix, secret substitution through CONNECT proxies, stale-sandbox cleanup); upstream public API is additive — no Ruby surface change |
238
238
  | `0.9.1` | `v0.6.2` | adopts upstream `v0.6.2` (faster image loads/pulls via early cache gate + zlib-rs); upstream API unchanged — no Ruby surface change |
239
239
  | `0.9.2` | `v0.6.3` | adopts upstream `v0.6.3` (v4-only sandboxes stop advertising AAAA DNS answers — fixes guest gRPC/c-ares preferring unreachable IPv6; scoped upstream TLS verification); glue moves to `*_local` SDK variants — no Ruby surface change |
240
- | `0.9.3` | `v0.6.6` | adopts upstream `v0.6.4`+`v0.6.6` (`v0.6.5` was yanked upstream): snapshot restore by pinned digest — fixes fatal restore-after-tag-republish bug, fragmented-UDP/PMTU relay fixes, exec kills the whole process group, ephemeral stop-wait tolerance, readdir RSS-leak fix; upstream API growth is additive-only — no Ruby surface change |
241
- | `0.10.0` | `v0.6.6` | `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
240
+ | `0.10.0` | `v0.6.6` | adopts upstream `v0.6.4`+`v0.6.6` (`v0.6.5` was yanked upstream): snapshot restore by pinned digest — fixes fatal restore-after-tag-republish bug, fragmented-UDP/PMTU relay fixes, exec kills the whole process group, ephemeral stop-wait tolerance, readdir RSS-leak fix; plus `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
242
241
  | `0.11.0` | `v0.6.7` | adopts upstream `v0.6.7` (**breaking**): network profiles replace `public_only`/`non_local`, structured `root_disk:` replaces `oci_upper_size:` (deprecated alias kept), snapshot descriptor contract (`create` re-keyed by name, `save`/`load` rename, `snapshot_to` removed, on-disk auto-migration), `Image.load`/`Image.save`, `follow_root_symlinks:`; runtime carries the GHSA-4vq3-cjpp-v7fg `msb copy` fix |
243
242
  | `0.12.0` | `v0.6.8` | adopts upstream `v0.6.8` (**breaking**): `Sandbox.list`/`.list_with` return a cursor-paginated `SandboxPage` (`limit:`/`cursor:` keywords), `UnsupportedError` re-keyed by structured operations with `#operation`/`#hint`; runtime adds a shared log registry for followed streams and cloud exec/ssh reconnects |
244
243
  | `0.13.0` | `v0.6.9` | adopts upstream `v0.6.9` (**breaking**): a bare `MSB_API_KEY` no longer selects the cloud backend (explicit `MSB_BACKEND=cloud` or a cloud profile required; invalid cloud config fails closed with `InvalidConfigError`); snapshot payload integrity becomes opt-in (`record_integrity:`, `verify` can report `:not_recorded`). Parity: default-workload execution (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`), flat root disks (`RootDisk.flat`), `modify(root_disk_size:)`, `rate_limiter:`, `vsock:`, `default_backend_info`, `Volume.get_default` |
@@ -247,13 +246,14 @@ constants against this gem's. Every release records its runtime in
247
246
  | `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
248
247
  | `0.17.0` | `v0.6.18` | adopts upstream `v0.6.17`+`v0.6.18` step by step: outbound SOCKS4/SOCKS5 proxies (SOCKS5 UDP + credentials), migration-order fix for databases last opened by a `v0.6.15` `msb`, security hardening — plain-HTTP `Host`/`:authority` now checked against domain-rule policies even in non-strict mode, plus fail-closed strict hostname mode. Parity: `proxy:` (`Microsandbox::OutboundProxy` / `SecretSource`), `strict:` |
249
248
  | `0.17.1` | `v0.6.18` | gem-only security and hardening release: relative host paths pinned at create time (GHSA-mcw9-9rf6-p4rm), `Volume.fs` path validation, `Cargo.lock` advisory updates, argument-guard and cleanup fixes, `SECURITY.md` |
249
+ | `0.17.2` | `v0.6.18` | gem-only fix and release-pipeline hardening release: an interrupted native call no longer loses its result (a sandbox the call owns is stopped on `Timeout`/`Thread#kill`), release preflight, SHA-pinned workflow actions and `cargo deny` advisory checks, RBS and docs contract specs |
250
250
 
251
251
  ## Development
252
252
 
253
253
  ```bash
254
254
  bin/setup # bundle install + compile the native extension
255
255
  bundle exec rake compile # rebuild the extension (debug); compile:release for optimized
256
- bundle exec rake spec # unit specs (no runtime needed)
256
+ bundle exec rake spec # unit + fake-runtime specs (no runtime needed)
257
257
  MICROSANDBOX_INTEGRATION=1 bundle exec rspec spec/integration # boot real microVMs (KVM / Apple Silicon)
258
258
  bundle exec standardrb # Ruby lint
259
259
  cargo fmt --check --manifest-path ext/microsandbox/Cargo.toml
data/SECURITY.md CHANGED
@@ -32,7 +32,7 @@ entry in some of them refers to the upstream project's own Ruby gem, not to
32
32
 
33
33
  | Advisory | Severity | Affected `microsandbox-rb` | Status |
34
34
  |---|---|---|---|
35
- | [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. |
35
+ | [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. Tracked for this gem as [GHSA-qrhv-m6hf-9cmg](https://github.com/ya-luotao/microsandbox-rb/security/advisories/GHSA-qrhv-m6hf-9cmg). |
36
36
  | [GHSA-cg9x-2vfm-3j58](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-cg9x-2vfm-3j58) — NAT64 addresses can bypass destination-group network policy | High | all versions (runtime `< v0.7.5`) | **Not fixed in this gem yet**; needs the runtime fix. Only exploitable where the host network routes a NAT64 prefix. Mitigate by denying the prefix explicitly (see below) or blocking it at the host. |
37
37
  | [GHSA-3mpp-52gc-935r](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-3mpp-52gc-935r) — hostname allowlist bypass via mismatched HTTP `Host` / `:authority` | High | `<= 0.16.0` | Runtime fix included from `0.17.0`. If you rely on a hostname allowlist for HTTPS without TLS interception, also pass `strict: true` (off by default in this gem). |
38
38
  | [GHSA-gj24-29j2-3x78](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-gj24-29j2-3x78) — egress allowlist bypass on malformed HTTP/1 request lines | Medium | `<= 0.12.0` | Runtime fix included from `0.13.0`. |
data/docs/execution.md CHANGED
@@ -76,7 +76,14 @@ Don't share one handle across threads.
76
76
  The GVL is released during sandbox calls, so *other* Ruby threads keep
77
77
  running. The *calling* thread blocks uninterruptibly until the call returns:
78
78
  `Timeout::timeout`, `Thread#kill`, and Ctrl-C cannot interrupt a blocked native
79
- call. Bound long-running work with a real deadline where one exists:
79
+ call — they are delivered once it has returned. Its result is not lost when
80
+ that happens: an interrupted `exec_stream` handle, stream or SSH client is
81
+ closed when the unreferenced object is garbage-collected, and a
82
+ `Sandbox.create` interrupted by a `Timeout`/`Thread#raise` or a `Thread#kill`
83
+ stops the sandbox it booted (after a Ctrl-C it is torn down by garbage
84
+ collection instead; see
85
+ [lifecycle.md](lifecycle.md#timeouts-and-interrupts-around-a-create)). Bound
86
+ long-running work with a real deadline where one exists:
80
87
 
81
88
  - `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds.
82
89
  - `AgentClient.connect_sandbox` / `connect_path` take a `timeout:` that bounds
data/docs/lifecycle.md CHANGED
@@ -57,6 +57,39 @@ Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
57
57
  `ENTRYPOINT`/`CMD`. See [execution.md](execution.md#default-workload) for
58
58
  `exec_default`.
59
59
 
60
+ ### Timeouts and interrupts around a create
61
+
62
+ A create cannot be interrupted while it runs, so `Timeout.timeout(5) {
63
+ Sandbox.create(...) }` does not return after 5 seconds: `Timeout::Error` is
64
+ raised only once the create has finished. When that happens the sandbox is
65
+ **stopped before the exception reaches you** — the call returns only after the
66
+ create *and* that cleanup stop have completed, so it takes longer than the
67
+ create alone — and nothing is left running without an owner. The same holds
68
+ for `Thread#raise` and `Thread#kill`, and for `Sandbox.connect_or_create`,
69
+ `Sandbox.start` and `PullSession#sandbox`. Two details:
70
+
71
+ - The sandbox is stopped, not removed. Like any stopped sandbox it keeps its
72
+ name until you `Sandbox.remove` it (or pass `ephemeral: true`), so retrying
73
+ the create under the same name raises `SandboxAlreadyExistsError`.
74
+ - Only a sandbox the interrupted call owns is stopped. `connect_or_create`
75
+ that merely connected to a sandbox already running for someone else leaves
76
+ it running, exactly as its block form does.
77
+
78
+ The cleanup stop is best-effort: if it fails, the interrupt is still the
79
+ exception you get, and the sandbox is torn down when its now unreferenced
80
+ object is garbage-collected.
81
+
82
+ A Ctrl-C (`SIGINT`) is the one interrupt that cannot be held back until the
83
+ sandbox is in your hands: one that lands during the create is raised the
84
+ moment the create returns, before anything can stop the sandbox, which is then
85
+ torn down when the unreferenced object is garbage-collected instead of
86
+ immediately.
87
+
88
+ These methods hold queued interrupts back while they acquire the sandbox, so
89
+ an exception you deferred with `Thread.handle_interrupt(X => :on_blocking)` is
90
+ not raised by them: you get the sandbox, and the exception arrives at the
91
+ thread's next blocking operation.
92
+
60
93
  ## Convergent lifecycle
61
94
 
62
95
  Idempotent operations that take a name to the state you want, whatever state
data/docs/releasing.md CHANGED
@@ -7,54 +7,105 @@ or credential setup is needed.
7
7
 
8
8
  ## Each release
9
9
 
10
- 1. Bump the gem's **own** version — `Microsandbox::VERSION` and the matching
11
- `[package] version` in `ext/microsandbox/Cargo.toml` (they must stay equal) —
12
- on its independent semver track (see the
10
+ 1. Bump the gem's **own** version on its independent semver track (see the
13
11
  [Versioning](../README.md#versioning) section of the README); **don't** pick
14
- the number to mirror the upstream tag. If the release also adopts a new
15
- upstream runtime, bump the `tag = "vX.Y.Z"` on **all three** upstream git
16
- deps (`microsandbox`, `microsandbox-network`, `microsandbox-runtime`), update
17
- `Microsandbox::RUNTIME_VERSION` to match, commit the release's
18
- `checksums.sha256` as `binaries/checksums/<tag>.sha256`, and add a row to the
19
- Versioning table. Also bump `Microsandbox::Binaries::VERSION` (and, on a
20
- runtime adoption, `::RUNTIME_VERSION`) in
21
- `binaries/lib/microsandbox/binaries.rb` — the companion gem ships in
22
- lockstep and the specs assert both. Update `CHANGELOG.md`.
23
- 2. Push a `vX.Y.Z` tag. CI builds the **source gem** and pushes it to RubyGems
24
- via `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
12
+ the number to mirror the upstream tag. The version is written in three
13
+ files that must stay equal (`spec/unit/version_spec.rb` asserts it), and
14
+ recorded in the lockfile:
15
+ - `lib/microsandbox/version.rb` — `Microsandbox::VERSION`;
16
+ - `ext/microsandbox/Cargo.toml` — the `[package] version`;
17
+ - `binaries/lib/microsandbox/binaries.rb` — `Microsandbox::Binaries::VERSION`
18
+ (the companion gem ships in lockstep);
19
+ - `Cargo.lock` — committed and packed into the gem, and it records the
20
+ extension crate's version, so a version bump must commit the regenerated
21
+ lock (`bundle exec rake compile` rewrites it).
22
+
23
+ If the release also adopts a new upstream runtime, move the runtime tag
24
+ everywhere it is named:
25
+ - `ext/microsandbox/Cargo.toml` — the `tag = "vX.Y.Z"` on **all three**
26
+ upstream git deps (`microsandbox`, `microsandbox-network`,
27
+ `microsandbox-runtime`);
28
+ - `lib/microsandbox/version.rb` — `Microsandbox::RUNTIME_VERSION`;
29
+ - `binaries/lib/microsandbox/binaries.rb` —
30
+ `Microsandbox::Binaries::RUNTIME_VERSION`;
31
+ - `binaries/checksums/<tag>.sha256` — the release's `checksums.sha256`,
32
+ committed (vendoring fails closed without it);
33
+ - `Cargo.lock` — regenerated again, since it pins the git deps to the new
34
+ tag's commit.
35
+
36
+ Then add a row to the Versioning table in the README and update
37
+ `CHANGELOG.md`.
38
+ 2. Push a `vX.Y.Z` tag. Once the [release preflight](#release-preflight)
39
+ passes, CI pushes the **source gem** to RubyGems via
40
+ `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
25
41
  `RUBYGEMS_API_KEY` secret required — then creates the GitHub Release.
26
42
 
43
+ ## Release preflight
44
+
45
+ `release.yml`'s `preflight` job runs `.github/scripts/release_preflight.sh
46
+ <tag>` and the `publish` job needs it, so nothing reaches RubyGems unless:
47
+
48
+ - the tag is `vX.Y.Z` and the three version constants **in the tagged commit**
49
+ — `Microsandbox::VERSION`, `[package] version` in
50
+ `ext/microsandbox/Cargo.toml`, `Microsandbox::Binaries::VERSION` — all equal
51
+ `X.Y.Z`;
52
+ - the tagged commit is on `main`;
53
+ - the `CI` workflow's push run for that exact commit concluded `success`.
54
+
55
+ Pushing `main` and the tag together is fine: the preflight **waits** for that
56
+ CI run to appear and finish (polling every 30 s, up to 45 min —
57
+ `PREFLIGHT_CI_WAIT_SECONDS` / `PREFLIGHT_CI_POLL_SECONDS`), so a release
58
+ publishes only after CI, including the real-microVM integration lanes, is
59
+ green. A failed or timed-out preflight leaves the tag unpublished. How to
60
+ recover depends on whether the tag has to move:
61
+
62
+ - **The tagged commit is right** (CI flaked, or was still red): re-run the
63
+ `CI` run for that commit until it is green, then re-run the failed `Release`
64
+ run.
65
+ - **The tag must point at a different commit** (wrong version constants, not
66
+ on `main`): delete the tag, fix the commit, and push the tag again — that
67
+ starts a new `Release` run. Never re-run the old run after retagging: a
68
+ re-run keeps the commit it was started for, so it checks the old commit
69
+ again and fails the same way.
70
+
71
+ Dry-run it against an existing tag — nothing is published on a manual
72
+ dispatch — or run the script locally from a full clone:
73
+
74
+ ```bash
75
+ gh workflow run release.yml --ref main -f preflight_ref=vX.Y.Z
76
+ GH_TOKEN=$(gh auth token) .github/scripts/release_preflight.sh vX.Y.Z
77
+ ```
78
+
79
+ It is a workflow-level gate; branch protection and a protected environment for
80
+ the trusted publisher are separate repository settings.
81
+
82
+ CI's `lint` job also runs `cargo deny check advisories` against `Cargo.lock`;
83
+ the exceptions (advisories with no fixed release at the pinned runtime) are in
84
+ `deny.toml` at the repository root — re-check them on every runtime adoption.
85
+
27
86
  ## Precompiled per-platform gems
28
87
 
29
- Precompiled *extension* gems are built best-effort by the `cross-gems` job,
30
- gated to manual `workflow_dispatch` so you can iterate
31
- (`gh workflow run release.yml`) without failing tag releases. They are not
32
- auto-published on tags yet: a gem that *compiles but can't boot a microVM*
33
- would be served to users ahead of the source gem, and CI can't boot a VM to
34
- prove otherwise — so promotion is manual after validating the artifact on each
35
- platform. A precompiled gem ships the compiled extension (with the guest
36
- `agentd` baked in by *target* arch); the host-side `msb` + `libkrunfw` runtime
37
- is **not** in it — that comes from the companion `microsandbox-rb-binaries`
38
- gem, or, when that isn't installed, is fetched into `~/.microsandbox` on first
39
- use by `Microsandbox.ensure_runtime!` (libkrunfw is `dlopen`'d by `msb` at
40
- runtime, never linked into the gem). The real cross-compile work is linking the
41
- *target* native libraries — `libcap-ng` on Linux (handled via Debian multiarch
42
- in the workflow) and the Hypervisor + Security frameworks on macOS (via
43
- osxcross; the one platform left to confirm). Until promoted, users install the
44
- source gem (which compiles via `rb_sys`).
88
+ Precompiled *extension* gems are not built or published. Users install the
89
+ source gem, which compiles the extension via `rb_sys` at install time (so a
90
+ stable Rust toolchain is required on the installing host). The companion
91
+ `microsandbox-rb-binaries` gems below are unaffected: they carry the host
92
+ runtime, not the extension.
45
93
 
46
94
  ## Runtime binaries gems
47
95
 
48
96
  `microsandbox-rb-binaries` (source in `binaries/`) is a *separate* artifact from
49
- the precompiled extension gems above: it carries no Ruby extension, only the
97
+ the SDK gem: it carries no Ruby extension, only the
50
98
  upstream `msb` + `libkrunfw` for one platform, verified against the release's
51
99
  published `checksums.sha256` when vendored. CI's `binaries` job vendors and
52
100
  builds all three (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) on
53
101
  every run and smoke-tests the host one; `release.yml`'s `binaries-gems` job
54
102
  does the same on a tag and a separate `publish-binaries` job pushes them after
55
- the SDK gem is live (a companion failure is its own red job and never blocks
56
- the SDK release). They use their own RubyGems trusted-publisher entry (same
57
- repo + workflow, gem name `microsandbox-rb-binaries`). To build them by hand:
103
+ the SDK gem is live. The SDK `publish` job needs `binaries-gems`, so a
104
+ companion **build** failure blocks the SDK publish as well. A companion
105
+ **push** failure is its own red job once the SDK gem is live: the release is
106
+ then incomplete until `publish-binaries` is re-run. They use their own RubyGems
107
+ trusted-publisher entry (same repo + workflow, gem name
108
+ `microsandbox-rb-binaries`). To build them by hand:
58
109
 
59
110
  ```bash
60
111
  rake -C binaries vendor[arm64-darwin] # download + sha256-verify into binaries/vendor
data/docs/runtime.md CHANGED
@@ -85,7 +85,9 @@ Microsandbox.set_default_backend(:cloud, url: "https://api.example.com", api_key
85
85
  Microsandbox.set_default_backend(:cloud, profile: "prod")
86
86
 
87
87
  # Scoped override (restored afterward, even on error):
88
- Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "alpine") { |sb| ... } }
88
+ Microsandbox.with_backend(:local) do
89
+ Microsandbox::Sandbox.create("box", image: "alpine") { |sb| puts sb.shell("uname -a").stdout }
90
+ end
89
91
  ```
90
92
 
91
93
  Resolution order when no backend is set programmatically: `MSB_BACKEND`