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.
@@ -43,28 +43,123 @@ use std::ffi::c_void;
43
43
  use std::future::Future;
44
44
  use std::mem::ManuallyDrop;
45
45
  use std::panic::{catch_unwind, AssertUnwindSafe};
46
- use std::sync::atomic::{AtomicPtr, Ordering};
46
+ use std::sync::atomic::{AtomicPtr, AtomicU32, Ordering};
47
47
 
48
48
  use magnus::{Error, Ruby, TypedData};
49
49
  use tokio::runtime::Runtime;
50
50
 
51
51
  use crate::error;
52
52
 
53
- /// The current Ruby handle. Safe to call from any bound method or value
54
- /// conversion: we always hold the GVL there (conversions run after `block_on`
55
- /// returns and re-acquires it).
53
+ /// The current Ruby handle. Safe to call from any bound method, value
54
+ /// conversion or error construction (`error::*`): we always hold the GVL there
55
+ /// (both run after `block_on` returns and re-acquires it).
56
+ ///
57
+ /// Panics when the calling thread does not hold the GVL (a `nogvl` callback, a
58
+ /// tokio worker): `Ruby::get` checks that in release builds too, so a misplaced
59
+ /// call fails loudly instead of touching Ruby without the lock.
56
60
  pub fn ruby() -> Ruby {
57
- Ruby::get().expect("microsandbox: not on a Ruby thread")
61
+ Ruby::get().expect("microsandbox: not on a Ruby thread holding the GVL")
58
62
  }
59
63
 
