microsandbox-rb 0.17.3 → 0.17.4

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: 6d3b616f0ff13a9961eab2e0cdd3812f859754efd7489d5857db0e49588f2b73
4
- data.tar.gz: a172d9b8b62bb19f8b4cc2accfadec90f56b83b0e99112109a42032b7ca9a6f4
3
+ metadata.gz: 7d72d5c6e7a8d135e93d02d64c8966cf6cd4b7dcbf17d02bb65d074a963a977c
4
+ data.tar.gz: cde0f1e7c9d8b8270a98dd333e7e49709d24bd237ee2c7a6c671b8913f1b3393
5
5
  SHA512:
6
- metadata.gz: 9357333f88d8061e822da080bdc014438b7ca8bda1b94584a01e89fa1278c9d661b5a5eb2a7e5e36e5879076c2e9f6e82cb067dd6a4aa86023f16360953369ed
7
- data.tar.gz: e7b24e0067df67c76a57f5b204f743d9420c4cd9d43f717bd3576e0324023493b68821d65e1c910168ad87a3e76261dcbb825b1476184b6c9318891cb4a99c1a
6
+ metadata.gz: 5c1ceaecff30c64f9fd7f138c75acf080202468f56859b613a52d7ef482f8da32b82c4bf0f4191a50e0804e0938fa62c4ceff1b771b8df4cd0d1e10b06a19c81
7
+ data.tar.gz: 4a817a61508dd57b041c56b351be03d6dfc317d1a84079c9e0049e9dde374583252afe91522fdb7c61fbce547d87dc326d4c3bb2aaa77fb27b69e68669907b69
data/CHANGELOG.md CHANGED
@@ -8,6 +8,72 @@ wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.17.4] - 2026-10-14
12
+
13
+ Gem-only fix release; the embedded runtime stays at **`v0.6.18`**. Fork safety
14
+ now also survives pid reuse (a process is identified by its pid and fork
15
+ generation together), `install`/`setup` in a forked child no longer touch the
16
+ backend, a `Sandbox` subclass's `initialize` no longer runs with interrupts
17
+ masked, and the macOS limit that a forked child must not build network clients
18
+ is documented. It also picks up dependency updates: magnus 0.9, tokio 1.53,
19
+ rb-sys 0.9.130, and rustls 0.23.46 for four TLS advisories (see Security).
20
+
21
+ ### Fixed
22
+
23
+ - **A `Sandbox` subclass's `initialize` no longer runs with interrupts
24
+ masked.** `Sandbox.create`, `.connect_or_create` and `.start` (and
25
+ `PullSession#sandbox`) hold a queued `Thread#raise`/`Timeout`/`Thread#kill`
26
+ back only while the native call runs; the wrapper is now built after that,
27
+ so a subclass whose constructor blocks or runs long no longer delays a
28
+ `Timeout.timeout` or `Thread#kill` until the constructor returns. The
29
+ best-effort interrupt cleanup moved to the native handle with it: an
30
+ interrupt that lands before or during `initialize`, or an exception
31
+ `initialize` raises, still stops the sandbox when the call owns it and
32
+ leaves one it merely connected to running.
33
+ Behaviour of `Sandbox` itself is unchanged.
34
+
35
+ - **`Microsandbox.install` and `Microsandbox.setup` no longer take over the
36
+ backend in a forked child.** They went through the same entry as every
37
+ other native call, so in a child they first rebuilt the backend inherited
38
+ from the parent — which they never use — and failed with the configuration
39
+ error when the parent's selection no longer resolved in the child (say its
40
+ environment selected the cloud backend without an API key). They now leave
41
+ the backend alone; the child's first call that uses it takes it over, as
42
+ before.
43
+
44
+ - **Fork safety no longer identifies a process by its pid alone.** A forked
45
+ process that the kernel had given the pid of an ancestor whose SDK state it
46
+ inherited (the pid counter wrapped onto exactly that value, with no SDK call
47
+ in between) was taken for that ancestor: it would have used the inherited
48
+ runtime, backend and handles instead of rebuilding them or raising
49
+ `ForkedHandleError`. The extension now counts forks with a `pthread_atfork`
50
+ child handler and identifies a process by its pid and that count together.
51
+ `ForkedHandleError`'s message no longer names one pid as two processes when
52
+ the pids are equal.
53
+
54
+ ### Security
55
+
56
+ - **rustls 0.23.45 → 0.23.46** (a transitive dependency, through the network
57
+ stack's DNS client and the HTTP client). Fixes RUSTSEC-2026-0346 (`Debug`
58
+ output of client session values exposed TLS secrets), RUSTSEC-2026-0347
59
+ (buffered application data sent to the outer server after ECH rejection),
60
+ RUSTSEC-2026-0348 (Brotli certificate decompression could allocate up to
61
+ 1 GiB before authentication) and RUSTSEC-2026-0349 (an unauthenticated
62
+ ServerHello could downgrade later connections to classical key exchange).
63
+
64
+ ### Docs
65
+
66
+ - **Known limitation: on macOS a forked child must not build network
67
+ clients.** A child forked (without `exec`) from a Ruby process on macOS
68
+ crashes — `SIGSEGV` or `SIGABRT`, which cannot be rescued — when the SDK
69
+ builds an HTTP client or reads the Keychain there: any cloud backend use,
70
+ `Sandbox.create` with an image not yet cached (or `pull_policy: :always`),
71
+ `Image.load`, and runtime install/setup. The macOS system frameworks behind
72
+ the proxy lookup and the Keychain are not usable after `fork`. Do that work
73
+ in the parent before forking, or in a process started with `Process.spawn`.
74
+ On Linux the cloud backend works in a forked child; new fork specs cover it
75
+ there (they are skipped on macOS). See [runtime.md](docs/runtime.md#macos-no-network-clients-in-a-forked-child).
76
+
11
77
  ## [0.17.3] - 2026-10-13
12
78
 
13
79
  Gem-only fix release; the embedded runtime stays at **`v0.6.18`**. A process
@@ -1513,7 +1579,8 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
1513
1579
  core crate has Apple-native deps). Until precompiled gems are published,
