microsandbox-rb 0.17.1 → 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.
data/docs/releasing.md CHANGED
@@ -7,54 +7,105 @@ or credential setup is needed.
7
7
 
8
8
  ## Each release
9
9
 
10
- 1. Bump the gem's **own** version — `Microsandbox::VERSION` and the matching
11
- `[package] version` in `ext/microsandbox/Cargo.toml` (they must stay equal) —
12
- on its independent semver track (see the
10
+ 1. Bump the gem's **own** version on its independent semver track (see the
13
11
  [Versioning](../README.md#versioning) section of the README); **don't** pick
14
- the number to mirror the upstream tag. If the release also adopts a new
15
- upstream runtime, bump the `tag = "vX.Y.Z"` on **all three** upstream git
16
- deps (`microsandbox`, `microsandbox-network`, `microsandbox-runtime`), update
17
- `Microsandbox::RUNTIME_VERSION` to match, commit the release's
18
- `checksums.sha256` as `binaries/checksums/<tag>.sha256`, and add a row to the
19
- Versioning table. Also bump `Microsandbox::Binaries::VERSION` (and, on a
20
- runtime adoption, `::RUNTIME_VERSION`) in
21
- `binaries/lib/microsandbox/binaries.rb` — the companion gem ships in
22
- lockstep and the specs assert both. Update `CHANGELOG.md`.
23
- 2. Push a `vX.Y.Z` tag. CI builds the **source gem** and pushes it to RubyGems
24
- via `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
12
+ the number to mirror the upstream tag. The version is written in three
13
+ files that must stay equal (`spec/unit/version_spec.rb` asserts it), and
14
+ recorded in the lockfile:
15
+ - `lib/microsandbox/version.rb` — `Microsandbox::VERSION`;
16
+ - `ext/microsandbox/Cargo.toml` — the `[package] version`;
17
+ - `binaries/lib/microsandbox/binaries.rb` — `Microsandbox::Binaries::VERSION`
18
+ (the companion gem ships in lockstep);
19
+ - `Cargo.lock` — committed and packed into the gem, and it records the
20
+ extension crate's version, so a version bump must commit the regenerated
21
+ lock (`bundle exec rake compile` rewrites it).
22
+
23
+ If the release also adopts a new upstream runtime, move the runtime tag
24
+ everywhere it is named:
25
+ - `ext/microsandbox/Cargo.toml` — the `tag = "vX.Y.Z"` on **all three**
26
+ upstream git deps (`microsandbox`, `microsandbox-network`,
27
+ `microsandbox-runtime`);
28
+ - `lib/microsandbox/version.rb` — `Microsandbox::RUNTIME_VERSION`;
29
+ - `binaries/lib/microsandbox/binaries.rb` —
30
+ `Microsandbox::Binaries::RUNTIME_VERSION`;
31
+ - `binaries/checksums/<tag>.sha256` — the release's `checksums.sha256`,
32
+ committed (vendoring fails closed without it);
33
+ - `Cargo.lock` — regenerated again, since it pins the git deps to the new
34
+ tag's commit.
35
+
36
+ Then add a row to the Versioning table in the README and update
37
+ `CHANGELOG.md`.
38
+ 2. Push a `vX.Y.Z` tag. Once the [release preflight](#release-preflight)
39
+ passes, CI pushes the **source gem** to RubyGems via
40
+ `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
25
41
  `RUBYGEMS_API_KEY` secret required — then creates the GitHub Release.
26
42
 
43
+ ## Release preflight
44
+
45
+ `release.yml`'s `preflight` job runs `.github/scripts/release_preflight.sh
46
+ <tag>` and the `publish` job needs it, so nothing reaches RubyGems unless:
47
+
48
+ - the tag is `vX.Y.Z` and the three version constants **in the tagged commit**
49
+ — `Microsandbox::VERSION`, `[package] version` in
50
+ `ext/microsandbox/Cargo.toml`, `Microsandbox::Binaries::VERSION` — all equal
51
+ `X.Y.Z`;
52
+ - the tagged commit is on `main`;
53
+ - the `CI` workflow's push run for that exact commit concluded `success`.
54
+
55
+ Pushing `main` and the tag together is fine: the preflight **waits** for that
56
+ CI run to appear and finish (polling every 30 s, up to 45 min —
57
+ `PREFLIGHT_CI_WAIT_SECONDS` / `PREFLIGHT_CI_POLL_SECONDS`), so a release
58
+ publishes only after CI, including the real-microVM integration lanes, is
59
+ green. A failed or timed-out preflight leaves the tag unpublished. How to
60
+ recover depends on whether the tag has to move:
61
+
62
+ - **The tagged commit is right** (CI flaked, or was still red): re-run the
63
+ `CI` run for that commit until it is green, then re-run the failed `Release`
64
+ run.
65
+ - **The tag must point at a different commit** (wrong version constants, not
66
+ on `main`): delete the tag, fix the commit, and push the tag again — that
67
+ starts a new `Release` run. Never re-run the old run after retagging: a
68
+ re-run keeps the commit it was started for, so it checks the old commit
69
+ again and fails the same way.
70
+
71
+ Dry-run it against an existing tag — nothing is published on a manual
72
+ dispatch — or run the script locally from a full clone:
73
+
74
+ ```bash
75
+ gh workflow run release.yml --ref main -f preflight_ref=vX.Y.Z
76
+ GH_TOKEN=$(gh auth token) .github/scripts/release_preflight.sh vX.Y.Z
77
+ ```
78
+
79
+ It is a workflow-level gate; branch protection and a protected environment for
80
+ the trusted publisher are separate repository settings.
81
+
82
+ CI's `lint` job also runs `cargo deny check advisories` against `Cargo.lock`;
83
+ the exceptions (advisories with no fixed release at the pinned runtime) are in
84
+ `deny.toml` at the repository root — re-check them on every runtime adoption.
85
+
27
86
  ## Precompiled per-platform gems
28
87
 
29
- Precompiled *extension* gems are built best-effort by the `cross-gems` job,
30
- gated to manual `workflow_dispatch` so you can iterate
31
- (`gh workflow run release.yml`) without failing tag releases. They are not
32
- auto-published on tags yet: a gem that *compiles but can't boot a microVM*
33
- would be served to users ahead of the source gem, and CI can't boot a VM to
34
- prove otherwise — so promotion is manual after validating the artifact on each
35
- platform. A precompiled gem ships the compiled extension (with the guest
36
- `agentd` baked in by *target* arch); the host-side `msb` + `libkrunfw` runtime
37
- is **not** in it — that comes from the companion `microsandbox-rb-binaries`
38
- gem, or, when that isn't installed, is fetched into `~/.microsandbox` on first
39
- use by `Microsandbox.ensure_runtime!` (libkrunfw is `dlopen`'d by `msb` at
40
- runtime, never linked into the gem). The real cross-compile work is linking the
41
- *target* native libraries — `libcap-ng` on Linux (handled via Debian multiarch
42
- in the workflow) and the Hypervisor + Security frameworks on macOS (via
43
- osxcross; the one platform left to confirm). Until promoted, users install the
44
- source gem (which compiles via `rb_sys`).
88
+ Precompiled *extension* gems are not built or published. Users install the
89
+ source gem, which compiles the extension via `rb_sys` at install time (so a
90
+ stable Rust toolchain is required on the installing host). The companion
91
+ `microsandbox-rb-binaries` gems below are unaffected: they carry the host
92
+ runtime, not the extension.
45
93
 
46
94
  ## Runtime binaries gems
47
95
 
48
96
  `microsandbox-rb-binaries` (source in `binaries/`) is a *separate* artifact from
49
- the precompiled extension gems above: it carries no Ruby extension, only the
97
+ the SDK gem: it carries no Ruby extension, only the
50
98
  upstream `msb` + `libkrunfw` for one platform, verified against the release's
51
99
  published `checksums.sha256` when vendored. CI's `binaries` job vendors and
52
100
  builds all three (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) on
53
101
  every run and smoke-tests the host one; `release.yml`'s `binaries-gems` job
54
102
  does the same on a tag and a separate `publish-binaries` job pushes them after
55
- the SDK gem is live (a companion failure is its own red job and never blocks
56
- the SDK release). They use their own RubyGems trusted-publisher entry (same
57
- repo + workflow, gem name `microsandbox-rb-binaries`). To build them by hand:
103
+ the SDK gem is live. The SDK `publish` job needs `binaries-gems`, so a
104
+ companion **build** failure blocks the SDK publish as well. A companion
105
+ **push** failure is its own red job once the SDK gem is live: the release is
106
+ then incomplete until `publish-binaries` is re-run. They use their own RubyGems
107
+ trusted-publisher entry (same repo + workflow, gem name
108
+ `microsandbox-rb-binaries`). To build them by hand:
58
109
 
59
110
  ```bash
60
111
  rake -C binaries vendor[arm64-darwin] # download + sha256-verify into binaries/vendor
data/docs/runtime.md CHANGED
@@ -85,7 +85,9 @@ Microsandbox.set_default_backend(:cloud, url: "https://api.example.com", api_key
85
85
  Microsandbox.set_default_backend(:cloud, profile: "prod")
86
86
 
87
87
  # Scoped override (restored afterward, even on error):
88
- Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "alpine") { |sb| ... } }
88
+ Microsandbox.with_backend(:local) do
89
+ Microsandbox::Sandbox.create("box", image: "alpine") { |sb| puts sb.shell("uname -a").stdout }
90
+ end
89
91
  ```
90
92
 
91
93
  Resolution order when no backend is set programmatically: `MSB_BACKEND`
@@ -102,6 +104,91 @@ operations (create/start/stop/remove/get/list, one-shot exec, follow log
102
104
  streaming); unsupported operations raise `Microsandbox::UnsupportedError`
103
105
  (with `#operation` and `#hint`).
104
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
+
105
192
  ## One runtime line per home
106
193
 
107
194
  The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
data/docs/surface.md CHANGED
@@ -23,8 +23,10 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
23
23
  `write_stream`.
24
24
  - **Observability** — per-sandbox `metrics`, `Microsandbox.all_sandbox_metrics`,
25
25
  streaming `metrics_stream`/`log_stream`, `logs`, `ping`/`touch`.
26
- - **Live modification** — `modify` with `dry_run:`, `policy:`, CPU/memory
27
- resize, root-disk growth, env/labels/workdir, secret rotation.
26
+ - **Live modification** — `modify` with `dry_run:` and `policy:`: CPU/memory
27
+ resize (`cpus`/`max_cpus`/`memory`/`max_memory`), root-disk growth
28
+ (`root_disk_size`, MiB), `env`/`remove_env`, `labels`/`remove_labels`,
29
+ `workdir`, and secret rotation (`secrets`/`remove_secrets`).
28
30
  - **OCI image cache** — `Image.get`/`list`/`inspect`/`remove`/`prune`, image
29
31
  archives (`Image.load`/`Image.save`), streaming pull progress
30
32
  (`Sandbox.create_with_progress` → `PullSession`).
@@ -35,14 +37,16 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
35
37
  - **Rootfs patches** — `Microsandbox::Patch` (text/file/append/copy_file/
36
38
  copy_dir/symlink/mkdir/remove) via `create(patches:)`.
37
39
  - **Root disk** — structured `root_disk:` (managed / tmpfs / disk / flat via
38
- `Microsandbox::RootDisk`), `follow_root_symlinks:`.
40
+ `Microsandbox::RootDisk`), `follow_root_symlinks:`; `oci_upper_size:` is the
41
+ deprecated alias for a managed `root_disk:` size.
39
42
  - **Network configuration** — composable profiles, custom per-rule
