microsandbox-rb 0.17.3 → 0.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/DESIGN.md CHANGED
@@ -105,13 +105,27 @@ reached once the result is a Ruby object. What happens to it then:
105
105
 
106
106
  - **`Sandbox.create`, `Sandbox.connect_or_create`, `Sandbox.start`, and
107
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.
108
+ :never)`) around the native call only, so the bare `Native::Sandbox` is
109
+ bound to a local before a queued exception or kill can land; an `ensure`
110
+ then **stops the sandbox** when one cuts in before the caller has the
111
+ object — but only a sandbox the call owns (`owns_lifecycle`, the same
112
+ predicate as `connect_or_create`'s block form): one that was already
113
+ running for someone else is left running. The Ruby wrapper is constructed
114
+ *after* the mask, with normal interrupt handling, because `new` runs a
115
+ subclass's `initialize`: a constructor that blocks or runs long must not
116
+ hold a `Timeout.timeout` or `Thread#kill` back until it returns. So the
117
+ cleanup works on the native handle (its `owns_lifecycle` and `stop`), never
118
+ on the wrapper, and covers the window from the native handle's assignment
119
+ through the wrapper's construction: an interrupt delivered as the mask ends
120
+ (before `new` runs), one delivered inside a subclass's `initialize`, and an
121
+ exception that `initialize` raises itself. That cleanup is best-effort, not
122
+ a guarantee for every lost return: a *further* interrupt that arrives during
123
+ the cleanup itself, before the native `stop` has started, or one delivered
124
+ at the method's final return boundary, after the wrapper has been handed
125
+ off but before the caller has assigned it, leaves the teardown to GC (as it
126
+ did before construction moved out of the mask). A `SIGINT` cannot be masked
127
+ this way; it can arrive before the native handle is assigned, and then the
128
+ next case applies.
115
129
  - **Every other wrapper** (`ExecHandle`, streams, SSH clients, write sinks, …)
116
130
  and every plain result simply becomes garbage; GC frees the wrapper and the
117
131
  Rust value's `Drop` closes the session or connection.
@@ -129,7 +143,9 @@ for the thread's next blocking operation. The four acquisition methods are
129
143
  the exception: their own `Object => :never` mask sits on top of the caller's,
130
144
  so a deferred exception is not raised at the native call's entry there — the
131
145
  sandbox is returned and the exception is delivered at the next blocking
132
- operation after the acquisition.
146
+ operation after the acquisition. (If that blocking operation is inside a
147
+ subclass's `initialize`, the caller does not have the object yet, so the
148
+ sandbox is stopped as above.)
133
149
 
134
150
  `spec/fake_runtime/interrupt_safety_spec.rb` pins all of this against the real
135
151
  extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
@@ -148,23 +164,62 @@ Linux and die with `SIGTRAP` on macOS — and so does *destroying* it, because
148
164
  the destructors wake the same missing threads (on macOS, replacing an
149
165
  inherited `LocalBackend` was enough to kill the child). One rule covers all of
150
166
  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
167
+ is leaked, once per fork, and replaced by state built in the child.**
168
+
169
+ That needs a reliable answer to "was this built by the process I am?". The
170
+ binding's answer is a **process identity** (`runtime::ProcessIdentity`): the
171
+ pid together with a *fork generation*. At extension load the binding
172
+ registers a `pthread_atfork` child handler whose only work is one atomic
173
+ increment of a counter; the child inherits its parent's counter as plain
174
+ memory, and the handler, running in the child before `fork` returns there,
175
+ makes it one higher. `Kernel#fork` calls libc's `fork`, which runs atfork
176
+ handlers on Linux and macOS, so a child, grandchild, … of the loading process
177
+ has generation 1, 2, …, whatever its pid. Everything the binding builds is
178
+ tagged with the identity of the process that built it, and two identities
179
+ are equal only when both halves are:
180
+
181
+ - the generation tells apart a descendant that was handed an ancestor's pid
182
+ again by the kernel (the pid counter wrapped onto exactly that value)
183
+ without any SDK call in between — pid alone would take it for the
184
+ ancestor;
185
+ - the pid tells apart a process created without running atfork handlers
186
+ (`vfork`, a raw `clone`), whose generation equals its parent's.
187
+
188
+ The handler is async-signal-safe (a lock-free atomic add, no allocation, no
189
+ lock, no Ruby), as a child handler must be; `Relaxed` ordering suffices
190
+ because the forked child is single-threaded until it starts threads, which
191
+ see the counter through the spawn's own synchronization. Handlers cannot be
192
+ unregistered, and Ruby never unloads an extension, so the handler stays
193
+ valid for the life of the process. Should registration fail (`ENOMEM`), the
194
+ generation stays 0 and the identity is effectively the pid alone.
195
+ Three spec-only diagnostics on `Native` expose it: `fork_generation` returns
196
+ the counter, and `fork_identity_equal` / `fork_identity_guard` run the real
197
+ comparisons (`==`, the packed backend word, a handle's ownership check) on
198
+ identities a spec chooses — the only way to pin that both halves decide,
199
+ since a real fork changes both and pid reuse cannot be staged
200
+ (`spec/unit/fork_identity_spec.rb`).
201
+
202
+ Three pieces use the identity:
203
+
204
+ - **Runtime cell** (`runtime.rs`). The runtime and the identity of the process
205
+ that built it live in one heap cell behind one `AtomicPtr`. `runtime()`
206
+ returns the cell's runtime when that identity is ours; otherwise it builds a
207
+ new cell and publishes it with a compare-exchange (a loser of that race in
208
+ the same process drops its own, never-published runtime). There is no mutex,
209
+ so nothing can be inherited locked or poisoned, and no window in which
210
+ pointer and identity disagree. A cell built by a parent process is leaked:
211
+ `Runtime`'s `Drop` joins worker threads that do not exist.
212
+ - **Backend record** (`backend.rs`). The binding records how the default
163
213
  backend was chosen — the core's own ladder (environment, profile, config
164
214
  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
215
+ the identity of the process it belongs to (one `AtomicU64`, pid and
216
+ generation, written and read whole). `ensure_this_process_backend` runs,
217
+ with the GVL held, at the start of every `block_on` and of every synchronous
218
+ entry that reads the backend. The one exception is the runtime installer
219
+ (`Native.install`, `Native.setup`): it never reads the backend, so it runs
220
+ through `block_on_runtime_install`, which skips the check, and a child that
221
+ installs leaves the record alone for its first call that does use the
222
+ backend. In a process whose identity differs from the recorded one it
168
223
  builds a fresh backend from the record, swaps it into the core and
169
224
  `mem::forget`s the inherited one, along with the backends that open
170
225
  `with_backend` scopes had saved for their restore. A scope the parent opened
@@ -185,20 +240,23 @@ pieces implement it, all keyed on the pid (`runtime::current_pid`):
185
240
  the error is reported (by `with_backend` as a warning when the block itself
186
241
  raised, so that the block's exception is the one that propagates).
187
242
  - **`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.
243
+ type sits in one, tagged with the identity of the creating process. `get()`
244
+ is the only way to it for anything that acts on the value, and in another
245
+ process it returns `Microsandbox::ForkedHandleError` (there is no `Deref`,
246
+ so an unchecked access does not compile). The message names the creating pid
247
+ and the current one; when they are equal — a reused pid — it says so rather
248
+ than naming the same pid twice as two processes. Its `Drop` runs the
249
+ payload's destructor only in the creating process; a child that
250
+ garbage-collects an inherited handle — at the latest when it exits, since
251
+ Ruby frees every wrapped object then — leaks it. Accessors that only copy
252
+ plain data out of the value (`name`, `id`, the `SandboxHandle` snapshot
253
+ fields, `ExecHandle#id`) skip the check through `peek()`, so a child can
254
+ still read the name it needs to open a handle of its own.
255
+
256
+ What a child leaks is bounded: one runtime; once it uses the backend, the
257
+ inherited default backend and the saved backend of each open scope; each
258
+ inherited handle it collects; plus one backend for each parent-opened scope it
259
+ closes. A child that only installs the runtime builds no backend.
202
260
 
203
261
  The mutexes involved (`backend.rs`) are only taken with the GVL held, and
204
262
  nothing that runs under them calls into Ruby or releases the GVL — Ruby
@@ -223,10 +281,45 @@ Limits, by design:
223
281
  with any threaded native library.
224
282
  - **Other libraries.** The SDK no longer touches inherited state; code that
225
283
  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.
284
+ - **macOS system frameworks.** On macOS a forked child that has not called
285
+ `exec` cannot use the system frameworks the core reaches for network and
286
+ credential work: CoreFoundation and libobjc are not fork-safe, and the
287
+ child dies — `SIGSEGV`, or `SIGABRT` from libobjc's fork check — inside the
288
+ framework. It is a crash, not an exception; nothing can rescue it. Two such
289
+ calls sit on SDK paths. Every HTTP client the core builds (reqwest, with the
290
+ `system-proxy` feature that the core crate enables; a downstream crate
291
+ cannot turn a Cargo feature off) looks up the system proxy settings through
292
+ SystemConfiguration (`SCDynamicStoreCopyProxies`) while it is built; and a
293
+ pull without explicit `registry_auth:` first looks for registry credentials
294
+ in the Keychain (`keyring`). So in a forked child on macOS, each of these
295
+ kills the child:
296
+ - any use of a cloud backend, however it is chosen (`set_default_backend`,
297
+ `with_backend`, `MSB_BACKEND`/`MSB_API_KEY`, a profile) — the child's
298
+ rebuild of the backend described above builds a client;
299
+ - `Sandbox.create` with an image that is not in the image cache yet, or
300
+ with `pull_policy: :always` (the Keychain lookup, then the registry
301
+ client; with `registry_auth:` only the latter);
302
+ - `Image.load` (it builds a registry client even for a local archive);
303
+ - runtime provisioning: `Microsandbox.install`, `Microsandbox.setup`, and
304
+ the first-use install in `ensure_runtime!`.
305
+
306
+ Whether the parent used the SDK makes no difference: a child of a parent
307
+ that only required the gem dies the same way, and
308
+ `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` does not prevent it. Calls that
309
+ stay on the local catalog and runtime are unaffected, including
310
+ `Sandbox.create` with an image already in the cache. On Linux the cloud
311
+ case is exercised by `spec/unit/fork_safety_spec.rb` and works (those
312
+ examples are skipped on macOS). On macOS, do this work in a process that
313
+ was not forked — the parent before it forks, or a fresh one
314
+ (`Process.spawn`, or `fork` followed by `exec`) — for example, pull the
315
+ images the workers need in the master before forking them.
316
+ - **Forks that skip atfork handlers.** A process created with `vfork` or a
317
+ raw `clone` keeps its parent's generation, so only its pid identifies it.
318
+ Ruby itself never forks that way — `Kernel#fork` runs the handlers, and
319
+ `spawn`/`system` `exec` at once, which discards all of this state — so
320
+ mistaking such a process for its ancestor needs a native library that
321
+ clones without the handlers and then runs Ruby, together with exact pid
322
+ reuse.
230
323
 
231
324
  `spec/unit/fork_safety_spec.rb` (catalog only, no runtime),
232
325
  `spec/fake_runtime/fork_safety_spec.rb` (a live handle over the fake `msb`)
data/README.md CHANGED
@@ -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.3" (the gem's own version)
212
+ Microsandbox::VERSION # => "0.17.5" (the gem's own version)
213
213
  Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
214
214
  ```
215
215
 
@@ -245,9 +245,11 @@ constants against this gem's. Every release records its runtime in
245
245
  | `0.15.0` | `v0.6.14` | adopts upstream `v0.6.10`–`v0.6.14` step by step: bind-mount correctness, guest bootstrap off the kernel command line, DNS pins for deferred domain allows, Linux glibc 2.28 baseline for the prebuilt runtime, legacy ext4 upper-disk resize, `msb_krun` 0.1.32. Parity: `ssh.open_client`/`prepare_server` accept `inactivity_timeout:` (seconds; `0` disables, `nil` inherits the 600s global default) |
246
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` |
247
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:` |
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` |
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 (mitigates GHSA-rh2c-vm8j-5gg6), `Cargo.lock` advisory updates, argument-guard and cleanup fixes, `SECURITY.md` |
249
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
  | `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 |
251
+ | `0.17.4` | `v0.6.18` | gem-only fix release: fork safety identifies a process by pid and fork generation (survives pid reuse), `install`/`setup` leave the backend alone in a forked child, a `Sandbox` subclass's `initialize` runs outside the interrupt mask, and the macOS limit that a forked child must not build network clients is documented; rustls 0.23.46 fixes four TLS advisories (RUSTSEC-2026-0346 to -0349) |
252
+ | `0.17.5` | `v0.6.18` | gem-only security release: dependency updates clear RUSTSEC-2026-0381 to -0410, including the zstd used for image-layer decompression (three zstd advisories remain only on an external-kernel path the runtime never takes, see `CHANGELOG.md`); `SECURITY.md` maps more upstream advisories to gem versions |
251
253
 
252
254
  ## Development
253
255
 
data/SECURITY.md CHANGED
@@ -32,12 +32,20 @@ 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-rh2c-vm8j-5gg6](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-rh2c-vm8j-5gg6) — `Volume.fs` paths that pass through a missing directory escape the volume root | Critical | `0.7.0`–`0.17.0` | Mitigated in `0.17.1`: every `Volume.fs` / `VolumeInfo#fs` method refuses a path with a `..` segment, in both the Ruby and the native layer, which closes the published bypass (`write`, `mkdir`, `copy` and `rename` through a directory that does not exist). The complete fix is upstream's rewrite of host-side volume access in runtime `v0.7.6`, which also closes the window between checking a path and using it; it arrives with the first gem release on runtime `v0.7.6` or later, and there is none yet. The upstream advisory lists only `microsandbox <= 0.6.16` with no patched version, but the affected resolver is unchanged in every runtime up to `v0.7.5`, so gem `0.17.0` (runtime `v0.6.18`) is affected too. `Volume.fs` first appeared in `0.7.0`. |
35
36
  | [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
37
  | [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
38
  | [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
39
  | [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`. |
39
40
  | [GHSA-4vq3-cjpp-v7fg](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-4vq3-cjpp-v7fg) — guest-controlled symlinks in `msb copy` | High | `<= 0.10.0` (the bundled `msb` CLI) | Runtime fix included from `0.11.0`. |
41
+ | [GHSA-v693-3rfr-6q3q](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-v693-3rfr-6q3q) — IPv4-mapped IPv6 addresses bypass DNS rebind protection | Critical | `<= 0.9.1` | Runtime fix included from `0.9.2` (runtime `v0.6.3`). Affects network-enabled sandboxes. |
40
42
  | [GHSA-m8f5-rh7h-vgg3](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-m8f5-rh7h-vgg3) — secret values in world-readable process arguments | Medium | `<= 0.7.0` | Runtime fix included from `0.8.0`. |
43
+ | [GHSA-89j4-256r-3pcq](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-89j4-256r-3pcq) — symlink traversal when importing a snapshot archive | High | `0.5.7`–`0.5.9` (`Snapshot.import` and the bundled `msb snapshot import`) | Runtime fix included from `0.5.10` (runtime `v0.5.8`). |
44
+
45
+ [GHSA-xgg5-hjwq-2gw8](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-xgg5-hjwq-2gw8)
46
+ (domain deny rules bypassed by direct-IP TLS) does not affect any release of
47
+ this gem: it was fixed in runtime `v0.5.0`, and the first release, `0.5.7`,
48
+ embeds runtime `v0.5.7`.
41
49
 
42
50
  ### Mitigating GHSA-cg9x-2vfm-3j58 before the runtime fix
43
51
 
data/docs/filesystem.md CHANGED
@@ -42,4 +42,5 @@ through `Microsandbox::Volume.fs(name)` / `VolumeInfo#fs` (a `VolumeFs`). See
42
42
 
43
43
  Paths are relative to the volume root; a leading `/` is the volume root, not the
44
44
  host root. A path containing a `..` segment is refused with `FilesystemError` in
45
- every operation.
45
+ every operation. Versions `0.7.0`–`0.17.0` did not refuse such paths and are
46
+ affected by GHSA-rh2c-vm8j-5gg6; see [SECURITY.md](../SECURITY.md).
data/docs/runtime.md CHANGED
@@ -114,7 +114,9 @@ backend), chosen the same way as the parent's: an explicit
114
114
  `set_default_backend`, an enclosing `with_backend` block, or — when neither
115
115
  was used — the environment and config file as the child sees them at that
116
116
  call. Everything addressed by name works in the child from then on:
117
- `Sandbox.create`/`get`/`list`/`start`, `Volume`, `Image`, `Snapshot`.
117
+ `Sandbox.create`/`get`/`list`/`start`, `Volume`, `Image`, `Snapshot` — except
118
+ that on macOS a forked child must not do anything that builds a network
119
+ client (see [macOS](#macos-no-network-clients-in-a-forked-child) below).
118
120
 
119
121
  If the parent's choice cannot be made again in the child (say the child's
120
122
  environment now selects the cloud backend without credentials), SDK calls
@@ -126,6 +128,7 @@ does not depend on the inherited choice and works regardless.
126
128
  Microsandbox::Sandbox.list # the parent has used the SDK
127
129
 
128
130
  pid = fork do
131
+ # On macOS "alpine" must already be in the image cache (see below).
129
132
  Microsandbox::Sandbox.create("job-#{Process.pid}", image: "alpine") do |sb|
130
133
  puts sb.exec("echo", ["hello from the worker"]).stdout
131
134
  end
@@ -137,8 +140,8 @@ What does **not** cross a fork is an object created before it. A `Sandbox`,
137
140
  `SandboxHandle`, `ExecHandle`, `ExecStdin`, `PullSession`, log/metrics/file
138
141
  stream, `SshClient`/`SftpClient`/`SshServer`, `AgentClient` or `VolumeFs`
139
142
  belongs to the process that created it. In a forked child, any call that acts
140
- on one raises `Microsandbox::ForkedHandleError` (the message names both
141
- pids); the original keeps working in the parent. Reading `name` or `id` — and
143
+ on one raises `Microsandbox::ForkedHandleError` (the message names the
144
+ pids involved); the original keeps working in the parent. Reading `name` or `id` — and
142
145
  the snapshot fields of a `SandboxHandle` — still works, so a child can open
143
146
  its own handle:
144
147
 
@@ -189,6 +192,33 @@ Things to know:
189
192
  parent's runtime, backend and whichever inherited handles it collects)
190
193
  instead of destroying it — destroying it is what used to crash.
191
194
 
195
+ ### macOS: no network clients in a forked child
196
+
197
+ On macOS, a forked child that has not called `exec` dies — `SIGSEGV` or
198
+ `SIGABRT`, not an exception, so it cannot be rescued — as soon as the SDK
199
+ builds a network client or reads registry credentials from the Keychain
200
+ there. macOS's system frameworks do not support being used after `fork`
201
+ without `exec`, and every HTTP client the SDK builds looks up the system proxy
202
+ settings through them. In a forked child on macOS, do not:
203
+
204
+ - use a cloud backend at all, however it is selected (`set_default_backend`,
205
+ `with_backend`, `MSB_BACKEND`/`MSB_API_KEY`, a profile);
206
+ - `Sandbox.create` with an image that is not in the image cache yet, or with
207
+ `pull_policy: :always`;
208
+ - `Image.load`;
209
+ - install the runtime: `Microsandbox.install`, `Microsandbox.setup`, or the
210
+ first-use install `ensure_runtime!` performs.
211
+
212
+ It makes no difference whether the parent used the SDK, and
213
+ `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` does not help. Local work is
214
+ unaffected, including `Sandbox.create` with an image that is already cached.
215
+ On Linux the cloud backend works in a forked child.
216
+
217
+ Do that work in a process that was not forked: in the parent before it forks
218
+ (install the runtime and pull the images the workers will use — creating a
219
+ sandbox from the image pulls it), or in a fresh process started with
220
+ `Process.spawn` (or `fork` followed by `exec`).
221
+
192
222
  ## One runtime line per home
193
223
 
194
224
  The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
data/docs/surface.md CHANGED
@@ -59,7 +59,10 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
59
59
  - **Errors** — the typed hierarchy in [errors.md](errors.md).
60
60
  - **Process model** — the SDK may be used before and after `fork`; objects
61
61
  created before a fork are refused in the child with `ForkedHandleError`
62
- (see [runtime.md](runtime.md#forking-puma-resque-solid-queue)).
62
+ (see [runtime.md](runtime.md#forking-puma-resque-solid-queue)). On macOS a
63
+ forked child must not build network clients — cloud backends, image pulls,
64
+ `Image.load`, runtime install — or it crashes (see
65
+ [runtime.md](runtime.md#macos-no-network-clients-in-a-forked-child)).
63
66
 
64
67
  Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
65
68
  `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, named `scripts`, the image
@@ -8,7 +8,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
9
  # The core-crate dependencies below stay on their own tag, which is
10
10
  # Microsandbox::RUNTIME_VERSION and moves independently of this version.
11
- version = "0.17.3"
11
+ version = "0.17.5"
12
12
  authors = ["Super Rad Company <development@superrad.company>"]
13
13
  repository = "https://github.com/superradcompany/microsandbox"
14
14
  license = "Apache-2.0"
@@ -25,12 +25,19 @@ crate-type = ["cdylib"]
25
25
  [dependencies]
26
26
  # "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
27
27
  # uses to check interrupts without letting Ruby longjmp over Rust frames.
28
- magnus = { version = "0.8", features = ["rb-sys"] }
28
+ # Default features are off: `old-api` (the free functions that conjure a `Ruby`
29
+ # handle unchecked in release builds) must not creep back, and `io` only gates
30
+ # `magnus::io`, which the extension does not use.
31
+ magnus = { version = "0.9", default-features = false, features = ["rb-sys"] }
29
32
  # Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
30
33
  # release during blocking sandbox calls) and rb_thread_check_ints. Kept in
31
34
  # lock-step with magnus's own rb-sys.
32
35
  rb-sys = "0.9"
33
36
 
37
+ # `pthread_atfork`, for the fork generation (runtime::ProcessIdentity). Already
38
+ # in the dependency graph; listed so the binding can call it directly.
39
+ libc = "0.2"
40
+
34
41
  # The microsandbox core runtime, wrapped via FFI exactly like the official
35
42
  # Python (pyo3) and Node (napi) SDKs. Pinned to the matching upstream tag so the
36
43
  # gem is self-contained and buildable anywhere (CI, release containers, end-user
@@ -16,14 +16,14 @@
16
16
  //! matching Python and Node.
17
17
 
18
18
  use std::collections::HashMap;
19
- use std::sync::atomic::{AtomicU32, Ordering};
19
+ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
20
20
  use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
21
21
 
22
22
  use magnus::{function, prelude::*, Error, RModule, Ruby};
23
23
  use microsandbox::{Backend, MicrosandboxError, Operation};
24
24
 
25
25
  use crate::error;
26
- use crate::runtime::{block_on, current_pid};
26
+ use crate::runtime::{block_on, ProcessIdentity};
27
27
 
28
28
  /// Resolve the ambient default backend, requiring it to be local.
29
29
  ///
@@ -99,7 +99,7 @@ fn build_backend(
99
99
  }
100
100
 
101
101
  //--------------------------------------------------------------------------------------------------
102
- // Fork safety: the backend generation
102
+ // Fork safety: the backend record
103
103
  //--------------------------------------------------------------------------------------------------
104
104
  //
105
105
  // `fork(2)` copies only the calling thread. A child of a process that used the
@@ -114,7 +114,7 @@ fn build_backend(
114
114
  // So the child never touches what it inherited: the first native call after a
115
115
  // fork builds a fresh backend the same way the inherited one was chosen and
116
116
  // swaps it in, and every inherited `Arc` is `mem::forget`-ed — leaked once per
117
- // fork generation — instead of dropped.
117
+ // forked process — instead of dropped.
118
118
  //
119
119
  // Locking. The three mutexes below are only ever taken on a Ruby thread that
120
120
  // holds the GVL, and nothing that runs while one is held calls into Ruby or
@@ -158,9 +158,11 @@ impl Selection {
158
158
 
159
159
  static SELECTION: Mutex<Selection> = Mutex::new(Selection::Ladder);
160
160
 
161
- /// The pid of the process whose default backend the core currently holds.
162
- /// 0 = no native operation has touched the backend in this process lineage.
163
- static BACKEND_PID: AtomicU32 = AtomicU32::new(0);
161
+ /// The identity of the process whose default backend the core currently holds
162
+ /// ([`ProcessIdentity::to_bits`]: pid and fork generation in one word, so it
163
+ /// is read and written whole). 0 = no native operation has touched the
164
+ /// backend in this process lineage.
165
+ static BACKEND_OWNER: AtomicU64 = AtomicU64::new(0);
164
166
 
165
167
  static REBUILD_LOCK: Mutex<()> = Mutex::new(());
166
168
 
@@ -183,7 +185,7 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
183
185
  /// above).
184
186
  pub fn ensure_this_process_backend() -> Result<(), Error> {
185
187
  // The Ruby exception is built here, after the locks are released.
186
- adopt_backend(current_pid(), None)
188
+ adopt_backend(ProcessIdentity::current(), None)
187
189
  .map(|_| ())
188
190
  .map_err(error::to_ruby)
189
191
  }
@@ -204,23 +206,24 @@ pub fn ensure_this_process_backend() -> Result<(), Error> {
204
206
  /// Returns `Some(inherited selection)` when `replacement` was installed here
205
207
  /// (the caller has nothing left to install), `None` when the caller proceeds
206
208
  /// as in any process that already owns its backend. On an error nothing has
207
- /// changed, `BACKEND_PID` included, so the next call tries again.
209
+ /// changed, `BACKEND_OWNER` included, so the next call tries again.
208
210
  fn adopt_backend(
209
- cur: u32,
211
+ cur: ProcessIdentity,
210
212
  replacement: Option<&Selection>,
211
213
  ) -> Result<Option<Selection>, MicrosandboxError> {
212
- if BACKEND_PID.load(Ordering::Acquire) == cur {
214
+ let cur_bits = cur.to_bits();
215
+ if BACKEND_OWNER.load(Ordering::Acquire) == cur_bits {
213
216
  return Ok(None);
214
217
  }
215
218
  let _rebuild = lock(&REBUILD_LOCK);
216
- let seen = BACKEND_PID.load(Ordering::Acquire);
217
- if seen == cur {
219
+ let seen = BACKEND_OWNER.load(Ordering::Acquire);
220
+ if seen == cur_bits {
218
221
  return Ok(None);
219
222
  }
220
223
  if seen == 0 {
221
224
  // Nothing was inherited; the core resolves its default lazily, in this
222
225
  // process, on first use.
223
- BACKEND_PID.store(cur, Ordering::Release);
226
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
224
227
  return Ok(None);
225
228
  }
226
229
 
@@ -233,14 +236,14 @@ fn adopt_backend(
233
236
  // The inherited default must never be dropped (see above).
234
237
  std::mem::forget(microsandbox::swap_default_backend(fresh));
235
238
  // Neither may the backends that open `with_backend` scopes saved for their
236
- // restore. The entries themselves stay — pid and selection — so a scope
239
+ // restore. The entries themselves stay — owner and selection — so a scope
237
240
  // the parent opened can still be closed here (see `pop_default_backend`).
238
241
  for scope in lock(backend_scopes()).values_mut() {
239
242
  std::mem::forget(scope.previous.take());
240
243
  }
241
244
  let inherited =
242
245
  replacement.map(|selection| std::mem::replace(&mut *lock(&SELECTION), selection.clone()));
243
- BACKEND_PID.store(cur, Ordering::Release);
246
+ BACKEND_OWNER.store(cur_bits, Ordering::Release);
244
247
  Ok(inherited)
245
248
  }
246
249
 
@@ -261,7 +264,7 @@ fn set_default_backend(
261
264
  // A forked child that starts by selecting a backend: the inherited default
262
265
  // is forgotten and this selection installed in its place, without ever
263
266
  // rebuilding the inherited one.
264
- if adopt_backend(current_pid(), Some(&selection))
267
+ if adopt_backend(ProcessIdentity::current(), Some(&selection))
265
268
  .map_err(error::to_ruby)?
266
269
  .is_some()
267
270
  {
@@ -283,7 +286,7 @@ static BACKEND_SCOPES: OnceLock<Mutex<HashMap<u32, Scope>>> = OnceLock::new();
283
286
  /// What an open `with_backend` scope restores when it closes.
284
287
  struct Scope {
285
288
  /// The process that opened the scope.
286
- pid: u32,
289
+ owner: ProcessIdentity,
287
290
  /// The backend that was the default before the push, kept to be put back
288
291
  /// by a pop in the same process. `None` when there is no such backend of
289
292
  /// this process's own: a forked child took it out to leak it, or the push
@@ -308,7 +311,7 @@ fn push_default_backend(
308
311
  api_key: Option<String>,
309
312
  profile: Option<String>,
310
313
  ) -> Result<u32, Error> {
311
- let cur = current_pid();
314
+ let cur = ProcessIdentity::current();
312
315
  let selection = Selection::Explicit {
313
316
  kind,
314
317
  url,
@@ -332,7 +335,7 @@ fn push_default_backend(
332
335
  lock(backend_scopes()).insert(
333
336
  token,
334
337
  Scope {
335
- pid: cur,
338
+ owner: cur,
336
339
  previous,
337
340
  selection: before,
338
341
  },
@@ -352,7 +355,7 @@ fn push_default_backend(
352
355
  /// is a state the caller can describe and leave with `set_default_backend`;
353
356
  /// a token nobody can retry (`with_backend` never hands it out) would not be.
354
357
  fn pop_default_backend(token: u32) -> Result<(), Error> {
355
- let cur = current_pid();
358
+ let cur = ProcessIdentity::current();
356
359
  // (The registry lock is not held while an exception is built.)
357
360
  let Some(scope) = lock(backend_scopes()).remove(&token) else {
358
361
  return Err(error::base_error("unknown backend scope token"));
@@ -384,7 +387,7 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
384
387
 
385
388
  match scope.previous {
386
389
  // Opened and closed in the same process: put the saved backend back.
387
- Some(previous) if scope.pid == cur => microsandbox::set_default_backend(previous),
390
+ Some(previous) if scope.owner == cur => microsandbox::set_default_backend(previous),
388
391
  // No backend of this process's own was saved (see `Scope::previous`):
389
392
  // restore one built here from how the saved one had been chosen. On
390
393
  // failure the temporary backend stays installed (see above). The
@@ -393,8 +396,9 @@ fn pop_default_backend(token: u32) -> Result<(), Error> {
393
396
  // forked child ever drops a backend it might have inherited. That
394
397
  // leaks at most one backend per scope closed this way.
395
398
  previous => {
396
- // `None` unless the pid of the process that opened the scope was
397
- // reused; an inherited `Arc` is leaked either way.
399
+ // Always `None` here: a process that is not the scope's opener
400
+ // took the saved backend out when it adopted (`adopt_backend`).
401
+ // An inherited `Arc` would be leaked either way.
398
402
  std::mem::forget(previous);
399
403
  let fresh = scope.selection.build().map_err(error::to_ruby)?;
400
404
  std::mem::forget(microsandbox::swap_default_backend(fresh));