microsandbox-rb 0.17.2 → 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.
data/docs/runtime.md CHANGED
@@ -104,6 +104,121 @@ operations (create/start/stop/remove/get/list, one-shot exec, follow log
104
104
  streaming); unsupported operations raise `Microsandbox::UnsupportedError`
105
105
  (with `#operation` and `#hint`).
106
106
 
107
+ ## Forking (Puma, Resque, Solid Queue)
108
+
109
+ Forking a process that has already used the SDK is supported: preload the
110
+ application, use the SDK in the master, fork workers, use it in the workers.
111
+ The first SDK call in a child sets up its own runtime threads and its own
112
+ backend (a new catalog connection pool, or a new HTTP client for the cloud
113
+ backend), chosen the same way as the parent's: an explicit
114
+ `set_default_backend`, an enclosing `with_backend` block, or — when neither
115
+ was used — the environment and config file as the child sees them at that
116
+ call. Everything addressed by name works in the child from then on:
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).
120
+
121
+ If the parent's choice cannot be made again in the child (say the child's
122
+ environment now selects the cloud backend without credentials), SDK calls
123
+ there raise the configuration error until that is repaired. Selecting a
124
+ backend in the child — `set_default_backend`, or entering `with_backend` —
125
+ does not depend on the inherited choice and works regardless.
126
+
127
+ ```ruby
128
+ Microsandbox::Sandbox.list # the parent has used the SDK
129
+
130
+ pid = fork do
131
+ # On macOS "alpine" must already be in the image cache (see below).
132
+ Microsandbox::Sandbox.create("job-#{Process.pid}", image: "alpine") do |sb|
133
+ puts sb.exec("echo", ["hello from the worker"]).stdout
134
+ end
135
+ end
136
+ Process.wait(pid)
137
+ ```
138
+
139
+ What does **not** cross a fork is an object created before it. A `Sandbox`,
140
+ `SandboxHandle`, `ExecHandle`, `ExecStdin`, `PullSession`, log/metrics/file
141
+ stream, `SshClient`/`SftpClient`/`SshServer`, `AgentClient` or `VolumeFs`
142
+ belongs to the process that created it. In a forked child, any call that acts
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
145
+ the snapshot fields of a `SandboxHandle` — still works, so a child can open
146
+ its own handle:
147
+
148
+ ```ruby
149
+ sandbox = Microsandbox::Sandbox.create("shared", image: "alpine")
150
+
151
+ pid = fork do
152
+ begin
153
+ sandbox.exec("true") # the parent's handle
154
+ rescue Microsandbox::ForkedHandleError => e
155
+ warn e.message
156
+ end
157
+ mine = Microsandbox::Sandbox.get(sandbox.name).connect_or_start
158
+ puts mine.exec("echo", ["hello"]).stdout
159
+ end
160
+ Process.wait(pid)
161
+ sandbox.stop
162
+ ```
163
+
164
+ Things to know:
165
+
166
+ - **Cleanup code inherited by the child.** The block forms
167
+ (`Sandbox.create(...) { }`, `connect_or_create`) swallow the
168
+ `ForkedHandleError` their teardown hits when a forked child leaves the
169
+ block, so the child does not stop the parent's sandbox. An `ensure
170
+ sandbox.stop` of your own raises it in the child; rescue it there, or run
171
+ the cleanup only in the process that created the sandbox.
172
+ - **Leaving a `with_backend` block in the child.** When the backend to
173
+ restore was the parent's — the child was forked inside the block, or the
174
+ block is the child's first use of the SDK — leaving the block installs a
175
+ backend chosen the way that one had been. The block's own temporary backend
176
+ is not needed for that: if only it can no longer be chosen in the child (a
177
+ cloud profile that is gone there), leaving the block still restores the
178
+ previous backend. If the choice to restore no longer resolves in the child,
179
+ the block is closed anyway and its temporary selection stays in effect.
180
+ `with_backend` then raises the configuration error — unless the block
181
+ itself raised, in which case the block's exception propagates and the
182
+ restore failure is reported as a warning.
183
+ - **A child keeps the parent's sandboxes alive.** A child inherits the
184
+ parent's open file descriptors, among them the pipe through which a sandbox
185
+ notices that its owner is gone. If the parent exits while such a child is
186
+ still running, the sandbox is not torn down until that child exits as well.
187
+ Fork before creating sandboxes, or create them in the children.
188
+ - **Do not fork while another thread is inside an SDK call.** That thread
189
+ does not exist in the child, and a lock it held at that instant stays
190
+ locked there.
191
+ - **Memory.** A child leaks, once, the native state it inherited (the
192
+ parent's runtime, backend and whichever inherited handles it collects)
193
+ instead of destroying it — destroying it is what used to crash.
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
+
107
222
  ## One runtime line per home
108
223
 
109
224
  The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
data/docs/surface.md CHANGED
@@ -57,6 +57,12 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
57
57
  - **Registries** — `registry_auth`/`registry_insecure`/`registry_ca_certs` on
58
58
  `create`.
59
59
  - **Errors** — the typed hierarchy in [errors.md](errors.md).
60
+ - **Process model** — the SDK may be used before and after `fork`; objects
61
+ created before a fork are refused in the child with `ForkedHandleError`
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)).
60
66
 
61
67
  Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
62
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.2"
11
+ version = "0.17.4"
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
@@ -14,7 +14,7 @@ use microsandbox::agent::AgentClient as CoreAgentClient;
14
14
  use microsandbox::{AgentBridge, BridgeFrame, MicrosandboxError};
15
15
 
16
16
  use crate::error;
17
- use crate::runtime::{block_on, ruby};
17
+ use crate::runtime::{block_on, ruby, ForkGuarded};
18
18
 
19
19
  /// Map an agent-client error onto the Ruby exception hierarchy (via the core
20
20
  /// `MicrosandboxError::AgentClient` wrapper, exactly like the Python binding).
@@ -24,13 +24,13 @@ fn to_ruby_agent(err: microsandbox::AgentClientError) -> Error {
24
24
 
25
25
  #[magnus::wrap(class = "Microsandbox::Native::AgentClient", free_immediately, size)]
26
26
  pub struct AgentClient {
27
- inner: Arc<AgentBridge>,
27
+ inner: ForkGuarded<Arc<AgentBridge>>,
28
28
  }
29
29
 
30
30
  impl AgentClient {
31
31
  fn from_bridge(bridge: AgentBridge) -> Self {
32
32
  Self {
33
- inner: Arc::new(bridge),
33
+ inner: ForkGuarded::new("Microsandbox::AgentClient", Arc::new(bridge)),
34
34
  }
35
35
  }
36
36
 
@@ -60,6 +60,9 @@ impl AgentClient {
60
60
 
61
61
  /// Resolve a sandbox's agent relay socket path without connecting.
62
62
  fn socket_path(name: String) -> Result<String, Error> {
63
+ // Synchronous, and the core derives the path from the default
64
+ // backend's config.
65
+ crate::backend::ensure_this_process_backend()?;
63
66
  let path = CoreAgentClient::socket_path(&name).map_err(error::to_ruby)?;
64
67
  Ok(path.to_string_lossy().into_owned())
65
68
  }
@@ -71,7 +74,7 @@ impl AgentClient {
71
74
  /// Send one frame and await a single response frame ({id, flags, body}).
72
75
  fn request(&self, flags: u8, body: RString) -> Result<RHash, Error> {
73
76
  let body = unsafe { body.as_slice() }.to_vec();
74
- let inner = Arc::clone(&self.inner);
77
+ let inner = Arc::clone(self.inner.get()?);
75
78
  let frame =
76
79
  block_on(async move { inner.request(flags, body).await })?.map_err(to_ruby_agent)?;
77
80
  Ok(frame_to_hash(frame))
@@ -80,7 +83,7 @@ impl AgentClient {
80
83
  /// Open a streaming session; returns {id, handle}.
81
84
  fn stream_open(&self, flags: u8, body: RString) -> Result<RHash, Error> {
82
85
  let body = unsafe { body.as_slice() }.to_vec();
83
- let inner = Arc::clone(&self.inner);
86
+ let inner = Arc::clone(self.inner.get()?);
84
87
  let (id, handle) = block_on(async move { inner.stream_open(flags, body).await })?
85
88
  .map_err(to_ruby_agent)?;
86
89
  let hash = ruby().hash_new();
@@ -91,7 +94,7 @@ impl AgentClient {
91
94
 
92
95
  /// Pull the next frame from a stream; nil at end-of-stream.
93
96
  fn stream_next(&self, handle: u64) -> Result<Option<RHash>, Error> {
94
- let inner = Arc::clone(&self.inner);
97
+ let inner = Arc::clone(self.inner.get()?);
95
98
  let frame =
96
99
  block_on(async move { inner.stream_next(handle).await })?.map_err(to_ruby_agent)?;
97
100
  Ok(frame.map(frame_to_hash))
@@ -99,7 +102,7 @@ impl AgentClient {
99
102
 
100
103
  /// Close a stream handle. Idempotent.
101
104
  fn stream_close(&self, handle: u64) -> Result<(), Error> {
102
- let inner = Arc::clone(&self.inner);
105
+ let inner = Arc::clone(self.inner.get()?);
103
106
  block_on(async move { inner.stream_close(handle).await })?;
104
107
  Ok(())
105
108
  }
@@ -107,19 +110,19 @@ impl AgentClient {
107
110
  /// Send a follow-up frame on an existing correlation id.
108
111
  fn send(&self, id: u32, flags: u8, body: RString) -> Result<(), Error> {
109
112
  let body = unsafe { body.as_slice() }.to_vec();
110
- let inner = Arc::clone(&self.inner);
113
+ let inner = Arc::clone(self.inner.get()?);
111
114
  block_on(async move { inner.send(id, flags, body).await })?.map_err(to_ruby_agent)
112
115
  }
113
116
 
114
117
  /// Cached handshake `core.ready` frame body bytes (CBOR).
115
118
  fn ready_bytes(&self) -> Result<RString, Error> {
116
- let bytes = self.inner.ready_bytes().map_err(to_ruby_agent)?;
119
+ let bytes = self.inner.get()?.ready_bytes().map_err(to_ruby_agent)?;
117
120
  Ok(ruby().str_from_slice(&bytes))
118
121
  }
119
122
 
120
123
  /// Close the connection. Idempotent.
121
124
  fn close(&self) -> Result<(), Error> {
122
- let inner = Arc::clone(&self.inner);
125
+ let inner = Arc::clone(self.inner.get()?);
123
126
  block_on(async move { inner.close().await })?;
124
127
  Ok(())
125
128
  }