40
- `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, DNS, TLS interception,
41
- IPv4/IPv6 pools, `max_connections`, `trust_host_cas`, `strict` hostname mode,
43
+ `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, custom DNS (`dns:`),
44
+ TLS-interception tuning (`tls:`), guest address pools (`ipv4_pool:`/
45
+ `ipv6_pool:`), `max_connections`, `trust_host_cas`, `strict` hostname mode,
42
46
  outbound SOCKS4/SOCKS5 `proxy:` (`OutboundProxy`/`SecretSource`),
43
47
  `rate_limiter:`, `vsock:`.
44
48
  - **Secrets** — multi-host / wildcard allow-lists, injection toggles,
45
- per-secret and sandbox-level violation policy.
49
+ per-secret `on_violation:` and sandbox-level `on_secret_violation:` policy.
46
50
  - **SSH** — `Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`, with
47
51
  `inactivity_timeout:`.
48
52
  - **Raw agent client** — `Microsandbox::AgentClient` → `AgentStream`/
@@ -53,11 +57,15 @@ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
53
57
  - **Registries** — `registry_auth`/`registry_insecure`/`registry_ca_certs` on
54
58
  `create`.
55
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)).
56
63
 
57
64
  Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
58
- `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, `init`/`ephemeral`,
65
+ `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, named `scripts`, the image
66
+ `entrypoint`/`cmd` overrides, `init`/`ephemeral`,
59
67
  disk-image `fstype`, `ports`/`ports_udp`, network policy + config,
60
- `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
68
+ `log_level`/`quiet_logs`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
61
69
  (bind/named/tmpfs/disk with mount policies, `uid`/`gid`, `quota_mib`),
62
70
  `idle_timeout`/`max_duration`, `from_snapshot`, `detached`, and
63
71
  `replace`/`replace_with_timeout`.
@@ -6,8 +6,9 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
- # The core-crate dependency below stays pinned at its own tag (v0.6.16).
10
- version = "0.17.1"
9
+ # The core-crate dependencies below stay on their own tag, which is
10
+ # Microsandbox::RUNTIME_VERSION and moves independently of this version.
11
+ version = "0.17.3"
11
12
  authors = ["Super Rad Company <development@superrad.company>"]
12
13
  repository = "https://github.com/superradcompany/microsandbox"
13
14
  license = "Apache-2.0"
@@ -22,9 +23,12 @@ name = "microsandbox_rb"
22
23
  crate-type = ["cdylib"]
23
24
 
24
25
  [dependencies]
25
- magnus = "0.8"
26
- # Low-level Ruby C API — used directly for rb_thread_call_without_gvl (GVL release
27
- # during blocking sandbox calls). Kept in lock-step with magnus's own rb-sys.
26
+ # "rb-sys" exposes `magnus::rb_sys::protect` (rb_protect), which the GVL bridge
27
+ # uses to check interrupts without letting Ruby longjmp over Rust frames.
28
+ magnus = { version = "0.8", features = ["rb-sys"] }
29
+ # Low-level Ruby C API — used directly for rb_thread_call_without_gvl2 (GVL
30
+ # release during blocking sandbox calls) and rb_thread_check_ints. Kept in
31
+ # lock-step with magnus's own rb-sys.
28
32
  rb-sys = "0.9"
29
33
 
30
34
  # The microsandbox core runtime, wrapped via FFI exactly like the official
@@ -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
 
@@ -41,8 +41,8 @@ impl AgentClient {
41
41
  /// Connect to a running sandbox by name. `timeout` is optional seconds.
42
42
  fn connect_sandbox(name: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
43
43
  let bridge = match dur(timeout)? {
44
- Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t)),
45
- None => block_on(AgentBridge::connect_sandbox(&name)),
44
+ Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t))?,
45
+ None => block_on(AgentBridge::connect_sandbox(&name))?,
46
46
  }
47
47
  .map_err(to_ruby_agent)?;
48
48
  Ok(AgentClient::from_bridge(bridge))
@@ -51,8 +51,8 @@ impl AgentClient {
51
51
  /// Connect to an agentd relay socket by path. `timeout` is optional seconds.
52
52
  fn connect_path(path: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
53
53
  let bridge = match dur(timeout)? {
54
- Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t)),
55
- None => block_on(AgentBridge::connect_path(&path)),
54
+ Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t))?,
55
+ None => block_on(AgentBridge::connect_path(&path))?,
56
56
  }
57
57
  .map_err(to_ruby_agent)?;
58
58
  Ok(AgentClient::from_bridge(bridge))
@@ -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,18 +74,18 @@ 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
- block_on(async move { inner.request(flags, body).await }).map_err(to_ruby_agent)?;
79
+ block_on(async move { inner.request(flags, body).await })?.map_err(to_ruby_agent)?;
77
80
  Ok(frame_to_hash(frame))
78
81
  }
79
82
 
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);
84
- let (id, handle) =
85
- block_on(async move { inner.stream_open(flags, body).await }).map_err(to_ruby_agent)?;
86
+ let inner = Arc::clone(self.inner.get()?);
87
+ let (id, handle) = block_on(async move { inner.stream_open(flags, body).await })?
88
+ .map_err(to_ruby_agent)?;
86
89
  let hash = ruby().hash_new();
87
90
  hash.aset("id", id)?;
88
91
  hash.aset("handle", handle)?;
@@ -91,36 +94,36 @@ 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
- block_on(async move { inner.stream_next(handle).await }).map_err(to_ruby_agent)?;
99
+ block_on(async move { inner.stream_next(handle).await })?.map_err(to_ruby_agent)?;
97
100
  Ok(frame.map(frame_to_hash))
98
101
  }
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);
103
- block_on(async move { inner.stream_close(handle).await });
105
+ let inner = Arc::clone(self.inner.get()?);
106
+ block_on(async move { inner.stream_close(handle).await })?;
104
107
  Ok(())
105
108
  }
106
109
 
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);
111
- block_on(async move { inner.send(id, flags, body).await }).map_err(to_ruby_agent)
113
+ let inner = Arc::clone(self.inner.get()?);
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);
123
- block_on(async move { inner.close().await });
125
+ let inner = Arc::clone(self.inner.get()?);
126
+ block_on(async move { inner.close().await })?;
124
127
  Ok(())
125
128
  }
126
129
  }
@@ -157,6 +160,8 @@ fn frame_to_hash(frame: BridgeFrame) -> RHash {
157
160
 
158
161
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
159
162
  let class = native.define_class("AgentClient", ruby.class_object())?;
163
+ // Resolve the wrapper class now, not on first use (see `preload_class`).
164
+ crate::runtime::preload_class::<AgentClient>(ruby);
160
165
 
161
166
  class.define_singleton_method(
162
167
  "connect_sandbox",