60
- /// This process's id. The single `std::process::id()` call site: everything
61
- /// fork-aware in the extension (the runtime cell below, the backend generation
62
- /// in `backend.rs`, [`ForkGuarded`]) compares against this.
63
- pub fn current_pid() -> u32 {
64
- std::process::id()
64
+ //--------------------------------------------------------------------------------------------------
65
+ // Process identity
66
+ //--------------------------------------------------------------------------------------------------
67
+
68
+ /// Which process this is, for fork safety: the pid plus a fork generation.
69
+ ///
70
+ /// Everything fork-aware in the extension — the runtime cell below, the
71
+ /// backend record in `backend.rs`, [`ForkGuarded`] — tags what it builds with
72
+ /// the identity of the process that built it and compares that against
73
+ /// [`ProcessIdentity::current`] before use. Both halves are compared:
74
+ ///
75
+ /// * The pid alone is not enough. A descendant can end up with the pid of an
76
+ /// ancestor whose state it inherited, if the pid counter wraps onto exactly
77
+ /// that value and no SDK call happened in between; it would then pass for
78
+ /// that ancestor.
79
+ /// * The generation alone is not enough either. It counts the `fork`s that
80
+ /// ran this library's atfork child handler, and a process can be created
81
+ /// without running it — `vfork`, a raw `clone` — in which case only the pid
82
+ /// tells the child apart.
83
+ ///
84
+ /// Every `fork(2)` that runs atfork handlers (which `Kernel#fork` /
85
+ /// `Process.fork` always do: Ruby calls libc's `fork`) gives the child a
86
+ /// generation one higher than its parent's, so parent and child differ even
87
+ /// when their pids are equal.
88
+ #[derive(Clone, Copy, PartialEq, Eq)]
89
+ pub struct ProcessIdentity {
90
+ pub pid: u32,
91
+ generation: u32,
92
+ }
93
+
94
+ /// How many `fork`s, counted from the process that loaded the extension, led
95
+ /// to this process. Incremented only by [`on_fork_child`], in a child, and
96
+ /// inherited (as plain memory) by every child. 32 bits: it would take four
97
+ /// billion nested forks in one lineage to wrap, and a wrap matters only if
98
+ /// the pid matches as well.
99
+ static FORK_GENERATION: AtomicU32 = AtomicU32::new(0);
100
+
101
+ /// The `pthread_atfork` child handler. It runs in the new child before `fork`
102
+ /// returns there, while the child is single-threaded, and must be
103
+ /// async-signal-safe: one lock-free atomic add, nothing else (no allocation,
104
+ /// no lock, no Ruby). `Relaxed` is enough: the only thread that exists to read
105
+ /// the counter is this one, and threads started later see it through the
106
+ /// spawn's own synchronization. The parent never changes its counter.
107
+ extern "C" fn on_fork_child() {
108
+ FORK_GENERATION.fetch_add(1, Ordering::Relaxed);
109
+ }
110
+
111
+ /// Register [`on_fork_child`]. Called once, from the extension's init.
112
+ ///
113
+ /// Ruby never unloads (`dlclose`s) an extension, so the handler's code stays
114
+ /// mapped for the life of the process, as `pthread_atfork` requires — handlers
115
+ /// cannot be unregistered. If registration fails (`ENOMEM`), the generation
116
+ /// stays 0 and identity degrades to the pid alone, which is how the extension
117
+ /// behaved before the generation existed; that is not worth refusing to load
118
+ /// over. Platforms without `pthread_atfork` (the gem ships for macOS and
119
+ /// Linux only) get the same pid-only identity.
120
+ pub fn register_fork_handler() {
121
+ static ONCE: std::sync::Once = std::sync::Once::new();
122
+ ONCE.call_once(|| {
123
+ #[cfg(unix)]
124
+ // SAFETY: `on_fork_child` is an `extern "C" fn()` that stays valid for
125
+ // the life of the process (see above) and is async-signal-safe.
126
+ unsafe {
127
+ let _ = libc::pthread_atfork(None, None, Some(on_fork_child));
128
+ }
129
+ });
130
+ }
131
+
132
+ impl ProcessIdentity {
133
+ /// This process's identity. The single `std::process::id()` call site.
134
+ pub fn current() -> Self {
135
+ Self {
136
+ pid: std::process::id(),
137
+ generation: FORK_GENERATION.load(Ordering::Relaxed),
138
+ }
139
+ }
140
+
141
+ /// An identity from its two halves — ONLY for the fork-identity
142
+ /// diagnostics in `lib.rs`, which let the specs compare identities that no
143
+ /// real fork can produce on demand (an equal pid with another generation).
144
+ pub fn from_parts(pid: u32, generation: u32) -> Self {
145
+ Self { pid, generation }
146
+ }
147
+
148
+ /// The fork generation, for `Native.fork_generation` (a diagnostic the
149
+ /// fork specs read; not part of the Ruby API).
150
+ pub fn generation(self) -> u32 {
151
+ self.generation
152
+ }
153
+
154
+ /// Both halves in one word, for a lock-free atomic record. `0` is never a
155
+ /// real identity (no user process has pid 0), so it can mean "none".
156
+ pub fn to_bits(self) -> u64 {
157
+ (u64::from(self.pid) << 32) | u64::from(self.generation)
158
+ }
65
159
  }
66
160
 
67
- // The process-wide tokio runtime, tagged with the pid it was built under. A
161
+ // The process-wide tokio runtime, tagged with the identity of the process it
162
+ // was built under (see `ProcessIdentity`). A
68
163
  // multi-threaded runtime owns worker + I/O-driver threads, and `fork(2)` copies
69
164
  // ONLY the calling thread — so a child that inherits this runtime has a runtime
70
165
  // whose threads are gone: `block_on` can still drive the current thread, but the
@@ -72,12 +167,12 @@ pub fn current_pid() -> u32 {
72
167
  // connections stall/drop mid-use. This is hit in practice by forking job servers
73
168
  // (Solid Queue, Resque) and clustered web servers (Puma workers).
74
169
  //
75
- // The runtime and its pid live in ONE heap cell behind ONE atomic pointer, so a
76
- // reader sees either a whole cell or none: there is no window in which the
77
- // pointer is new and the pid old (or the reverse), and no mutex that a fork
78
- // could capture locked or that a panic could poison.
170
+ // The runtime and its process identity live in ONE heap cell behind ONE atomic
171
+ // pointer, so a reader sees either a whole cell or none: there is no window in
172
+ // which the pointer is new and the identity old (or the reverse), and no mutex
173
+ // that a fork could capture locked or that a panic could poison.
79
174
  struct RuntimeCell {
80
- pid: u32,
175
+ owner: ProcessIdentity,
81
176
  rt: Runtime,
82
177
  }
83
178
 
@@ -94,13 +189,13 @@ fn build_runtime() -> Runtime {
94
189
  /// The multi-threaded tokio runtime for THIS process, built on first use and
95
190
  /// rebuilt after a `fork(2)` (see the note above).
96
191
  pub fn runtime() -> &'static Runtime {
97
- let cur = current_pid();
192
+ let cur = ProcessIdentity::current();
98
193
  let mut observed = RUNTIME.load(Ordering::Acquire);
99
194
  // SAFETY (every dereference below): a non-null pointer in `RUNTIME` came
100
195
  // from `Box::into_raw` and is never freed once published, so it is valid
101
196
  // for `'static`.
102
197
  if let Some(cell) = unsafe { observed.as_ref() } {
103
- if cell.pid == cur {
198
+ if cell.owner == cur {
104
199
  // Built in this process: its threads are alive here.
105
200
  return &cell.rt;
106
201
  }
@@ -109,7 +204,7 @@ pub fn runtime() -> &'static Runtime {
109
204
  // Slow path: no runtime yet, or the one we see was built by a parent
110
205
  // process. Build ours and publish it with a compare-exchange.
111
206
  let fresh = Box::into_raw(Box::new(RuntimeCell {
112
- pid: cur,
207
+ owner: cur,
113
208
  rt: build_runtime(),
114
209
  }));
115
210
  loop {
@@ -117,12 +212,12 @@ pub fn runtime() -> &'static Runtime {
117
212
  // Published. The cell we replaced (if any) belonged to a parent
118
213
  // process and is LEAKED on purpose, never dropped: its worker
119
214
  // threads did not survive the fork, and `Runtime`'s `Drop` joins
120
- // them. One runtime is leaked per fork generation that uses the
215
+ // them. One runtime is leaked per forked process that uses the
121
216
  // SDK.
122
217
  Ok(_) => return unsafe { &(*fresh).rt },
123
218
  Err(winner) => {
124
219
  if let Some(cell) = unsafe { winner.as_ref() } {
125
- if cell.pid == cur {
220
+ if cell.owner == cur {
126
221
  // Another thread of THIS process won the race. Our
127
222
  // cell was never published and its threads are alive
128
223
  // here, so dropping it is safe (and joins them).
@@ -153,7 +248,7 @@ pub fn runtime() -> &'static Runtime {
153
248
  /// exits: Ruby frees every `T_DATA` object in `rb_objspace_call_finalizer` —
154
249
  /// leaks it instead.
155
250
  pub struct ForkGuarded<T> {
156
- pid: u32,
251
+ owner: ProcessIdentity,
157
252
  /// The Ruby class users know the handle by, for the error message.
158
253
  class: &'static str,
159
254
  inner: ManuallyDrop<T>,
@@ -162,7 +257,18 @@ pub struct ForkGuarded<T> {
162
257
  impl<T> ForkGuarded<T> {
163
258
  pub fn new(class: &'static str, inner: T) -> Self {
164
259
  Self {
165
- pid: current_pid(),
260
+ owner: ProcessIdentity::current(),
261
+ class,
262
+ inner: ManuallyDrop::new(inner),
263
+ }
264
+ }
265
+
266
+ /// A guard owned by `owner` instead of this process — ONLY for the
267
+ /// fork-identity diagnostic in `lib.rs`, which runs the real ownership
268
+ /// decision ([`get_in`](Self::get_in)) on identities given by a spec.
269
+ pub fn owned_by(owner: ProcessIdentity, class: &'static str, inner: T) -> Self {
270
+ Self {
271
+ owner,
166
272
  class,
167
273
  inner: ManuallyDrop::new(inner),
168
274
  }
@@ -171,11 +277,15 @@ impl<T> ForkGuarded<T> {
171
277
  /// The payload, or `ForkedHandleError` when this is not the process that
172
278
  /// created it.
173
279
  pub fn get(&self) -> Result<&T, Error> {
174
- let cur = current_pid();
175
- if self.pid == cur {
280
+ self.get_in(ProcessIdentity::current())
281
+ }
282
+
283
+ /// [`get`](Self::get) as decided in the process identified by `cur`.
284
+ pub fn get_in(&self, cur: ProcessIdentity) -> Result<&T, Error> {
285
+ if self.owner == cur {
176
286
  Ok(&self.inner)
177
287
  } else {
178
- Err(error::forked_handle(self.class, self.pid, cur))
288
+ Err(error::forked_handle(self.class, self.owner.pid, cur.pid))
179
289
  }
180
290
  }
181
291
 
@@ -191,7 +301,7 @@ impl<T> ForkGuarded<T> {
191
301
 
192
302
  impl<T> Drop for ForkGuarded<T> {
193
303
  fn drop(&mut self) {
194
- if self.pid == current_pid() {
304
+ if self.owner == ProcessIdentity::current() {
195
305
  // SAFETY: dropped exactly once, here; `inner` is not used again.
196
306
  unsafe { ManuallyDrop::drop(&mut self.inner) };
197
307
  }
@@ -209,6 +319,13 @@ impl<T> Drop for ForkGuarded<T> {
209
319
  /// Every module's `define` calls this for each type it wraps, right after
210
320
  /// defining the class, so wrapping a value afterwards is `TypedData::class`
211
321
  /// reading a resolved static plus `rb_data_typed_object_wrap`.
322
+ ///
323
+ /// Doing it at load also keeps that first resolution away from concurrent
324
+ /// callers, which matters since magnus 0.9: the static is a magnus `Lazy`, an
325
+ /// `OnceLock` whose initializer (the `const_get`) runs while holding the lock.
326
+ /// That `funcall` can switch Ruby threads, and a second thread reaching the
327
+ /// same lock would block on it while holding the GVL the first one needs to
328
+ /// finish.
212
329
  pub fn preload_class<T: TypedData>(ruby: &Ruby) {
213
330
  let _ = T::class(ruby);
214
331
  }
@@ -390,6 +507,9 @@ where
390
507
  /// `Err` means the future was never polled (see [`nogvl`], and the fork check
391
508
  /// below); propagate it with `?` before looking at the future's own output:
392
509
  /// `block_on(fut)?.map_err(error::to_ruby)?`.
510
+ ///
511
+ /// Every native call goes through this, with one deliberate exception: the
512
+ /// runtime installer, see [`block_on_runtime_install`].
393
513
  pub fn block_on<F>(fut: F) -> Result<F::Output, Error>
394
514
  where
395
515
  F: Future,
@@ -401,3 +521,29 @@ where
401
521
  crate::backend::ensure_this_process_backend()?;
402
522
  nogvl(|| runtime().block_on(fut))
403
523
  }
524
+
525
+ /// [`block_on`] without its backend check, for the runtime installer ONLY:
526
+ /// `Native.install` and `Native.setup` (`microsandbox::setup`).
527
+ ///
528
+ /// The check at the top of [`block_on`] is deliberate and stays there: it is
529
+ /// what makes every other native call safe after a fork. This entry is not a
530
+ /// cheaper alternative to it. It exists because the installer never touches
531
+ /// the default backend or the catalog — `Setup::install` creates the
532
+ /// `bin`/`lib` directories, runs the installed `msb --version`, copies a CI
533
+ /// build or downloads and unpacks the release bundle, and checks that the two
534
+ /// files exist, all under the base directory it resolves from `MSB_HOME` —
535
+ /// so in a forked child the check would only rebuild (and leak) a backend
536
+ /// nothing uses, or fail the install when the inherited backend selection no
537
+ /// longer resolves there. Skipping it leaves the backend record as it was:
538
+ /// the child's next call that does reach the backend is the one that takes
539
+ /// it over.
540
+ ///
541
+ /// Before adding a caller, trace its core path and confirm it never reaches
542
+ /// `microsandbox::default_backend()` (directly or through a `Sandbox`,
543
+ /// `Volume`, `Image`, `Snapshot` or agent function that resolves it).
544
+ pub fn block_on_runtime_install<F>(fut: F) -> Result<F::Output, Error>
545
+ where
546
+ F: Future,
547
+ {
548
+ nogvl(|| runtime().block_on(fut))
549
+ }
@@ -497,7 +497,7 @@ module Microsandbox
497
497
  opts = build_create_opts(**kwargs)
498
498
  Microsandbox.ensure_runtime!
499
499
  # Everything Ruby-level happens BEFORE `acquire`: its interrupt mask must
500
- # cover only the native call plus the wrap, never caller-supplied code.
500
+ # cover only the native call, never caller-supplied code.
501
501
  sandbox_name = name.to_s
502
502
  sandbox = acquire { Native::Sandbox.create(sandbox_name, opts) }
503
503
  return sandbox unless block_given?
@@ -603,17 +603,34 @@ module Microsandbox
603
603
  # sandbox would be garbage and only GC would stop it, "eventually"; for
604
604
  # `Timeout.timeout { Sandbox.create(...) }` that is not good enough. So:
605
605
  #
606
- # - the acquisition runs under `Thread.handle_interrupt(Object => :never)`,
607
- # which holds a queued exception (or kill) back until the wrapper is
608
- # assigned. The mask covers only the uninterruptible native call plus
609
- # the wrap, so it changes no observable timing; runtime provisioning
610
- # ({Microsandbox.ensure_runtime!}) and the block forms' `yield` stay
611
- # outside it, with normal interrupt handling. For the same reason the
612
- # block passed here must contain NOTHING but the native call: argument
613
- # conversion (`name.to_s`, option Hashes) is the caller's own code and
614
- # has to run before `acquire`, where it stays interruptible;
615
- # - the `ensure` plus the `acquired` flag — not `rescue Exception; raise` —
616
- # is what also covers `Thread#kill`, which no `rescue` can catch.
606
+ # - the native call runs under `Thread.handle_interrupt(Object => :never)`,
607
+ # which holds a queued exception (or kill) back until the bare
608
+ # {Native::Sandbox} is assigned to a local. The mask covers only that
609
+ # uninterruptible call, so it changes no observable timing; runtime
610
+ # provisioning ({Microsandbox.ensure_runtime!}), the wrapper's
611
+ # construction and the block forms' `yield` stay outside it, with
612
+ # normal interrupt handling. For the same reason the block passed here
613
+ # must contain NOTHING but the native call: argument conversion
614
+ # (`name.to_s`, option Hashes) is the caller's own code and has to run
615
+ # before `acquire`, where it stays interruptible;
616
+ # - the wrapper is built after the mask (`new` may run a subclass's
617
+ # `initialize`, which must not hold a `Timeout`/`Thread#kill` back), so
618
+ # a held-back interrupt is delivered as the mask ends, before `new`.
619
+ # Until `new` has returned, the cleanup therefore works on the bare
620
+ # native handle, never on the wrapper: an interrupt at that point, one
621
+ # that lands inside a subclass's `initialize`, and an exception that
622
+ # `initialize` raises itself all leave the caller without a reference,
623
+ # so all three stop the sandbox (when owned, see below);
624
+ # - the `ensure` plus the `handed_off` flag — not `rescue Exception;
625
+ # raise` — is what also covers `Thread#kill`, which no `rescue` can
626
+ # catch.
627
+ #
628
+ # The protected window is the native handle's assignment through the
629
+ # wrapper's construction, and the cleanup in it is best-effort: a
630
+ # *further* interrupt that lands during the cleanup, before the native
631
+ # `stop` has started, or one delivered at this method's return boundary
632
+ # (after `handed_off` is set, before the caller has assigned the result),
633
+ # leaves the teardown to GC.
617
634
  #
618
635
  # A SIGINT (Ctrl-C on the main thread) is the exception: it is not queued
619
636
  # like those two and cannot be masked. One that lands during the native
@@ -625,30 +642,32 @@ module Microsandbox
625
642
  # Ownership: {connect_or_create} can hand back a sandbox that was already
626
643
  # running for someone else, and {start} restarts a persisted one. The
627
644
  # cleanup therefore applies EXACTLY the predicate the block form of
628
- # {connect_or_create} uses — {Sandbox#owns_lifecycle?} — in all three
645
+ # {connect_or_create} uses — the lifecycle ownership behind
646
+ # {Sandbox#owns_lifecycle?}, read from the native handle — in all three
629
647
  # callers, {create} included: a sandbox this call does not own is never
630
648
  # stopped here. Its reference is just let go (GC closes the handle, the
631
649
  # sandbox keeps running).
632
650
  # @yieldreturn [Native::Sandbox]
633
651
  # @return [Sandbox]
634
652
  def acquire
635
- sandbox = nil
636
- acquired = false
653
+ native = nil
654
+ handed_off = false
637
655
  begin
638
656
  Thread.handle_interrupt(Object => :never) do
639
- sandbox = new(yield)
657
+ native = yield
640
658
  end
641
- acquired = true
659
+ sandbox = new(native)
660
+ handed_off = true
661
+ sandbox
642
662
  ensure
643
- if sandbox && !acquired && sandbox.owns_lifecycle?
663
+ if native && !handed_off
644
664
  begin
645
- sandbox.stop
665
+ native.stop if native.owns_lifecycle
646
666
  rescue Microsandbox::Error
647
667
  # best-effort cleanup; the interrupt is what the caller must see
648
668
  end
649
669
  end
650
670
  end
651
- sandbox
652
671
  end
653
672
 
654
673
  # @api private
@@ -8,7 +8,7 @@ module Microsandbox
8
8
  # Versioning section of the README for the full gem-to-runtime map. Must equal
9
9
  # the native ext's Cargo crate version (`Native.version`), enforced by
10
10
  # spec/unit/version_spec.rb.
11
- VERSION = "0.17.3"
11
+ VERSION = "0.17.4"
12
12
 
13
13
  # The upstream microsandbox runtime release this gem build embeds — the `tag`
14
14
  # pinned on the `microsandbox`/`microsandbox-network` git deps in
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: microsandbox-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.3
4
+ version: 0.17.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao