microsandbox-rb 0.8.1 → 0.8.2

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: f8cc1edaf4e221fe6e4addf4eea0d06e63370dd73bbcb1a7eb4d13e91bac7b40
4
- data.tar.gz: 17f18d5aefead5fbb584a7b06ecd82d0aade2394c3e9c48aafd0f5d98673abfd
3
+ metadata.gz: 9fa6d9703f579a81424b9a32d4ab7c066eb5a48c1f8f7407ae2f6ca98770fd85
4
+ data.tar.gz: 6f83fea6ecf71906786bbb2416d6ee3ad52a588077024a5da0eed71eb49430ef
5
5
  SHA512:
6
- metadata.gz: e7c98769be2508875e54b1449d290c18b63af3189af497d311c6222073b6018290950faa4438c165f656928d175e6f6a50ec0f7af6323a8174dbf0e460b99042
7
- data.tar.gz: 7a8edef36f6f7e3c12a5075e684d64ca71570350dd678ebcd3b819a9ebfab309007e2b71e348f1c0925ab8e3beb474787aeec741935f5e803676f5ce00daf513
6
+ metadata.gz: e18a042c506b1d501f072816c6bdd20f429c52a18410112cb82fb9fc97981d2ce38f53dc02b4024627c64a7dc6b643060c0d67c0dd073500b91a83a3d7c6319b
7
+ data.tar.gz: b7b473d7e9d5fc5321abd6f9e2348ed9f0e75d1a924e64fa2e19580eb78e40230cddbdc310132ffd9248c984e6684293868956abb365a72ca3dcf4d3b828f384
data/CHANGELOG.md CHANGED
@@ -6,7 +6,132 @@ All notable changes to this gem are documented here. The format is based on
6
6
  microsandbox runtime it embeds; each release notes the upstream runtime tag it
7
7
  wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
