microsandbox-rb 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76670a6753a6503a0764ed173db3c33a1eff9a62de7a7d9343bc76f8143c1ba
4
- data.tar.gz: 80d4d4f1c2bbb3f47dd8f714602e6b01a841d62971b70b5682d3ad0c7dcff904
3
+ metadata.gz: 6d3b616f0ff13a9961eab2e0cdd3812f859754efd7489d5857db0e49588f2b73
4
+ data.tar.gz: a172d9b8b62bb19f8b4cc2accfadec90f56b83b0e99112109a42032b7ca9a6f4
5
5
  SHA512:
6
- metadata.gz: 74429f29417c1b2c2e6aeee35948d3c5201d5ac9d001beba4efceca484dbea53f0041e84f0f7fa0fef5933a89f3e317737fb134d6329267eb5e239fe56d7e297
7
- data.tar.gz: d4d5072d1a68079a8bfe199e8c53e0d92e7614515e1326e81333f857c1b53b681575f8df2162abb167ea6228841ffba2631f4d2b8df38552040c946966223c2c
6
+ metadata.gz: 9357333f88d8061e822da080bdc014438b7ca8bda1b94584a01e89fa1278c9d661b5a5eb2a7e5e36e5879076c2e9f6e82cb067dd6a4aa86023f16360953369ed
7
+ data.tar.gz: e7b24e0067df67c76a57f5b204f743d9420c4cd9d43f717bd3576e0324023493b68821d65e1c910168ad87a3e76261dcbb825b1476184b6c9318891cb4a99c1a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.17.3] - 2026-10-13
12
+
13
+ Gem-only fix release; the embedded runtime stays at **`v0.6.18`**. A process
14
+ forked after the SDK was used — a Puma, Resque or Solid Queue worker — used to
15
+ hang for 30 s on Linux, or die with `SIGTRAP` on macOS, on its first SDK call.
16
+ The child's first SDK call now builds a fresh backend, and a handle created
17
+ before the fork raises the new `ForkedHandleError` there instead of being used.
18
+
19
+ ### Fixed
20
+
21
+ - **Forking a process that has used the SDK no longer hangs or kills the
22
+ child.** A forked child inherited the parent's default backend, whose SQLite
23
+ worker threads (local) or pooled connections (cloud) do not exist in it: the
24
+ child's first catalog call waited out a 30 s pool timeout on Linux and then
25
+ raised a plain `Microsandbox::Error`, and on macOS the child died with
26
+ `SIGTRAP` — as it did when it merely selected a backend, because destroying
27
+ the inherited one traps too. The first SDK call in a child now builds a
28
+ fresh backend the same way the parent's was chosen (`set_default_backend`,
29
+ an enclosing `with_backend`, or the environment) and leaks the inherited one
30
+ instead of destroying it; a `with_backend` block entered before the fork can
31
+ be left in the child, and selecting a backend there works even when the
32
+ inherited choice no longer resolves. If the backend such a block should
33
+ restore cannot be chosen again in the child, the block's temporary selection
34
+ stays in effect and an exception raised by the block is no longer replaced
35
+ by the restore failure (which is warned about instead). The tokio runtime, already rebuilt after a fork, now
36
+ keeps its pointer and pid in one atomic cell, without the mutex a fork could
37
+ capture locked. See the new "Forking" section of
38
+ [docs/runtime.md](docs/runtime.md#forking-puma-resque-solid-queue) for what
39
+ is supported and what is not.
40
+ - A handle created before a fork (`Sandbox`, `SandboxHandle`, `ExecHandle`,
41
+ streams, SSH and agent clients, `VolumeFs`, ...) is refused in the child
42
+ with `ForkedHandleError` instead of being driven on the parent's dead
43
+ runtime, and a child that garbage-collects one — at the latest when it
44
+ exits — no longer runs its destructor. `Sandbox#owns_lifecycle?` is refused
45
+ the same way; `name`, `id` and the `SandboxHandle` snapshot fields stay
46
+ readable.
47
+
48
+ ### Added
49
+
50
+ - `Microsandbox::ForkedHandleError` (`#code` `"forked-handle"`), raised when a
51
+ handle is used in a process forked from the one that created it. The message
52
+ names both pids.
53
+
11
54
  ## [0.17.2] - 2026-10-12
12
55
 
13
56
  Gem-only fix and release-pipeline hardening release; the embedded runtime
@@ -1470,7 +1513,8 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
1470
1513
  core crate has Apple-native deps). Until precompiled gems are published,
1471
1514
  installing from source requires a Rust toolchain (stable >= 1.91).
1472
1515
 
1473
- [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.2...HEAD
1516
+ [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.3...HEAD
1517
+ [0.17.3]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.2...v0.17.3
1474
1518
  [0.17.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...v0.17.2
1475
1519
  [0.17.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.0...v0.17.1
1476
1520
  [0.17.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.16.0...v0.17.0
data/Cargo.lock CHANGED
@@ -3660,7 +3660,7 @@ dependencies = [
3660
3660
 
3661
3661
  [[package]]
3662
3662
  name = "microsandbox_rb"
3663
- version = "0.17.2"
3663
+ version = "0.17.3"
3664
3664
  dependencies = [
3665
3665
  "chrono",
3666
3666
  "futures",
data/DESIGN.md CHANGED
@@ -135,6 +135,104 @@ operation after the acquisition.
135
135
  extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
136
136
  log shows whether a sandbox was stopped, dropped, or left attached.
137
137
 
138
+ ### Fork safety
139
+
140
+ `fork(2)` copies only the calling thread. A child of a process that has used
141
+ the SDK therefore inherits native state whose threads are gone: the tokio
142
+ runtime (workers, I/O driver), the core's process-wide default backend (each
143
+ SQLite connection of a `LocalBackend` is served by its own worker thread; a
144
+ `CloudBackend`'s pooled connections belong to the parent's runtime), and every
145
+ wrapped handle built on top of them. Using that state hangs or crashes — the
146
+ child's first catalog read used to wait out the pool's 30 s acquire timeout on
147
+ Linux and die with `SIGTRAP` on macOS — and so does *destroying* it, because
148
+ the destructors wake the same missing threads (on macOS, replacing an
149
+ inherited `LocalBackend` was enough to kill the child). One rule covers all of
150
+ it: **inherited native state is never used and never dropped in the child. It
151
+ is leaked, once per fork, and replaced by state built in the child.** Three
152
+ pieces implement it, all keyed on the pid (`runtime::current_pid`):
153
+
154
+ - **Runtime cell** (`runtime.rs`). The runtime and the pid it was built under
155
+ live in one heap cell behind one `AtomicPtr`. `runtime()` returns the cell's
156
+ runtime when its pid is ours; otherwise it builds a new cell and publishes
157
+ it with a compare-exchange (a loser of that race in the same process drops
158
+ its own, never-published runtime). There is no mutex, so nothing can be
159
+ inherited locked or poisoned, and no window in which pointer and pid
160
+ disagree. A cell built by a parent process is leaked: `Runtime`'s `Drop`
161
+ joins worker threads that do not exist.
162
+ - **Backend generation** (`backend.rs`). The binding records how the default
163
+ backend was chosen — the core's own ladder (environment, profile, config
164
+ file), or an explicit `set_default_backend`/`with_backend` selection — and
165
+ the pid it belongs to. `ensure_this_process_backend` runs, with the GVL
166
+ held, at the start of every `block_on` and of every synchronous entry that
167
+ reads the backend. In a process whose pid differs from the recorded one it
168
+ builds a fresh backend from the record, swaps it into the core and
169
+ `mem::forget`s the inherited one, along with the backends that open
170
+ `with_backend` scopes had saved for their restore. A scope the parent opened
171
+ can still be closed in the child: closing it installs a backend rebuilt from
172
+ how the saved one had been chosen. For the ladder this means the child
173
+ resolves it again, from its own environment, at its first native call; if
174
+ that fails, the call raises the configuration error, nothing is changed, and
175
+ the next call tries again. `set_default_backend` and a `with_backend` push
176
+ are the exception: they are about to replace the default anyway, so in a
177
+ child they take over with the backend they were asked for and never rebuild
178
+ the inherited selection — an explicit selection always gets a child out of
179
+ an inherited one that no longer resolves. Closing a scope works the same
180
+ way: a child whose first use of the backend is leaving a block its parent
181
+ entered takes over directly with what the scope restores, so it never needs
182
+ the scope's temporary backend (which it may be unable to rebuild). Closing a
183
+ scope always consumes it: when the backend it should restore cannot be
184
+ rebuilt in the child, the scope's temporary selection stays in effect and
185
+ the error is reported (by `with_backend` as a warning when the block itself
186
+ raised, so that the block's exception is the one that propagates).
187
+ - **`ForkGuarded<T>`** (`runtime.rs`). The payload of every `#[magnus::wrap]`
188
+ type sits in one, tagged with the creating pid. `get()` is the only way to
189
+ it for anything that acts on the value, and in another process it returns
190
+ `Microsandbox::ForkedHandleError` (there is no `Deref`, so an unchecked
191
+ access does not compile). Its `Drop` runs the payload's destructor only in
192
+ the creating process; a child that garbage-collects an inherited handle —
193
+ at the latest when it exits, since Ruby frees every wrapped object then —
194
+ leaks it. Accessors that only copy plain data out of the value (`name`,
195
+ `id`, the `SandboxHandle` snapshot fields, `ExecHandle#id`) skip the check
196
+ through `peek()`, so a child can still read the name it needs to open a
197
+ handle of its own.
198
+
199
+ What a child leaks is bounded: one runtime, the inherited default backend, the
200
+ saved backend of each open scope, and each inherited handle it collects; plus
201
+ one backend for each parent-opened scope it closes.
202
+
203
+ The mutexes involved (`backend.rs`) are only taken with the GVL held, and
204
+ nothing that runs under them calls into Ruby or releases the GVL — Ruby
205
+ exceptions are built after they are released. A Ruby-level `fork` needs the
206
+ GVL, so a child never inherits one of them locked.
207
+
208
+ Limits, by design:
209
+
210
+ - **Inherited file descriptors.** A child inherits the parent's open fds,
211
+ including the parent-watchdog pipe of every sandbox the parent created.
212
+ While such a child lives, the runtime sees no EOF on that pipe when the
213
+ parent exits, so the sandbox is not torn down until the child exits too.
214
+ - **Ownership.** A sandbox created before the fork belongs to the parent. The
215
+ child cannot act on it through the inherited handle; it can open its own by
216
+ name, with the usual ownership rules.
217
+ - **Threads mid-call.** A thread that is inside a native call when another
218
+ thread forks (the GVL is released there) may hold a lock in the core at
219
+ that instant — for example the read guard of the core's default-backend
220
+ `RwLock`, for the few instructions of an `Arc` clone — and the child's swap
221
+ then waits on that inherited lock forever. The binding cannot close this
222
+ window. Forking while another thread is inside a call is unsupported, as
223
+ with any threaded native library.
224
+ - **Other libraries.** The SDK no longer touches inherited state; code that
225
+ reaches it some other way can still hang or trap.
226
+ - **Pid reuse.** Process identity is the pid. A descendant that ends up with
227
+ the pid of an ancestor whose state it inherited without any SDK use in
228
+ between would be mistaken for that ancestor. This needs the pid counter to
229
+ wrap onto exactly that value.
230
+
231
+ `spec/unit/fork_safety_spec.rb` (catalog only, no runtime),
232
+ `spec/fake_runtime/fork_safety_spec.rb` (a live handle over the fake `msb`)
233
+ and `spec/integration/fork_safety_spec.rb` (a real microVM) pin the behaviour;
234
+ each forks from a probe process of its own.
235
+
138
236
  ## Error mapping
139
237
 
140
238
  The core returns one big `MicrosandboxError` enum. `error.rs` maps each variant
data/README.md CHANGED
@@ -167,7 +167,7 @@ See [docs/observability.md](docs/observability.md).
167
167
  | Guest filesystem, streaming reads/writes, host copies | [docs/filesystem.md](docs/filesystem.md) |
168
168
  | Metrics, logs, `ping`/`touch`, live `modify` plans | [docs/observability.md](docs/observability.md) |
169
169
  | Image cache, private registries, snapshots | [docs/images.md](docs/images.md) |
170
- | Runtime binaries gem, `msb` resolution order, backends, environment variables | [docs/runtime.md](docs/runtime.md) |
170
+ | Runtime binaries gem, `msb` resolution order, backends, forking, environment variables | [docs/runtime.md](docs/runtime.md) |
171
171
  | Error classes and stable codes | [docs/errors.md](docs/errors.md) |
172
172
  | Complete supported surface and what is not yet exposed | [docs/surface.md](docs/surface.md) |
173
173
  | Release process, release preflight, binaries gems | [docs/releasing.md](docs/releasing.md) |
@@ -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.2" (the gem's own version)
212
+ Microsandbox::VERSION # => "0.17.3" (the gem's own version)
213
213
  Microsandbox.runtime_version # => "v0.6.18" (the embedded upstream runtime tag)
214
214
  ```
215
215
 
@@ -247,6 +247,7 @@ constants against this gem's. Every release records its runtime in
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
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
+ | `0.17.3` | `v0.6.18` | gem-only fix release, fork safety: a forked child (Puma, Resque, Solid Queue) rebuilds its backend and runtime on its first SDK call instead of hanging or crashing, a handle created before the fork raises `ForkedHandleError` in the child, and nothing inherited from the parent is dropped there |
250
251
 
251
252
  ## Development
252
253
 
data/docs/errors.md CHANGED
@@ -56,6 +56,7 @@ options) raises a plain `ArgumentError` before the native layer is reached.
56
56
  | `UnsupportedOperationError` | `unsupported-operation` | The runtime version does not support the operation |
57
57
  | `CloudHttpError` | `cloud-http` | Cloud backend HTTP failure |
58
58
  | `UnsupportedError` | `unsupported` | The selected backend does not implement the operation; carries `#operation` (e.g. `"sandbox.kill"`) and `#hint` |
59
+ | `ForkedHandleError` | `forked-handle` | A handle was used in a process forked from the one that created it; open a new one in the child (see [runtime.md](runtime.md#forking-puma-resque-solid-queue)) |
59
60
 
60
61
  The snapshot classes go beyond the Python SDK (which collapses them into its
61
62
  base error) and match the Go SDK's per-variant coverage, so callers can rescue
data/docs/runtime.md CHANGED
@@ -104,6 +104,91 @@ 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`.
118
+
119
+ If the parent's choice cannot be made again in the child (say the child's
120
+ environment now selects the cloud backend without credentials), SDK calls
121
+ there raise the configuration error until that is repaired. Selecting a
122
+ backend in the child — `set_default_backend`, or entering `with_backend` —
123
+ does not depend on the inherited choice and works regardless.
124
+
125
+ ```ruby
126
+ Microsandbox::Sandbox.list # the parent has used the SDK
127
+
128
+ pid = fork do
129
+ Microsandbox::Sandbox.create("job-#{Process.pid}", image: "alpine") do |sb|
130
+ puts sb.exec("echo", ["hello from the worker"]).stdout
131
+ end
132
+ end
133
+ Process.wait(pid)
134
+ ```
135
+
136
+ What does **not** cross a fork is an object created before it. A `Sandbox`,
137
+ `SandboxHandle`, `ExecHandle`, `ExecStdin`, `PullSession`, log/metrics/file
138
+ stream, `SshClient`/`SftpClient`/`SshServer`, `AgentClient` or `VolumeFs`
139
+ 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
142
+ the snapshot fields of a `SandboxHandle` — still works, so a child can open
143
+ its own handle:
144
+
145
+ ```ruby
146
+ sandbox = Microsandbox::Sandbox.create("shared", image: "alpine")
147
+
148
+ pid = fork do
149
+ begin
150
+ sandbox.exec("true") # the parent's handle
151
+ rescue Microsandbox::ForkedHandleError => e
152
+ warn e.message
153
+ end
154
+ mine = Microsandbox::Sandbox.get(sandbox.name).connect_or_start
155
+ puts mine.exec("echo", ["hello"]).stdout
156
+ end
157
+ Process.wait(pid)
158
+ sandbox.stop
159
+ ```
160
+
161
+ Things to know:
162
+
163
+ - **Cleanup code inherited by the child.** The block forms
164
+ (`Sandbox.create(...) { }`, `connect_or_create`) swallow the
165
+ `ForkedHandleError` their teardown hits when a forked child leaves the
166
+ block, so the child does not stop the parent's sandbox. An `ensure
167
+ sandbox.stop` of your own raises it in the child; rescue it there, or run
168
+ the cleanup only in the process that created the sandbox.
169
+ - **Leaving a `with_backend` block in the child.** When the backend to
170
+ restore was the parent's — the child was forked inside the block, or the
171
+ block is the child's first use of the SDK — leaving the block installs a
172
+ backend chosen the way that one had been. The block's own temporary backend
173
+ is not needed for that: if only it can no longer be chosen in the child (a
174
+ cloud profile that is gone there), leaving the block still restores the
175
+ previous backend. If the choice to restore no longer resolves in the child,
176
+ the block is closed anyway and its temporary selection stays in effect.
177
+ `with_backend` then raises the configuration error — unless the block
178
+ itself raised, in which case the block's exception propagates and the
179
+ restore failure is reported as a warning.
180
+ - **A child keeps the parent's sandboxes alive.** A child inherits the
181
+ parent's open file descriptors, among them the pipe through which a sandbox
182
+ notices that its owner is gone. If the parent exits while such a child is
183
+ still running, the sandbox is not torn down until that child exits as well.
184
+ Fork before creating sandboxes, or create them in the children.
185
+ - **Do not fork while another thread is inside an SDK call.** That thread
186
+ does not exist in the child, and a lock it held at that instant stays
187
+ locked there.
188
+ - **Memory.** A child leaks, once, the native state it inherited (the
189
+ parent's runtime, backend and whichever inherited handles it collects)
190
+ instead of destroying it — destroying it is what used to crash.
191
+
107
192
  ## One runtime line per home
108
193
 
109
194
  The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
data/docs/surface.md CHANGED
@@ -57,6 +57,9 @@ 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)).
60
63
 
61
64
  Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
62
65
  `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.3"
12
12
  authors = ["Super Rad Company <development@superrad.company>"]
13
13
  repository = "https://github.com/superradcompany/microsandbox"
14
14
  license = "Apache-2.0"
@@ -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
  }