microsandbox-rb 0.17.1 → 0.17.3

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: 6d3b616f0ff13a9961eab2e0cdd3812f859754efd7489d5857db0e49588f2b73
4
+ data.tar.gz: a172d9b8b62bb19f8b4cc2accfadec90f56b83b0e99112109a42032b7ca9a6f4
5
5
  SHA512:
6
- metadata.gz: 49461a4a9ca44f5af82d62fe30086bd4b153d3885267274fce7de8328b071cb418055f202b3b2feea3f149d76572a2a78874596c707ac094d62f7cc5171269aa
7
- data.tar.gz: 929b0a19b88eb839d371899191e6a0229eded4eb616100dbd8d316352eac3bf68dcfefa91fb04c4ce41ad540deaa41233c1533d2471222bbf4772d8708451be6
6
+ metadata.gz: 9357333f88d8061e822da080bdc014438b7ca8bda1b94584a01e89fa1278c9d661b5a5eb2a7e5e36e5879076c2e9f6e82cb067dd6a4aa86023f16360953369ed
7
+ data.tar.gz: e7b24e0067df67c76a57f5b204f743d9420c4cd9d43f717bd3576e0324023493b68821d65e1c910168ad87a3e76261dcbb825b1476184b6c9318891cb4a99c1a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,124 @@ wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.17.3] - 2026-10-13
12
+
13
+ Gem-only fix release; the embedded runtime stays at **`v0.6.18`**. A process
14
+ forked after the SDK was used — a Puma, Resque or Solid Queue worker — used to
15
+ hang for 30 s on Linux, or die with `SIGTRAP` on macOS, on its first SDK call.
16
+ The child's first SDK call now builds a fresh backend, and a handle created
17
+ before the fork raises the new `ForkedHandleError` there instead of being used.
18
+
19
+ ### Fixed
20
+
21
+ - **Forking a process that has used the SDK no longer hangs or kills the
22
+ child.** A forked child inherited the parent's default backend, whose SQLite
23
+ worker threads (local) or pooled connections (cloud) do not exist in it: the
24
+ child's first catalog call waited out a 30 s pool timeout on Linux and then
25
+ raised a plain `Microsandbox::Error`, and on macOS the child died with
26
+ `SIGTRAP` — as it did when it merely selected a backend, because destroying
27
+ the inherited one traps too. The first SDK call in a child now builds a
28
+ fresh backend the same way the parent's was chosen (`set_default_backend`,
29
+ an enclosing `with_backend`, or the environment) and leaks the inherited one
30
+ instead of destroying it; a `with_backend` block entered before the fork can
31
+ be left in the child, and selecting a backend there works even when the
32
+ inherited choice no longer resolves. If the backend such a block should
33
+ restore cannot be chosen again in the child, the block's temporary selection
34
+ stays in effect and an exception raised by the block is no longer replaced
35
+ by the restore failure (which is warned about instead). The tokio runtime, already rebuilt after a fork, now
36
+ keeps its pointer and pid in one atomic cell, without the mutex a fork could
37
+ capture locked. See the new "Forking" section of
38
+ [docs/runtime.md](docs/runtime.md#forking-puma-resque-solid-queue) for what
39
+ is supported and what is not.
40
+ - A handle created before a fork (`Sandbox`, `SandboxHandle`, `ExecHandle`,
41
+ streams, SSH and agent clients, `VolumeFs`, ...) is refused in the child
42
+ with `ForkedHandleError` instead of being driven on the parent's dead
43
+ runtime, and a child that garbage-collects one — at the latest when it
44
+ exits — no longer runs its destructor. `Sandbox#owns_lifecycle?` is refused
45
+ the same way; `name`, `id` and the `SandboxHandle` snapshot fields stay
46
+ readable.
47
+
48
+ ### Added
49
+
50
+ - `Microsandbox::ForkedHandleError` (`#code` `"forked-handle"`), raised when a
51
+ handle is used in a process forked from the one that created it. The message
52
+ names both pids.
53
+
54
+ ## [0.17.2] - 2026-10-12
55
+
56
+ Gem-only fix and release-pipeline hardening release; the embedded runtime
57
+ stays at **`v0.6.18`**. One behaviour change is visible: under
58
+ `Thread.handle_interrupt(X => :on_blocking)` a native call now raises an
59
+ already-deferred exception before it starts instead of after it finishes,
60
+ except in the four sandbox-acquisition methods, which deliver it after the
61
+ acquisition (see the first Fixed entry).
62
+
63
+ ### Added
64
+
65
+ - Two contract specs keep the hand-maintained parts honest:
66
+ `spec/unit/rbs_contract_spec.rb` compares `sig/microsandbox.rbs` with the
67
+ loaded gem, and `spec/unit/docs_contract_spec.rb` checks the docs against
68
+ the code (version table, CHANGELOG links, error table, option list,
69
+ examples, release checklist). CI also runs `rbs -I sig validate`.
70
+
71
+ ### Changed
72
+
73
+ - **Release pipeline hardened** (no change to the gem's code or API). A tag
74
+ now publishes only after a preflight passes — the tag equals the three
75
+ version constants, its commit is on `main`, and CI is green for that exact
76
+ commit — and after the companion `microsandbox-rb-binaries` gems have built
77
+ (a companion build failure now blocks the SDK publish; a companion push
78
+ failure still needs a re-run of `publish-binaries`). The published gem is built
79
+ with plain `gem build`, every workflow action is pinned to a commit SHA
80
+ (kept current by Dependabot), and CI checks `Cargo.lock` against the RustSec
81
+ advisory database with `cargo deny`. See [docs/releasing.md](docs/releasing.md).
82
+ - **The precompiled-extension release job is removed.** The manual-dispatch
83
+ `cross-gems` job never produced a gem. Precompiled extension gems are not
84
+ built or published; every platform installs the source gem (compiled via
85
+ `rb_sys`), exactly as before. The `microsandbox-rb-binaries` gems are
86
+ unaffected.
87
+
88
+ ### Fixed
89
+
90
+ - **An interrupted call no longer leaks what it produced.** A `Timeout.timeout`
91
+ (or any `Thread#raise`), a `Thread#kill` or a Ctrl-C that arrived while a
92
+ native call was in flight used to discard the call's result without
93
+ releasing it: `Timeout.timeout { Sandbox.create(...) }` raised
94
+ `Timeout::Error` and left the sandbox running with no Ruby object owning it
95
+ until the process exited, an interrupted `exec_stream` left its guest
96
+ session open with nothing able to kill it, and an interrupted `exec` kept
97
+ its collected output in memory. The result now always reaches Ruby, and
98
+ what happens to it depends on the interrupt:
99
+ - **`Timeout`/`Thread#raise` and `Thread#kill`** during `Sandbox.create`,
100
+ `Sandbox.connect_or_create`, `Sandbox.start` or `PullSession#sandbox`: the
101
+ sandbox is stopped before the exception propagates — only one the call
102
+ owns; a sandbox `connect_or_create` merely connected to is left running.
103
+ The stop is best-effort: if it fails, the interrupt is still what you
104
+ see, and the sandbox is torn down when its object is garbage-collected.
105
+ - **Ctrl-C (`SIGINT`)** during those four, and **any interrupt** during
106
+ every other call (`exec_stream`, streams, SSH clients, …): nothing is
107
+ stopped at once; the result is released — and a sandbox torn down — when
108
+ its unreferenced object is garbage-collected.
109
+
110
+ Native calls are still uninterruptible, so the exception is raised once the
111
+ call has finished, and in the first case only after the cleanup stop as well
112
+ (a timed-out create therefore returns later than before). One visible
113
+ difference for `Thread.handle_interrupt(X => :on_blocking)`: a native call
114
+ now raises an exception that is already deferred *before* it starts, where
115
+ it used to raise it after finishing. The four acquisition methods above are
116
+ the exception — they hold every queued interrupt back until the sandbox is
117
+ in the caller's hands, so there a deferred exception is delivered after the
118
+ acquisition (at the thread's next blocking operation), not before it.
119
+ - **RBS: `Klass.new` now type-checks with its real arguments.** 24 classes
120
+ whose constructor takes arguments had no `initialize` declaration, so RBS
121
+ synthesized a zero-argument `new` for them.
122
+ - **Docs:** `docs/surface.md` now lists every `create`/`modify` keyword;
123
+ `DESIGN.md` no longer names a runtime tag the gem stopped pinning long ago;
124
+ the `with_backend` example in `docs/runtime.md` is valid Ruby. `0.9.3`
125
+ (never tagged or published) is folded into `0.10.0`, both in this file and
126
+ in the README's Versioning table, and this file now defines the compare
127
+ links for `0.9.1`–`0.13.0`.
128
+
11
129
  ## [0.17.1] - 2026-10-12
12
130
 
13
131
  Gem-only security and hardening release; the embedded runtime stays at
@@ -659,8 +777,10 @@ could make a later CLI copy-out overwrite an arbitrary host file — the SDK
659
777
 
660
778
  Ruby bindings for the additive `v0.6.6` SDK surface (upstream #1099 / #1128),
661
779
  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.
780
+ this API. This release also adopts the upstream runtime **`v0.6.3` →
781
+ `v0.6.6`**: that work was prepared as `0.9.3`, which was never tagged or
782
+ published, so the runtime bump and the bindings for its new surface first
783
+ shipped together here. Neither part is a breaking change.
664
784
 
665
785
  ### Added
666
786
 
@@ -693,16 +813,18 @@ Deliberately **not** exposed, matching the Python and Node SDKs (both surface
693
813
  these only through the CLI): `modify`'s `oci_upper_size` overlay-grow field, and
694
814
  the `SandboxMetricsReport` / per-sandbox metrics-report helpers.
695
815
 
696
- ## [0.9.3] - 2026-07-09
816
+ ### Runtime adoption v0.6.3 → v0.6.6 (prepared as 0.9.3, first released in 0.10.0)
817
+
818
+ `0.9.3` was never tagged or published; everything below shipped in `0.10.0`.
697
819
 
698
820
  Adopts upstream runtime **`v0.6.3` → `v0.6.6`** (spanning upstream `v0.6.4` and
699
821
  `v0.6.6`; `v0.6.5` was yanked upstream — its GitHub release was pulled and the
700
822
  type-model refactor it shipped, #1014, was reverted in `v0.6.6`, #1143). The
701
823
  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.
824
+ modify/resize, ping/touch). The Ruby bindings for it are the `### Added`
825
+ entries above.
704
826
 
705
- ### Fixed
827
+ #### Fixed
706
828
 
707
829
  - **Snapshot restore survives upstream tag republishes** (upstream #1130):
708
830
  restore now pulls the OCI image by its pinned manifest digest
@@ -722,7 +844,7 @@ so this is a pure runtime bump with no Ruby API change.
722
844
  - **Filesystem snapshot `readdir` streams entries** (upstream #1133), fixing
723
845
  unbounded memory growth (RSS leak) when listing very large directories.
724
846
 
725
- ### Changed
847
+ #### Changed
726
848
 
727
849
  - **Embedded runtime is now `v0.6.6`** (`Microsandbox::RUNTIME_VERSION` /
728
850
  `Microsandbox.runtime_version`). Also inherited:
@@ -1391,12 +1513,20 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
1391
1513
  core crate has Apple-native deps). Until precompiled gems are published,
1392
1514
  installing from source requires a Rust toolchain (stable >= 1.91).
1393
1515
 
1394
- [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...HEAD
1516
+ [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.3...HEAD
1517
+ [0.17.3]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.2...v0.17.3
1518
+ [0.17.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...v0.17.2
1395
1519
  [0.17.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.0...v0.17.1
1396
1520
  [0.17.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.16.0...v0.17.0
1397
1521
  [0.16.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.15.0...v0.16.0
1398
1522
  [0.15.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.14.0...v0.15.0
1399
1523
  [0.14.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.13.0...v0.14.0
1524
+ [0.13.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.12.0...v0.13.0
1525
+ [0.12.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.11.0...v0.12.0
1526
+ [0.11.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.10.0...v0.11.0
1527
+ [0.10.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.2...v0.10.0
1528
+ [0.9.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.1...v0.9.2
1529
+ [0.9.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.0...v0.9.1
1400
1530
  [0.9.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.2...v0.9.0
1401
1531
  [0.8.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.1...v0.8.2
1402
1532
  [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.3"
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,179 @@ 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.
137
+
138
+ ### Fork safety
139
+
140
+ `fork(2)` copies only the calling thread. A child of a process that has used
141
+ the SDK therefore inherits native state whose threads are gone: the tokio
142
+ runtime (workers, I/O driver), the core's process-wide default backend (each
143
+ SQLite connection of a `LocalBackend` is served by its own worker thread; a
144
+ `CloudBackend`'s pooled connections belong to the parent's runtime), and every
145
+ wrapped handle built on top of them. Using that state hangs or crashes — the
146
+ child's first catalog read used to wait out the pool's 30 s acquire timeout on
147
+ Linux and die with `SIGTRAP` on macOS — and so does *destroying* it, because
148
+ the destructors wake the same missing threads (on macOS, replacing an
149
+ inherited `LocalBackend` was enough to kill the child). One rule covers all of
150
+ it: **inherited native state is never used and never dropped in the child. It
151
+ is leaked, once per fork, and replaced by state built in the child.** Three
152
+ pieces implement it, all keyed on the pid (`runtime::current_pid`):
153
+
154
+ - **Runtime cell** (`runtime.rs`). The runtime and the pid it was built under
155
+ live in one heap cell behind one `AtomicPtr`. `runtime()` returns the cell's
156
+ runtime when its pid is ours; otherwise it builds a new cell and publishes
157
+ it with a compare-exchange (a loser of that race in the same process drops
158
+ its own, never-published runtime). There is no mutex, so nothing can be
159
+ inherited locked or poisoned, and no window in which pointer and pid
160
+ disagree. A cell built by a parent process is leaked: `Runtime`'s `Drop`
161
+ joins worker threads that do not exist.
162
+ - **Backend generation** (`backend.rs`). The binding records how the default
163
+ backend was chosen — the core's own ladder (environment, profile, config
164
+ file), or an explicit `set_default_backend`/`with_backend` selection — and
165
+ the pid it belongs to. `ensure_this_process_backend` runs, with the GVL
166
+ held, at the start of every `block_on` and of every synchronous entry that
167
+ reads the backend. In a process whose pid differs from the recorded one it
168
+ builds a fresh backend from the record, swaps it into the core and
169
+ `mem::forget`s the inherited one, along with the backends that open
170
+ `with_backend` scopes had saved for their restore. A scope the parent opened
171
+ can still be closed in the child: closing it installs a backend rebuilt from
172
+ how the saved one had been chosen. For the ladder this means the child
173
+ resolves it again, from its own environment, at its first native call; if
174
+ that fails, the call raises the configuration error, nothing is changed, and
175
+ the next call tries again. `set_default_backend` and a `with_backend` push
176
+ are the exception: they are about to replace the default anyway, so in a
177
+ child they take over with the backend they were asked for and never rebuild
178
+ the inherited selection — an explicit selection always gets a child out of
179
+ an inherited one that no longer resolves. Closing a scope works the same
180
+ way: a child whose first use of the backend is leaving a block its parent
181
+ entered takes over directly with what the scope restores, so it never needs
182
+ the scope's temporary backend (which it may be unable to rebuild). Closing a
183
+ scope always consumes it: when the backend it should restore cannot be
184
+ rebuilt in the child, the scope's temporary selection stays in effect and
185
+ the error is reported (by `with_backend` as a warning when the block itself
186
+ raised, so that the block's exception is the one that propagates).
187
+ - **`ForkGuarded<T>`** (`runtime.rs`). The payload of every `#[magnus::wrap]`
188
+ type sits in one, tagged with the creating pid. `get()` is the only way to
189
+ it for anything that acts on the value, and in another process it returns
190
+ `Microsandbox::ForkedHandleError` (there is no `Deref`, so an unchecked
191
+ access does not compile). Its `Drop` runs the payload's destructor only in
192
+ the creating process; a child that garbage-collects an inherited handle —
193
+ at the latest when it exits, since Ruby frees every wrapped object then —
194
+ leaks it. Accessors that only copy plain data out of the value (`name`,
195
+ `id`, the `SandboxHandle` snapshot fields, `ExecHandle#id`) skip the check
196
+ through `peek()`, so a child can still read the name it needs to open a
197
+ handle of its own.
198
+
199
+ What a child leaks is bounded: one runtime, the inherited default backend, the
200
+ saved backend of each open scope, and each inherited handle it collects; plus
201
+ one backend for each parent-opened scope it closes.
202
+
203
+ The mutexes involved (`backend.rs`) are only taken with the GVL held, and
204
+ nothing that runs under them calls into Ruby or releases the GVL — Ruby
205
+ exceptions are built after they are released. A Ruby-level `fork` needs the
206
+ GVL, so a child never inherits one of them locked.
207
+
208
+ Limits, by design:
209
+
210
+ - **Inherited file descriptors.** A child inherits the parent's open fds,
211
+ including the parent-watchdog pipe of every sandbox the parent created.
212
+ While such a child lives, the runtime sees no EOF on that pipe when the
213
+ parent exits, so the sandbox is not torn down until the child exits too.
214
+ - **Ownership.** A sandbox created before the fork belongs to the parent. The
215
+ child cannot act on it through the inherited handle; it can open its own by
216
+ name, with the usual ownership rules.
217
+ - **Threads mid-call.** A thread that is inside a native call when another
218
+ thread forks (the GVL is released there) may hold a lock in the core at
219
+ that instant — for example the read guard of the core's default-backend
220
+ `RwLock`, for the few instructions of an `Arc` clone — and the child's swap
221
+ then waits on that inherited lock forever. The binding cannot close this
222
+ window. Forking while another thread is inside a call is unsupported, as
223
+ with any threaded native library.
224
+ - **Other libraries.** The SDK no longer touches inherited state; code that
225
+ reaches it some other way can still hang or trap.
226
+ - **Pid reuse.** Process identity is the pid. A descendant that ends up with
227
+ the pid of an ancestor whose state it inherited without any SDK use in
228
+ between would be mistaken for that ancestor. This needs the pid counter to
229
+ wrap onto exactly that value.
230
+
231
+ `spec/unit/fork_safety_spec.rb` (catalog only, no runtime),
232
+ `spec/fake_runtime/fork_safety_spec.rb` (a live handle over the fake `msb`)
233
+ and `spec/integration/fork_safety_spec.rb` (a real microVM) pin the behaviour;
234
+ each forks from a probe process of its own.
77
235
 
78
236
  ## Error mapping
79
237
 
@@ -185,7 +343,7 @@ extension.
185
343
  `ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
186
344
  (`microsandbox` / `microsandbox-network`, pinned to the same tag as
187
345
  `Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
188
- — CI, `rake-compiler-dock` release containers, and end-user source installs —
346
+ — CI and end-user source installs —
189
347
  without an adjacent checkout. For fast local development against a sibling
190
348
  microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
191
349
  (gitignored); its `paths` override builds against the local crates instead of
@@ -195,25 +353,13 @@ git. The override must never be committed — it would break container builds.
195
353
 
196
354
  * **Source gem**: compiles the extension via `extconf.rb` (rb-sys
197
355
  `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).
356
+ * **Precompiled platform gems**: not built or published. Every platform
357
+ installs the source gem, which compiles the extension via `rb_sys` at install
358
+ time; the host runtime comes from the `microsandbox-rb-binaries` gem — or,
359
+ without it, is fetched on first use (see above). The companion binaries gems
360
+ below are unaffected. See [docs/releasing.md](docs/releasing.md).
215
361
  * **Runtime binaries gems** (`microsandbox-rb-binaries`): a distinct artifact
216
- from the precompiled *extension* gems above — no Ruby code beyond a small
362
+ from the SDK gem — no Ruby code beyond a small
217
363
  locator module, just the verified upstream `msb` + `libkrunfw` for one
218
364
  platform. CI's `binaries` job vendors and builds all three platforms on every
219
365
  run and smoke-tests the host one; the `package` job installs the source gem
@@ -269,7 +415,8 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
269
415
  host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
270
416
 
271
417
  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
418
+ upstream-gated — they exist at the pinned runtime,
419
+ `Microsandbox::RUNTIME_VERSION`): per-published-port host
273
420
  **bind address** (ports always bind loopback), network **interface overrides**,
274
421
  and inline **named-volume create-mode** (pre-create with `Volume.create`, then
275
422
  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
@@ -167,10 +167,10 @@ See [docs/observability.md](docs/observability.md).
167
167
  | Guest filesystem, streaming reads/writes, host copies | [docs/filesystem.md](docs/filesystem.md) |
168
168
  | Metrics, logs, `ping`/`touch`, live `modify` plans | [docs/observability.md](docs/observability.md) |
169
169
  | Image cache, private registries, snapshots | [docs/images.md](docs/images.md) |
170
- | Runtime binaries gem, `msb` resolution order, backends, environment variables | [docs/runtime.md](docs/runtime.md) |
170
+ | Runtime binaries gem, `msb` resolution order, backends, forking, 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.3" (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,15 @@ 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
+ | `0.17.3` | `v0.6.18` | gem-only fix release, fork safety: a forked child (Puma, Resque, Solid Queue) rebuilds its backend and runtime on its first SDK call instead of hanging or crashing, a handle created before the fork raises `ForkedHandleError` in the child, and nothing inherited from the parent is dropped there |
250
251
 
251
252
  ## Development
252
253
 
253
254
  ```bash
254
255
  bin/setup # bundle install + compile the native extension
255
256
  bundle exec rake compile # rebuild the extension (debug); compile:release for optimized
256
- bundle exec rake spec # unit specs (no runtime needed)
257
+ bundle exec rake spec # unit + fake-runtime specs (no runtime needed)
257
258
  MICROSANDBOX_INTEGRATION=1 bundle exec rspec spec/integration # boot real microVMs (KVM / Apple Silicon)
258
259
  bundle exec standardrb # Ruby lint
259
260
  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/errors.md CHANGED
@@ -56,6 +56,7 @@ options) raises a plain `ArgumentError` before the native layer is reached.
56
56
  | `UnsupportedOperationError` | `unsupported-operation` | The runtime version does not support the operation |
57
57
  | `CloudHttpError` | `cloud-http` | Cloud backend HTTP failure |
58
58
  | `UnsupportedError` | `unsupported` | The selected backend does not implement the operation; carries `#operation` (e.g. `"sandbox.kill"`) and `#hint` |
59
+ | `ForkedHandleError` | `forked-handle` | A handle was used in a process forked from the one that created it; open a new one in the child (see [runtime.md](runtime.md#forking-puma-resque-solid-queue)) |
59
60
 
60
61
  The snapshot classes go beyond the Python SDK (which collapses them into its
61
62
  base error) and match the Go SDK's per-variant coverage, so callers can rescue
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