1514
1580
  installing from source requires a Rust toolchain (stable >= 1.91).
1515
1581
 
1516
- [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.3...HEAD
1582
+ [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.4...HEAD
1583
+ [0.17.4]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.3...v0.17.4
1517
1584
  [0.17.3]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.2...v0.17.3
1518
1585
  [0.17.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...v0.17.2
1519
1586
  [0.17.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.0...v0.17.1
data/Cargo.lock CHANGED
@@ -971,9 +971,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
971
971
 
972
972
  [[package]]
973
973
  name = "chacha20"
974
- version = "0.10.0"
974
+ version = "0.10.2"
975
975
  source = "registry+https://github.com/rust-lang/crates.io-index"
976
- checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
976
+ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
977
977
  dependencies = [
978
978
  "cfg-if",
979
979
  "cipher 0.5.2",
@@ -1597,9 +1597,9 @@ dependencies = [
1597
1597
 
1598
1598
  [[package]]
1599
1599
  name = "der"
1600
- version = "0.8.0"
1600
+ version = "0.8.2"
1601
1601
  source = "registry+https://github.com/rust-lang/crates.io-index"
1602
- checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
1602
+ checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
1603
1603
  dependencies = [
1604
1604
  "const-oid",
1605
1605
  "pem-rfc7468",
@@ -1923,7 +1923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1923
1923
  checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1924
1924
  dependencies = [
1925
1925
  "libc",
1926
- "windows-sys 0.52.0",
1926
+ "windows-sys 0.61.2",
1927
1927
  ]
1928
1928
 
1929
1929
  [[package]]
@@ -2051,7 +2051,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
2051
2051
  dependencies = [
2052
2052
  "io-lifetimes 2.0.4",
2053
2053
  "rustix",
2054
- "windows-sys 0.52.0",
2054
+ "windows-sys 0.59.0",
2055
2055
  ]
2056
2056
 
2057
2057
  [[package]]
@@ -2858,7 +2858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2858
2858
  checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
2859
2859
  dependencies = [
2860
2860
  "io-lifetimes 3.0.1",
2861
- "windows-sys 0.52.0",
2861
+ "windows-sys 0.60.2",
2862
2862
  ]
2863
2863
 
2864
2864
  [[package]]
@@ -3289,9 +3289,9 @@ dependencies = [
3289
3289
 
3290
3290
  [[package]]
3291
3291
  name = "magnus"
3292
- version = "0.8.2"
3292
+ version = "0.9.2"
3293
3293
  source = "registry+https://github.com/rust-lang/crates.io-index"
3294
- checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012"
3294
+ checksum = "b9068710e00d21be762edca59b9c4c8946dbdb29820d4f76ad559afea07982c9"
3295
3295
  dependencies = [
3296
3296
  "magnus-macros",
3297
3297
  "rb-sys",
@@ -3301,9 +3301,9 @@ dependencies = [
3301
3301
 
3302
3302
  [[package]]
3303
3303
  name = "magnus-macros"
3304
- version = "0.8.0"
3304
+ version = "0.9.1"
3305
3305
  source = "registry+https://github.com/rust-lang/crates.io-index"
3306
- checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892"
3306
+ checksum = "9dafde2eeb66f090e0f326112323eb8324b12cb32b5d1210e541d3df6b0ce77e"
3307
3307
  dependencies = [
3308
3308
  "proc-macro2",
3309
3309
  "quote",
@@ -3660,11 +3660,12 @@ dependencies = [
3660
3660
 
3661
3661
  [[package]]
3662
3662
  name = "microsandbox_rb"
3663
- version = "0.17.3"
3663
+ version = "0.17.4"
3664
3664
  dependencies = [
3665
3665
  "chrono",
3666
3666
  "futures",
3667
3667
  "ipnetwork",
3668
+ "libc",
3668
3669
  "magnus",
3669
3670
  "microsandbox",
3670
3671
  "microsandbox-network",
@@ -4312,9 +4313,9 @@ dependencies = [
4312
4313
 
4313
4314
  [[package]]
4314
4315
  name = "pageant"
4315
- version = "0.2.1"
4316
+ version = "0.2.4"
4316
4317
  source = "registry+https://github.com/rust-lang/crates.io-index"
4317
- checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
4318
+ checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
4318
4319
  dependencies = [
4319
4320
  "base16ct",
4320
4321
  "byteorder",
@@ -4733,7 +4734,7 @@ dependencies = [
4733
4734
  "once_cell",
4734
4735
  "socket2",
4735
4736
  "tracing",
4736
- "windows-sys 0.52.0",
4737
+ "windows-sys 0.60.2",
4737
4738
  ]
4738
4739
 
4739
4740
  [[package]]
@@ -4870,18 +4871,18 @@ dependencies = [
4870
4871
 
4871
4872
  [[package]]
4872
4873
  name = "rb-sys"
4873
- version = "0.9.128"
4874
+ version = "0.9.130"
4874
4875
  source = "registry+https://github.com/rust-lang/crates.io-index"
4875
- checksum = "45ca28513560e56cfb79a62b1fce363c73af170a182024ce880c77ee9429920a"
4876
+ checksum = "02faf625bb10ba893e3ae620f19c9fb1b5f8fcae0fe4eb86bb3f2230fad75edb"
4876
4877
  dependencies = [
4877
4878
  "rb-sys-build",
4878
4879
  ]
4879
4880
 
4880
4881
  [[package]]
4881
4882
  name = "rb-sys-build"
4882
- version = "0.9.128"
4883
+ version = "0.9.130"
4883
4884
  source = "registry+https://github.com/rust-lang/crates.io-index"
4884
- checksum = "ce04b2c55eff3a21aaa623fcc655d94373238e72cac6b3e1a3641ff31649f99a"
4885
+ checksum = "05be6f9c86fe5482808162826f6c047d093b3670582296c770de1d94f8066694"
4885
4886
  dependencies = [
4886
4887
  "bindgen",
4887
4888
  "lazy_static",
@@ -5270,7 +5271,7 @@ dependencies = [
5270
5271
  "errno",
5271
5272
  "libc",
5272
5273
  "linux-raw-sys",
5273
- "windows-sys 0.52.0",
5274
+ "windows-sys 0.61.2",
5274
5275
  ]
5275
5276
 
5276
5277
  [[package]]
@@ -5285,9 +5286,9 @@ dependencies = [
5285
5286
 
5286
5287
  [[package]]
5287
5288
  name = "rustls"
5288
- version = "0.23.45"
5289
+ version = "0.23.46"
5289
5290
  source = "registry+https://github.com/rust-lang/crates.io-index"
5290
- checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
5291
+ checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
5291
5292
  dependencies = [
5292
5293
  "aws-lc-rs",
5293
5294
  "log",
@@ -5339,7 +5340,7 @@ dependencies = [
5339
5340
  "security-framework 3.7.0",
5340
5341
  "security-framework-sys",
5341
5342
  "webpki-root-certs",
5342
- "windows-sys 0.52.0",
5343
+ "windows-sys 0.61.2",
5343
5344
  ]
5344
5345
 
5345
5346
  [[package]]
@@ -5969,9 +5970,9 @@ dependencies = [
5969
5970
 
5970
5971
  [[package]]
5971
5972
  name = "spin"
5972
- version = "0.9.8"
5973
+ version = "0.9.9"
5973
5974
  source = "registry+https://github.com/rust-lang/crates.io-index"
5974
- checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
5975
+ checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
5975
5976
  dependencies = [
5976
5977
  "lock_api",
5977
5978
  ]
@@ -6413,7 +6414,7 @@ dependencies = [
6413
6414
  "getrandom 0.4.2",
6414
6415
  "once_cell",
6415
6416
  "rustix",
6416
- "windows-sys 0.52.0",
6417
+ "windows-sys 0.61.2",
6417
6418
  ]
6418
6419
 
6419
6420
  [[package]]
@@ -6531,9 +6532,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
6531
6532
 
6532
6533
  [[package]]
6533
6534
  name = "tokio"
6534
- version = "1.52.3"
6535
+ version = "1.53.2"
6535
6536
  source = "registry+https://github.com/rust-lang/crates.io-index"
6536
- checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
6537
+ checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
6537
6538
  dependencies = [
6538
6539
  "bytes",
6539
6540
  "libc",
@@ -7210,7 +7211,7 @@ version = "0.1.11"
7210
7211
  source = "registry+https://github.com/rust-lang/crates.io-index"
7211
7212
  checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7212
7213
  dependencies = [
7213
- "windows-sys 0.52.0",
7214
+ "windows-sys 0.61.2",
7214
7215
  ]
7215
7216
 
7216
7217
  [[package]]
@@ -7521,7 +7522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
7521
7522
  checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
7522
7523
  dependencies = [
7523
7524
  "bitflags 2.13.0",
7524
- "windows-sys 0.52.0",
7525
+ "windows-sys 0.59.0",
7525
7526
  ]
7526
7527
 
7527
7528
  [[package]]
@@ -7620,13 +7621,14 @@ dependencies = [
7620
7621
 
7621
7622
  [[package]]
7622
7623
  name = "wnaf"
7623
- version = "0.14.0"
7624
+ version = "0.14.1"
7624
7625
  source = "registry+https://github.com/rust-lang/crates.io-index"
7625
- checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
7626
+ checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
7626
7627
  dependencies = [
7627
7628
  "ff",
7628
7629
  "group",
7629
7630
  "hybrid-array",
7631
+ "primefield",
7630
7632
  ]
7631
7633
 
7632
7634
  [[package]]
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.4" (the gem's own version)
213
213
  Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
214
214
  ```
215
215
 
@@ -248,6 +248,7 @@ constants against this gem's. Every release records its runtime in
248
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
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) |
251
252
 
252
253
  ## Development
253
254
 
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