- ## [Unreleased]
9
+ ## [0.8.2] - 2026-06-29
10
+
11
+ Gem-only release on the `v0.5.10` runtime (unchanged). Bundles the post-`0.8.1`
12
+ audit follow-ups: a secret-leak fix, typed snapshot errors, panic-free duration
13
+ parsing, the precompiled fat-gem loader + `extconf` preflight corrections, and a
14
+ sweep of threading/streaming/SSH documentation.
15
+
16
+ ### Documentation
17
+
18
+ - **Calling-thread non-preemption is now documented** (issue #24). The GVL is
19
+ released during native calls so *other* threads keep running, but the
20
+ *calling* thread blocks uninterruptibly until the call returns —
21
+ `Timeout::timeout`/`Thread#kill`/Ctrl-C can't interrupt it. README and
22
+ DESIGN.md now state this and steer callers to the genuinely-bounding
23
+ `exec(timeout:)` / `shell(timeout:)` knobs, and clarify that
24
+ `AgentClient.connect_sandbox`/`connect_path`'s `timeout:` bounds only the
25
+ connect handshake while `AgentClient#request`/`#stream` and the streaming
26
+ paths have no timeout knob and can block indefinitely — rather than reaching
27
+ for `Timeout::timeout`.
28
+ - **`exec` `timeout: 0` semantics clarified** (issue #29). The `@param timeout`
29
+ doc now notes the asymmetry: omit or `nil` means *no* timeout, while `0` is an
30
+ immediate (zero) deadline that kills the command before any output and raises
31
+ `ExecTimeoutError` — so use `nil`/omit, never `0`, for "no limit". Also noted
32
+ that `exec_stream`/`shell_stream` accept `timeout:` but do **not** apply it
33
+ (the streaming path discards it).
34
+ - **Streaming classes documented as single-pass / single-consumer** (issues #34,
35
+ #31). `ExecHandle`, `LogStream`, `MetricsStream`, `FsReadStream`,
36
+ `PullSession`, and `AgentStream` are `Enumerable` but drain a one-shot native
37
+ channel: forward-only, not rewindable, and meant for one consumer on one
38
+ thread. A second `each` (or a combinator after a partial drain) silently
39
+ yields nothing. Noted on each class and in a README streaming caveat.
40
+ - **SSH `close` disconnect behavior documented** (issue #33). `SshClient#close` /
41
+ `SftpClient#close` send the graceful protocol disconnect; relying on GC skips
42
+ it (only the in-process server task is aborted). The block-less
43
+ `open_client`/`sftp` docs now tell callers to `close` (or use the block form)
44
+ for a clean disconnect.
45
+
46
+ ### Internal
47
+
48
+ - **`DESIGN.md` refreshed** (issue #35). The stale runtime-pin references
49
+ (`v0.5.7`/`v0.5.8`) now point at `v0.5.10` via `RUNTIME_VERSION`, and the
50
+ hard-coded unit-example count is replaced with rot-proof phrasing.
51
+ - **RBS gains a note on SDK-constructed types** (issue #36). `sig/microsandbox.rbs`
52
+ now carries a top-of-file note explaining that native-backed value/handle/stream
53
+ types are constructed by the SDK from an internal native handle or data hash, not
54
+ by user code. Their `initialize` signatures are kept: RBS derives `new` from
55
+ `initialize`, so omitting them would not hide the constructor — it would
56
+ synthesize a misleading zero-arg `() -> instance` that Ruby actually rejects.
57
+
58
+ ### Fixed
59
+
60
+ - **Precompiled fat-gem loader now finds the staged binary** (issue #25). The
61
+ native-extension require used `RbConfig::CONFIG["ruby_version"]` — the API
62
+ string `"3.4.0"` — but rake-compiler stages a multi-version fat gem's binaries
63
+ under the **major.minor** subdir (`3.4`), so the versioned require always
64
+ missed and fell to the flat-path rescue, which is **absent** in a precompiled
65
+ gem (only the versioned binary is packed). Every fat-gem install would have
66
+ failed at `require "microsandbox"` the moment precompiled gems are promoted.
67
+ The loader now derives the subdir from `RUBY_VERSION[/\d+\.\d+/]` (`"3.4"`),
68
+ matching the staged path; the flat-path rescue still covers source builds.
69
+ - **`extconf` MSRV preflight probes the compiler the build actually runs**
70
+ (issue #39). The preflight ran a bare `rustc --version` and hard-aborted when
71
+ `< 1.91`, but the build is driven by `cargo`, which resolves its compiler from
72
+ `$RUSTC` if set, otherwise the bare `rustc` on PATH — it never uses the `rustc`
73
+ beside the `cargo` binary, and the rustup `cargo` shim neither sets `$RUSTC` nor
74
+ reorders PATH. The preflight now mirrors that exact resolution (`$RUSTC`, else
75
+ PATH `rustc`), so it neither false-passes when a stale non-rustup `rustc`
76
+ shadows a rustup `cargo` (the build would compile with that stale `rustc` and
77
+ fail deep in smoltcp) nor false-aborts when `$RUSTC` points at a newer compiler.
78
+
79
+ ### Internal
80
+
81
+ - **CI now installs the packed gem from source** (issue #37). A new `package`
82
+ job runs `rake build`, `gem install`s the packed gem (exercising the gemspec
83
+ `spec.files` glob and the full from-gem `extconf` + `cargo` compile against the
84
+ packed `Cargo.toml`/`Cargo.lock`/`rust-toolchain.toml`), and requires it from
85
+ outside the repo. Previously every job compiled the working tree in place, so
86
+ a packaging regression could reach RubyGems undetected.
87
+ - **`version_spec` now guards the `Cargo.lock` version** (issue #38). The spec
88
+ already asserted `Native.version == VERSION` and the runtime-tag pin, but
89
+ nothing checked the `microsandbox_rb` version in the committed `Cargo.lock`,
90
+ which the gemspec packs. A release that bumped `version.rb` + `Cargo.toml` but
91
+ forgot to refresh the lock would ship a stale lock (and a `--locked` build
92
+ would reject it) — a recurring release mistake this now catches.
93
+ ### Security
94
+
95
+ - **Secret values no longer leak into `ArgumentError` messages** (issue #23).
96
+ `Sandbox.create(secrets:)` validation interpolated the whole secret spec via
97
+ `spec.inspect` into two error messages — and because the `:value`-present
98
+ guard runs first, the "needs `:host`/`:hosts`/`:host_patterns`" error *always*
99
+ embedded the cleartext secret value (and the env/value error did whenever a
100
+ value was supplied). Such messages routinely reach logs and error trackers.
101
+ Both messages now report the spec's keys only (`spec.keys.inspect`), mirroring
102
+ the existing `registry_auth` handling, with a unit spec asserting the value
103
+ is never present in the raised message.
104
+
105
+ ### Fixed
106
+
107
+ - **Native duration parsing is panic-free regardless of the Ruby layer**
108
+ (issue #30). The native binding called `Duration::from_secs_f64` directly at
109
+ five sites (`exec`/`shell` timeout, `stop_with_timeout`, `kill_with_timeout`,
110
+ `metrics_stream` interval, `replace_with_timeout`); that panics on NaN/Inf/
111
+ negative *and on finite-but-out-of-range* values (e.g. `Float::MAX`), which
112
+ surfaced as an ugly panic-turned-exception. The Ruby `coerce_duration` guard
113
+ set no upper bound, so a large finite value still reached and panicked the
114
+ native layer. All five sites now route through a `secs_to_duration` helper
115
+ (`try_from_secs_f64` + a clean `Microsandbox::Error`), matching the existing
116
+ agent-client pattern — defense in depth so the native layer is panic-free on
117
+ its own.
118
+ ### Added
119
+
120
+ - **Typed snapshot error classes** (issue #28). The five core snapshot error
121
+ variants — reachable through the gem's fully-wired `Snapshot` API — previously
122
+ collapsed to the base `Microsandbox::Error`, forcing callers to string-match
123
+ the message. They now raise typed subclasses:
124
+ `SnapshotNotFoundError` (`snapshot-not-found`),
125
+ `SnapshotAlreadyExistsError` (`snapshot-already-exists`),
126
+ `SnapshotSandboxRunningError` (`snapshot-sandbox-running`),
127
+ `SnapshotImageMissingError` (`snapshot-image-missing`), and
128
+ `SnapshotIntegrityError` (`snapshot-integrity`). This goes **beyond** the
129
+ Python SDK mirror (which defines no snapshot classes), matching the Go SDK's
130
+ per-variant coverage — a deliberate divergence. Additionally, the previously
131
+ orphaned `NetworkPolicyError` now also carries the core's `NetworkBuilder`
132
+ build/validation error (a `network(|n| ...)` failure), which previously fell
133
+ through to the base `Error`. All additive — existing `rescue Microsandbox::Error`
134
+ handlers still catch them.
10
135
 
11
136
  ## [0.8.1] - 2026-06-25
12
137
 
data/Cargo.lock CHANGED
@@ -3249,7 +3249,7 @@ dependencies = [
3249
3249
 
3250
3250
  [[package]]
3251
3251
  name = "microsandbox_rb"
3252
- version = "0.8.1"
3252
+ version = "0.8.2"
3253
3253
  dependencies = [
3254
3254
  "chrono",
3255
3255
  "futures",
data/DESIGN.md CHANGED
@@ -58,6 +58,23 @@ only (never touches the Ruby C API) and uses `catch_unwind` so a Rust panic is
58
58
  captured and re-raised *after* the GVL is re-acquired rather than unwinding
59
59
  across the C frame (which would be UB).
60
60
 
61
+ **The *calling* thread is not preemptible during the call.** `nogvl` passes a
62
+ null unblock-function to `rb_thread_call_without_gvl`, and Ruby only checks
63
+ pending interrupts *before* and *after* the GVL-released region — so while a
64
+ native call blocks, the thread that issued it cannot be interrupted:
65
+ `Timeout::timeout` (which relies on `Thread#raise`), `Thread#kill`, and `SIGINT`
66
+ (Ctrl-C) are all deferred until the call returns on its own. The GVL release
67
+ keeps *other* threads live; it does **not** make the calling thread
68
+ cancelable. This is harmless for the bounded calls (`exec`/`shell` with a
69
+ `timeout:`, `AgentClient` with a `timeout:`) because the deadline fires inside
70
+ the future, but the unbounded/streaming paths — `exec`/`shell` with
71
+ `timeout: nil`, `ExecHandle#recv`/`#wait`, `log_stream`/`metrics_stream` `recv`
72
+ (especially `follow: true`), `Sandbox#wait`/`SandboxHandle#wait_until_stopped`,
73
+ and `AgentClient#request` with no timeout — can block their caller indefinitely
74
+ if the guest wedges or a relay drops. **Bound such calls with the explicit
75
+ `timeout:` knobs rather than wrapping them in `Timeout::timeout`**, which will
76
+ not fire while the native call is in flight.
77
+
61
78
  ## Error mapping
62
79
 
63
80
  The core returns one big `MicrosandboxError` enum. `error.rs` maps each variant
@@ -88,7 +105,8 @@ out (air-gapped hosts that provision out of band). libkrunfw is `dlopen`'d by
88
105
  ## Core-crate dependency (self-contained)
89
106
 
90
107
  `ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
91
- (`microsandbox` / `microsandbox-network` at `v0.5.7`), so the gem builds anywhere
108
+ (`microsandbox` / `microsandbox-network`, pinned to the same tag as
109
+ `Microsandbox::RUNTIME_VERSION` — currently `v0.5.10`), so the gem builds anywhere
92
110
  — CI, `rake-compiler-dock` release containers, and end-user source installs —
93
111
  without an adjacent checkout. For fast local development against a sibling
94
112
  microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
@@ -166,8 +184,8 @@ Create options now cover `image`, `cpus`, `memory`, `oci_upper_size`, `env`,
166
184
 
167
185
  The binding is verified at four levels:
168
186
 
169
- 1. **Unit** (192 examples) — the Ruby layer's option normalization and value
170
- objects, with the native layer stubbed.
187
+ 1. **Unit** (several hundred examples) — the Ruby layer's option normalization
188
+ and value objects, with the native layer stubbed.
171
189
  2. **Real-microVM integration** (`spec/integration`, opt-in via
172
190
  `MICROSANDBOX_INTEGRATION=1`) — boots actual sandboxes and round-trips
173
191
  `exec`/`shell`/`fs`/`metrics`/`logs`/streaming/snapshots. Run locally on
@@ -193,7 +211,7 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
193
211
  host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
194
212
 
195
213
  A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
196
- upstream-gated — they exist at the pinned `v0.5.8`): per-published-port host
214
+ upstream-gated — they exist at the pinned `v0.5.10` runtime): per-published-port host
197
215
  **bind address** (ports always bind loopback), network **interface overrides**,
198
216
  and inline **named-volume create-mode** (pre-create with `Volume.create`, then
199
217
  mount with `{ named: }`). These slot in module-by-module exactly as the existing
data/README.md CHANGED
@@ -43,7 +43,7 @@ them. Our deepest thanks to the maintainers and community. 🙏
43
43
  - **SSH & SFTP** — native in-process SSH client/server and file transfer (`Sandbox#ssh`)
44
44
  - **Raw agent client** — byte-level access to the guest `agentd` protocol (`Microsandbox::AgentClient`)
45
45
  - **Idiomatic Ruby** — keyword arguments, block-scoped lifecycle, a typed error hierarchy
46
- - **Thread-friendly** — the GVL is released during sandbox calls, so other Ruby threads keep running
46
+ - **Thread-friendly** — the GVL is released during sandbox calls, so _other_ Ruby threads keep running. The _calling_ thread blocks uninterruptibly until the call returns (`Timeout::timeout`/`Thread#kill`/Ctrl-C can't interrupt a blocked native call), so bound long-running work with a real deadline where one exists: `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds, and `AgentClient.connect_sandbox`/`connect_path` take a `timeout:` that bounds only the connect handshake. The streaming paths and `AgentClient#request`/`#stream` have no timeout knob and can block indefinitely if the guest wedges — see DESIGN.md
47
47
 
48
48
  ## Requirements
49
49
 
@@ -244,6 +244,13 @@ Microsandbox::Sandbox.create("stream", image: "public.ecr.aws/docker/library/pyt
244
244
  end
245
245
  ```
246
246
 
247
+ > **Streams are single-pass.** `ExecHandle`, `LogStream`, `MetricsStream`,
248
+ > `FsReadStream`, `PullSession`, and `AgentStream` are `Enumerable`, but `each`
249
+ > drains a one-shot native channel — they are forward-only, not rewindable, and
250
+ > meant for a single consumer. Iterate (or `collect`/`read`) exactly once: a
251
+ > second `each`, or a combinator after a partial drain (`count` then `each`,
252
+ > `to_a` twice), silently yields nothing. Don't share one handle across threads.
253
+
247
254
  ### Images
248
255
 
249
256
  Manage the local OCI image cache (images are pulled automatically on `create`):
@@ -399,6 +406,7 @@ Microsandbox.runtime_version # => "v0.5.10" (the embedded upstream runtime ta
399
406
  | `0.7.0` | `v0.5.8` | SDK parity release (large binding-gap closure) |
400
407
  | `0.8.0` | `v0.5.10` | adopts upstream `v0.5.10` (idle-only heartbeat, config-fd hardening, **4 GiB default bind-mount quota**); supersedes the reverted `v0.5.9` attempt |
401
408
  | `0.8.1` | `v0.5.10` | gem-only: re-provision a stale local runtime; per-bind-mount `quota_mib:` override |
409
+ | `0.8.2` | `v0.5.10` | gem-only: redact secrets from errors, typed snapshot errors, panic-free durations, fat-gem loader + `extconf` preflight fixes, threading/streaming docs |
402
410
 
403
411
  **Going forward** — the gem version moves on its own semver track and no longer
404
412
  mirrors the upstream tag:
@@ -7,7 +7,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
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
9
  # The core-crate dependency below stays pinned at its own tag (v0.5.10).
10
- version = "0.8.1"
10
+ version = "0.8.2"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -2,31 +2,46 @@
2
2
 
3
3
  require "mkmf"
4
4
  require "rb_sys/mkmf"
5
+ require "shellwords"
5
6
 
6
7
  # Preflight: the embedded microsandbox core is edition 2024 and pulls smoltcp,
7
- # which sets a Minimum Supported Rust Version of 1.91. A `cargo` from an older
8
- # toolchain (commonly Homebrew's rustc, which shadows a newer rustup on PATH and
9
- # ignores this gem's rust-toolchain.toml) fails deep in the build with a cryptic
10
- # "rustc X is not supported by smoltcp" error. Detect it up front and explain
11
- # the fix instead.
8
+ # which sets a Minimum Supported Rust Version of 1.91. An older rustc (commonly
9
+ # Homebrew's, which shadows a newer rustup on PATH and ignores this gem's
10
+ # rust-toolchain.toml) fails deep in the build with a cryptic "rustc X is not
11
+ # supported by smoltcp" error. Detect it up front and explain the fix instead.
12
+ #
13
+ # Probe the *same* rustc the build will invoke. create_rust_makefile drives the
14
+ # build through `cargo`, and cargo resolves its compiler exactly as: the `RUSTC`
15
+ # env var if set, otherwise the bare `rustc` found on PATH. It does NOT use the
16
+ # `rustc` sitting beside the `cargo` binary, and the rustup `cargo` shim neither
17
+ # sets `RUSTC` nor prepends its toolchain bin to PATH — toolchain selection
18
+ # survives only because the PATH `rustc` is normally itself a rustup shim that
19
+ # honors rust-toolchain.toml. So a non-rustup rustc earlier on PATH (which reads
20
+ # neither RUSTUP_TOOLCHAIN nor the toolchain file) is what the build actually
21
+ # runs. Mirroring cargo's RUSTC-then-PATH resolution is the only probe that
22
+ # neither false-passes (the trap of checking the cargo sibling, which stays a
23
+ # valid rustup shim while the build silently uses the stale PATH rustc) nor
24
+ # false-aborts (when `RUSTC` points at a newer compiler than the PATH `rustc`).
12
25
  MSRV = Gem::Version.new("1.91")
26
+
27
+ def build_rustc
28
+ rustc = ENV["RUSTC"].to_s.strip
29
+ rustc.empty? ? "rustc" : rustc
30
+ end
31
+
32
+ rustc = build_rustc
13
33
  rustc_version = begin
14
- out = `rustc --version 2>/dev/null`
34
+ out = `#{rustc.shellescape} --version 2>/dev/null`
15
35
  out[/\d+\.\d+(\.\d+)?/] && Gem::Version.new(out[/\d+\.\d+(\.\d+)?/])
16
36
  rescue
17
37
  nil
18
38
  end
19
39
 
20
40
  if rustc_version && rustc_version < MSRV
21
- which_rustc = begin
22
- `which rustc 2>/dev/null`.strip
23
- rescue
24
- ""
25
- end
26
41
  abort(<<~MSG)
27
42
 
28
43
  [microsandbox-rb] Rust #{rustc_version} is too old — the embedded core requires rustc >= #{MSRV}.
29
- Found: #{which_rustc.empty? ? "rustc" : which_rustc} (#{rustc_version})
44
+ Found: #{rustc} (#{rustc_version})
30
45
 
31
46
  This usually means an older rustc (e.g. Homebrew's) is ahead of a newer
32
47
  rustup toolchain on your PATH. Fixes:
@@ -37,8 +52,12 @@ if rustc_version && rustc_version < MSRV
37
52
  rustup install stable && rustup default stable
38
53
  • Or upgrade your system Rust to >= #{MSRV}.
39
54
 
40
- (This gem ships a rust-toolchain.toml pinning `stable`; the rustup `cargo`
41
- shim honors it, but a non-rustup `cargo` does not.)
55
+ Or point `RUSTC` at a recent compiler (cargo honors it over PATH):
56
+ RUSTC="$HOME/.cargo/bin/rustc" gem install microsandbox-rb
57
+
58
+ (This gem ships a rust-toolchain.toml pinning `stable`, but only a rustup
59
+ `rustc` shim reads it — a non-rustup `rustc` ahead on PATH ignores it, and
60
+ cargo invokes that PATH `rustc` unless `RUSTC` says otherwise.)
42
61
  MSG
43
62
  end
44
63
 
@@ -34,6 +34,22 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
34
34
  // client's `UnsupportedOperation` above.
35
35
  CloudHttp { .. } => "CloudHttpError",
36
36
  Unsupported { .. } => "UnsupportedError",
37
+ // Snapshot operations, all reachable through the gem's fully-wired
38
+ // `Snapshot` API. Upstream raises these un-wrapped, so without a mapping
39
+ // they collapse to the base `Error` and callers must string-match the
40
+ // message. This goes BEYOND the Python mirror (which has no Snapshot
41
+ // classes and matches the Go SDK's per-variant coverage instead) — a
42
+ // deliberate divergence noted in `lib/microsandbox/errors.rb`.
43
+ SnapshotNotFound(_) => "SnapshotNotFoundError",
44
+ SnapshotAlreadyExists(_) => "SnapshotAlreadyExistsError",
45
+ SnapshotSandboxRunning(_) => "SnapshotSandboxRunningError",
46
+ SnapshotImageMissing(_) => "SnapshotImageMissingError",
47
+ SnapshotIntegrity(_) => "SnapshotIntegrityError",
48
+ // Give the already-defined-but-orphaned `NetworkPolicyError` a mapping:
49
+ // a builder parse/validation error from `network(|n| ...)`. The gem
50
+ // unconditionally enables the core's `net` feature (default-features),
51
+ // so this variant is always present.
52
+ NetworkBuilder(_) => "NetworkPolicyError",
37
53
  _ => "Error",
38
54
  }
39
55
  }
@@ -40,6 +40,21 @@ use crate::exec::ExecHandle;
40
40
  use crate::runtime::{block_on, ruby};
41
41
  use crate::stream::{LogStream, MetricsStream};
42
42
 
43
+ /// Convert a seconds `f64` into a `Duration`, surfacing a clean Ruby error
44
+ /// instead of the panic `Duration::from_secs_f64` raises on NaN/Inf/negative —
45
+ /// *and on finite-but-out-of-range* values (e.g. `Float::MAX`). The Ruby
46
+ /// `coerce_duration` already guards the public paths, but it sets no upper
47
+ /// bound, so a large finite value would still reach (and panic) the native
48
+ /// layer; this keeps the native layer panic-free regardless of the Ruby layer,
49
+ /// mirroring `agent::dur`.
50
+ fn secs_to_duration(secs: f64) -> Result<Duration, Error> {
51
+ Duration::try_from_secs_f64(secs).map_err(|e| {
52
+ error::base_error(format!(
53
+ "duration must be a non-negative, finite number of seconds in range (got {secs}: {e})"
54
+ ))
55
+ })
56
+ }
57
+
43
58
  #[magnus::wrap(class = "Microsandbox::Native::Sandbox", free_immediately, size)]
44
59
  pub struct Sandbox {
45
60
  inner: microsandbox::Sandbox,
@@ -380,7 +395,7 @@ impl Sandbox {
380
395
  b = b.detached(true);
381
396
  }
382
397
  if let Some(secs) = conv::opt_f64(opts, "replace_with_timeout")? {
383
- b = b.replace_with_timeout(Duration::from_secs_f64(secs));
398
+ b = b.replace_with_timeout(secs_to_duration(secs)?);
384
399
  } else if conv::opt_bool(opts, "replace")? {
385
400
  b = b.replace();
386
401
  }
@@ -579,14 +594,22 @@ impl Sandbox {
579
594
 
580
595
  /// Stream metrics snapshots at `interval` seconds. Returns a MetricsStream
581
596
  /// to pull snapshots from.
582
- fn metrics_stream(&self, interval: f64) -> MetricsStream {
583
- let dur = Duration::from_secs_f64(if interval <= 0.0 { 1.0 } else { interval });
597
+ fn metrics_stream(&self, interval: f64) -> Result<MetricsStream, Error> {
598
+ // `interval <= 0.0` (0 or negative) keeps the prior 1s-default behavior;
599
+ // NaN (for which `<= 0.0` is false) and finite-but-out-of-range values
600
+ // fall through to `secs_to_duration`, which errors cleanly rather than
601
+ // letting `from_secs_f64` panic across the FFI boundary.
602
+ let dur = if interval <= 0.0 {
603
+ Duration::from_secs(1)
604
+ } else {
605
+ secs_to_duration(interval)?
606
+ };
584
607
  // `metrics_stream` is synchronous but builds a `tokio::time::interval`,
585
608
  // which panics ("no reactor running") unless constructed inside the
586
609
  // runtime context — so build it under `block_on`. (`log_stream` is async
587
610
  // and already runs inside `block_on`, so it needs no such wrapper.)
588
611
  let stream = block_on(async { self.inner.metrics_stream(dur) });
589
- MetricsStream::from_stream(stream)
612
+ Ok(MetricsStream::from_stream(stream))
590
613
  }
591
614
 
592
615
  /// Stream captured logs as they appear. `opts`: sources, since_ms,
@@ -1544,7 +1567,9 @@ impl ExecOpts {
1544
1567
  cwd: conv::opt_string(opts, "cwd")?,
1545
1568
  user: conv::opt_string(opts, "user")?,
1546
1569
  env: conv::opt_string_map(opts, "env")?,
1547
- timeout: conv::opt_f64(opts, "timeout")?.map(Duration::from_secs_f64),
1570
+ timeout: conv::opt_f64(opts, "timeout")?
1571
+ .map(secs_to_duration)
1572
+ .transpose()?,
1548
1573
  tty: conv::opt_bool(opts, "tty")?,
1549
1574
  stdin,
1550
1575
  stdin_pipe: conv::opt_bool(opts, "stdin_pipe")?,
@@ -1907,8 +1932,7 @@ impl SbHandle {
1907
1932
 
1908
1933
  /// Graceful stop with a custom escalation timeout (seconds).
1909
1934
  fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
1910
- block_on(self.inner.stop_with_timeout(Duration::from_secs_f64(secs)))
1911
- .map_err(error::to_ruby)
1935
+ block_on(self.inner.stop_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
1912
1936
  }
1913
1937
 
1914
1938
  /// Force kill (SIGKILL) and wait.
@@ -1918,8 +1942,7 @@ impl SbHandle {
1918
1942
 
1919
1943
  /// Force kill, waiting up to `secs` for the process to disappear.
1920
1944
  fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
1921
- block_on(self.inner.kill_with_timeout(Duration::from_secs_f64(secs)))
1922
- .map_err(error::to_ruby)
1945
+ block_on(self.inner.kill_with_timeout(secs_to_duration(secs)?)).map_err(error::to_ruby)
1923
1946
  }
1924
1947
 
1925
1948
  /// Send the graceful-shutdown request and return without waiting.
@@ -35,6 +35,10 @@ module Microsandbox
35
35
  # consume {AgentFrame}s until the stream ends (the terminal frame is delivered,
36
36
  # then iteration stops). Mirrors the official SDKs' `AgentStream`.
37
37
  #
38
+ # @note **Single-pass, forward-only, single-consumer.** `each` drains a
39
+ # one-shot native channel — not rewindable, iterate once from a single
40
+ # thread; a second pass or a post-drain combinator yields nothing.
41
+ #
38
42
  # @example
39
43
  # stream = client.stream(0, request_body)
40
44
  # stream.each { |frame| handle(frame) }
@@ -51,7 +51,22 @@ module Microsandbox
51
51
  define_error(:ImageInUseError, "image-in-use")
52
52
  define_error(:ImagePullFailedError, "image-pull-failed")
53
53
 
54
+ # Snapshot errors ---------------------------------------------------------
55
+ # These go BEYOND the Python SDK mirror (which has no Snapshot classes and
56
+ # collapses all five to its base error) — a deliberate divergence matching the
57
+ # Go SDK's per-variant coverage, so callers can rescue a missing/duplicate/
58
+ # running-source/missing-image/corrupt snapshot specifically. The native layer
59
+ # (ext/microsandbox/src/error.rs) maps the five core Snapshot* variants here.
60
+ define_error(:SnapshotNotFoundError, "snapshot-not-found")
61
+ define_error(:SnapshotAlreadyExistsError, "snapshot-already-exists")
62
+ define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running")
63
+ define_error(:SnapshotImageMissingError, "snapshot-image-missing")
64
+ define_error(:SnapshotIntegrityError, "snapshot-integrity")
65
+
54
66
  # Networking / secrets errors ---------------------------------------------
67
+ # NetworkPolicyError now also carries the core's `NetworkBuilder` build/parse
68
+ # error (a `network(|n| ...)` validation failure), which was previously
69
+ # unmapped and fell through to the base Error.
55
70
  define_error(:NetworkPolicyError, "network-policy-error")
56
71
  define_error(:SecretViolationError, "secret-violation")
57
72
  define_error(:TlsError, "tls-error")
@@ -86,6 +86,13 @@ module Microsandbox
86
86
  # Iterate it (it is {Enumerable}) to consume {ExecEvent}s as they arrive, or
87
87
  # call {#collect} to drain it into an {ExecOutput}.
88
88
  #
89
+ # @note **Single-pass, forward-only, single-consumer.** `each`/`collect` drain
90
+ # a one-shot native event channel, so the handle is *not* rewindable: a
91
+ # second `each`, or `collect` after a partial `each` (and vice versa),
92
+ # yields only what is left. Consume it once, from one thread. (An
93
+ # out-of-band {#kill}/{#signal} via the control channel is the exception —
94
+ # it can unblock a parked `each` from another thread.)
95
+ #
89
96
  # @example
90
97
  # handle = sb.exec_stream("python", ["-u", "script.py"])
91
98
  # handle.each do |event|
@@ -199,6 +199,11 @@ module Microsandbox
199
199
  # A streaming reader over a guest file, from {FS#read_stream}. Iterate it (it
200
200
  # is {Enumerable}) to consume byte chunks (ASCII-8BIT) as they arrive, or call
201
201
  # {#read} to drain it into one String.
202
+ #
203
+ # @note **Single-pass, forward-only, single-consumer.** `each`/`read` drain a
204
+ # one-shot native channel — not rewindable, and not safe to share across
205
+ # threads. Consume it once: a second `each`, or `read` after a partial
206
+ # `each`, yields only the remaining bytes.
202
207
  class FsReadStream
203
208
  include Enumerable
204
209
 
@@ -526,7 +526,10 @@ module Microsandbox
526
526
  env = spec[:env] || spec["env"]
527
527
  value = spec[:value] || spec["value"]
528
528
  unless env && value
529
- raise ArgumentError, "secret spec needs :env and :value (got #{spec.inspect})"
529
+ # Report only the keys given, never the values a secret spec carries
530
+ # the cleartext :value, and exception messages routinely land in logs
531
+ # and error trackers. Mirrors apply_registry_opts above.
532
+ raise ArgumentError, "secret spec needs :env and :value (got keys: #{spec.keys.inspect})"
530
533
  end
531
534
  out = {"env" => env.to_s, "value" => value.to_s}
532
535
  hosts = Array(spec[:hosts] || spec["hosts"]).map(&:to_s)
@@ -534,8 +537,10 @@ module Microsandbox
534
537
  hosts << single.to_s if single
535
538
  patterns = Array(spec[:host_patterns] || spec["host_patterns"]).map(&:to_s)
536
539
  if hosts.empty? && patterns.empty?
540
+ # Keys only — this branch runs after :value is confirmed present, so
541
+ # spec.inspect would always embed the cleartext secret value.
537
542
  raise ArgumentError,
538
- "secret spec needs :host, :hosts, or :host_patterns (got #{spec.inspect})"
543
+ "secret spec needs :host, :hosts, or :host_patterns (got keys: #{spec.keys.inspect})"
539
544
  end
540
545
  out["hosts"] = hosts unless hosts.empty?
541
546
  out["host_patterns"] = patterns unless patterns.empty?
@@ -820,7 +825,11 @@ module Microsandbox
820
825
  # @param cwd [String, nil] working directory
821
826
  # @param user [String, nil] user to run as
822
827
  # @param env [Hash, nil] extra environment variables
823
- # @param timeout [Numeric, nil] kill after N seconds
828
+ # @param timeout [Numeric, nil] kill the command after N seconds and raise
829
+ # {ExecTimeoutError}. Omit or pass +nil+ for *no* timeout. Note the
830
+ # asymmetry: +0+ is **not** "disable" — it is an immediate (zero) deadline,
831
+ # so the command is killed before producing any output and {ExecTimeoutError}
832
+ # is raised. Use +nil+/omit, never +0+, to mean "no limit".
824
833
  # @param tty [Boolean] allocate a pseudo-terminal
825
834
  # @param stdin [String, Symbol, nil] bytes to feed to stdin, or +:pipe+ to
826
835
  # open a streaming stdin pipe (write/close it via {ExecHandle#stdin}; only
@@ -843,6 +852,11 @@ module Microsandbox
843
852
  # Pass +stdin: :pipe+ to feed the process interactively: {ExecHandle#stdin}
844
853
  # then returns a writable sink; close it to send EOF (a process like +cat+
845
854
  # that reads until EOF will otherwise block forever).
855
+ #
856
+ # @note +timeout:+ is accepted for signature symmetry with {#exec} but is
857
+ # **not applied** on the streaming path (the runtime discards it). Enforce a
858
+ # deadline yourself around the iteration, or use blocking {#exec} with
859
+ # +timeout:+ for the kill-after-N-seconds behavior.
846
860
  # @return [ExecHandle]
847
861
  # @see ExecHandle
848
862
  def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
@@ -851,6 +865,8 @@ module Microsandbox
851
865
  end
852
866
 
853
867
  # Run a shell script and stream its output as it arrives.
868
+ # @note Like {#exec_stream}, +timeout:+ is accepted but **not applied** on
869
+ # the streaming path.
854
870
  # @return [ExecHandle]
855
871
  def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
856
872
  ExecHandle.new(@native.shell_stream(script.to_s,
@@ -1083,6 +1099,12 @@ module Microsandbox
1083
1099
  # "kind" key (e.g. "resolving", "resolved", "layer_download_progress",
1084
1100
  # "layer_materialize_progress", "complete") plus kind-specific fields.
1085
1101
  #
1102
+ # @note **Single-pass, forward-only, single-consumer.** `each` drains a
1103
+ # one-shot native progress channel — not rewindable, iterate once from a
1104
+ # single thread. {#sandbox} works whether or not you iterated (it awaits the
1105
+ # create either way), so you can skip `each` entirely; but don't expect a
1106
+ # second `each` to replay the events.
1107
+ #
1086
1108
  # @example
1087
1109
  # session = Microsandbox::Sandbox.create_with_progress("box", image: "python")
1088
1110
  # session.each { |ev| puts "#{ev["kind"]} #{ev["downloaded_bytes"]}" }
@@ -121,7 +121,10 @@ module Microsandbox
121
121
  @native.read_link(path.to_s)
122
122
  end
123
123
 
124
- # Close the SFTP session. Idempotent.
124
+ # Close the SFTP session. Idempotent. Sends the graceful SFTP close;
125
+ # relying on GC to reclaim an unclosed session drops it abruptly (no
126
+ # graceful close), so call this — or use the block form of
127
+ # {SshClient#sftp} — for a clean shutdown.
125
128
  # @return [nil]
126
129
  def close
127
130
  @native.close
@@ -161,7 +164,9 @@ module Microsandbox
161
164
  end
162
165
 
163
166
  # Open an SFTP session over this connection. With a block, the session is
164
- # yielded and closed when the block returns.
167
+ # yielded and closed when the block returns. Without a block you own the
168
+ # returned {SftpClient} and must call {SftpClient#close} (letting it GC skips
169
+ # the graceful disconnect).
165
170
  # @yieldparam sftp [SftpClient]
166
171
  # @return [SftpClient, Object]
167
172
  def sftp
@@ -175,7 +180,11 @@ module Microsandbox
175
180
  end
176
181
  end
177
182
 
178
- # Close the SSH client session. Idempotent.
183
+ # Close the SSH client session. Idempotent. Sends the graceful protocol
184
+ # disconnect (russh `Disconnect::ByApplication`); if the client is instead
185
+ # left to GC, only the in-process server task is aborted and that disconnect
186
+ # is skipped. Prefer the block form of {SshOps#open_client}, or call this in
187
+ # an `ensure`, so the session closes cleanly.
179
188
  # @return [nil]
180
189
  def close
181
190
  @native.close
@@ -216,7 +225,9 @@ module Microsandbox
216
225
  end
217
226
 
218
227
  # Open a native in-process SSH client to the sandbox. With a block, the
219
- # client is yielded and closed when the block returns.
228
+ # client is yielded and closed when the block returns. Without a block you
229
+ # own the returned {SshClient} and must call {SshClient#close} for a clean
230
+ # protocol disconnect (letting it GC only aborts the in-process server task).
220
231
  # @param user [String] guest user to authenticate as (default "root")
221
232
  # @param term [String, nil] TERM value for the session
222
233
  # @param sftp [Boolean] enable the SFTP subsystem (default true)
@@ -6,6 +6,14 @@ module Microsandbox
6
6
  # iteration blocks for new entries until the sandbox stops; otherwise it ends
7
7
  # once the historical log is drained.
8
8
  #
9
+ # @note **Single-pass, forward-only, single-consumer.** `each` drains a
10
+ # one-shot native channel, so it is *not* rewindable: a second `each` — or
11
+ # any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
12
+ # then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
13
+ # exactly once, from one thread. With `follow: true`, `each` blocks the
14
+ # calling thread uninterruptibly until the sandbox stops (see DESIGN.md on
15
+ # GVL release).
16
+ #
9
17
  # @example
10
18
  # sb.log_stream(follow: true).each { |entry| print entry.text }
11
19
  class LogStream
@@ -31,6 +39,11 @@ module Microsandbox
31
39
  # Enumerable: iteration yields one snapshot per interval tick until the
32
40
  # sandbox stops.
33
41
  #
42
+ # @note **Single-pass, forward-only, single-consumer.** Like {LogStream},
43
+ # `each` drains a one-shot native channel — not rewindable, iterate once
44
+ # from a single thread; a second pass or a post-drain combinator yields
45
+ # nothing.
46
+ #
34
47
  # @example
35
48
  # sb.metrics_stream(interval: 0.5).each { |m| puts m.cpu_percent }
36
49
  class MetricsStream
@@ -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.8.1"
11
+ VERSION = "0.8.2"
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
data/lib/microsandbox.rb CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  require_relative "microsandbox/version"
4
4
 
5
- # Load the compiled native extension. Precompiled platform gems ship a
6
- # version-specific subdirectory (e.g. lib/microsandbox/3.4/microsandbox_rb.bundle);
7
- # fall back to the flat path used by source builds.
5
+ # Load the compiled native extension. Precompiled platform gems stage the
6
+ # binary under a major.minor subdirectory (e.g.
7
+ # lib/microsandbox/3.4/microsandbox_rb.bundle) that is the directory
8
+ # rake-compiler builds (it matches the `ruby_version` against /(\d+\.\d+)/), NOT
9
+ # the API string "3.4.0" that RbConfig::CONFIG["ruby_version"] returns. Use
10
+ # RUBY_VERSION's major.minor so the require hits the staged path; fall back to
11
+ # the flat path source builds produce.
8
12
  begin
9
- ruby_version = RbConfig::CONFIG["ruby_version"].to_s
10
- require_relative "microsandbox/#{ruby_version}/microsandbox_rb"
13
+ abi_version = RUBY_VERSION[/\d+\.\d+/]
14
+ require_relative "microsandbox/#{abi_version}/microsandbox_rb"
11
15
  rescue LoadError
12
16
  require_relative "microsandbox/microsandbox_rb"
13
17
  end
data/sig/microsandbox.rbs CHANGED
@@ -1,4 +1,13 @@
1
1
  # Type signatures for the public microsandbox Ruby API.
2
+ #
3
+ # Note: value, stream, and handle types (e.g. ExecOutput, Metrics, SandboxHandle,
4
+ # the Ssh* clients, AgentClient/AgentStream, PullSession, Snapshot*/Image*/Volume*)
5
+ # are constructed by the SDK from an internal native handle or data hash, not by
6
+ # user code — you receive them from SDK calls. Their `initialize` is documented
7
+ # below only to keep `new`'s synthesized signature accurate (RBS derives `new`
8
+ # from `initialize`; omitting it does NOT hide the constructor — it falls back to
9
+ # a misleading zero-arg `() -> instance` that Ruby actually rejects). Treat these
10
+ # constructors as internal.
2
11
 
3
12
  module Microsandbox
4
13
  VERSION: String
@@ -38,6 +47,11 @@ module Microsandbox
38
47
  class ImageNotFoundError < Error end
39
48
  class ImageInUseError < Error end
40
49
  class ImagePullFailedError < Error end
50
+ class SnapshotNotFoundError < Error end
51
+ class SnapshotAlreadyExistsError < Error end
52
+ class SnapshotSandboxRunningError < Error end
53
+ class SnapshotImageMissingError < Error end
54
+ class SnapshotIntegrityError < Error end
41
55
  class NetworkPolicyError < Error end
42
56
  class SecretViolationError < Error end
43
57
  class TlsError < Error end
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.8.1
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao