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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -8
- data/Cargo.lock +1 -1
- data/DESIGN.md +183 -36
- data/README.md +8 -7
- data/SECURITY.md +1 -1
- data/docs/errors.md +1 -0
- data/docs/execution.md +8 -1
- data/docs/lifecycle.md +33 -0
- data/docs/releasing.md +85 -34
- data/docs/runtime.md +88 -1
- data/docs/surface.md +16 -8
- data/ext/microsandbox/Cargo.toml +9 -5
- data/ext/microsandbox/src/agent.rs +26 -21
- data/ext/microsandbox/src/backend.rs +291 -34
- data/ext/microsandbox/src/error.rs +18 -0
- data/ext/microsandbox/src/exec.rs +39 -21
- data/ext/microsandbox/src/fs_stream.rs +30 -15
- data/ext/microsandbox/src/host_path.rs +4 -3
- data/ext/microsandbox/src/image.rs +2 -2
- data/ext/microsandbox/src/lib.rs +3 -2
- data/ext/microsandbox/src/runtime.rs +314 -47
- data/ext/microsandbox/src/sandbox.rs +225 -124
- data/ext/microsandbox/src/snapshot.rs +24 -15
- data/ext/microsandbox/src/ssh.rs +48 -39
- data/ext/microsandbox/src/stream.rs +19 -9
- data/ext/microsandbox/src/volume.rs +40 -32
- data/lib/microsandbox/errors.rb +8 -0
- data/lib/microsandbox/sandbox.rb +75 -4
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox.rb +20 -1
- data/sig/microsandbox.rbs +25 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d3b616f0ff13a9961eab2e0cdd3812f859754efd7489d5857db0e49588f2b73
|
|
4
|
+
data.tar.gz: a172d9b8b62bb19f8b4cc2accfadec90f56b83b0e99112109a42032b7ca9a6f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9357333f88d8061e822da080bdc014438b7ca8bda1b94584a01e89fa1278c9d661b5a5eb2a7e5e36e5879076c2e9f6e82cb067dd6a4aa86023f16360953369ed
|
|
7
|
+
data.tar.gz: e7b24e0067df67c76a57f5b204f743d9420c4cd9d43f717bd3576e0324023493b68821d65e1c910168ad87a3e76261dcbb825b1476184b6c9318891cb4a99c1a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,124 @@ 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
|
+
|
|
54
|
+
## [0.17.2] - 2026-10-12
|
|
55
|
+
|
|
56
|
+
Gem-only fix and release-pipeline hardening release; the embedded runtime
|
|
57
|
+
stays at **`v0.6.18`**. One behaviour change is visible: under
|
|
58
|
+
`Thread.handle_interrupt(X => :on_blocking)` a native call now raises an
|
|
59
|
+
already-deferred exception before it starts instead of after it finishes,
|
|
60
|
+
except in the four sandbox-acquisition methods, which deliver it after the
|
|
61
|
+
acquisition (see the first Fixed entry).
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- Two contract specs keep the hand-maintained parts honest:
|
|
66
|
+
`spec/unit/rbs_contract_spec.rb` compares `sig/microsandbox.rbs` with the
|
|
67
|
+
loaded gem, and `spec/unit/docs_contract_spec.rb` checks the docs against
|
|
68
|
+
the code (version table, CHANGELOG links, error table, option list,
|
|
69
|
+
examples, release checklist). CI also runs `rbs -I sig validate`.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **Release pipeline hardened** (no change to the gem's code or API). A tag
|
|
74
|
+
now publishes only after a preflight passes — the tag equals the three
|
|
75
|
+
version constants, its commit is on `main`, and CI is green for that exact
|
|
76
|
+
commit — and after the companion `microsandbox-rb-binaries` gems have built
|
|
77
|
+
(a companion build failure now blocks the SDK publish; a companion push
|
|
78
|
+
failure still needs a re-run of `publish-binaries`). The published gem is built
|
|
79
|
+
with plain `gem build`, every workflow action is pinned to a commit SHA
|
|
80
|
+
(kept current by Dependabot), and CI checks `Cargo.lock` against the RustSec
|
|
81
|
+
advisory database with `cargo deny`. See [docs/releasing.md](docs/releasing.md).
|
|
82
|
+
- **The precompiled-extension release job is removed.** The manual-dispatch
|
|
83
|
+
`cross-gems` job never produced a gem. Precompiled extension gems are not
|
|
84
|
+
built or published; every platform installs the source gem (compiled via
|
|
85
|
+
`rb_sys`), exactly as before. The `microsandbox-rb-binaries` gems are
|
|
86
|
+
unaffected.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **An interrupted call no longer leaks what it produced.** A `Timeout.timeout`
|
|
91
|
+
(or any `Thread#raise`), a `Thread#kill` or a Ctrl-C that arrived while a
|
|
92
|
+
native call was in flight used to discard the call's result without
|
|
93
|
+
releasing it: `Timeout.timeout { Sandbox.create(...) }` raised
|
|
94
|
+
`Timeout::Error` and left the sandbox running with no Ruby object owning it
|
|
95
|
+
until the process exited, an interrupted `exec_stream` left its guest
|
|
96
|
+
session open with nothing able to kill it, and an interrupted `exec` kept
|
|
97
|
+
its collected output in memory. The result now always reaches Ruby, and
|
|
98
|
+
what happens to it depends on the interrupt:
|
|
99
|
+
- **`Timeout`/`Thread#raise` and `Thread#kill`** during `Sandbox.create`,
|
|
100
|
+
`Sandbox.connect_or_create`, `Sandbox.start` or `PullSession#sandbox`: the
|
|
101
|
+
sandbox is stopped before the exception propagates — only one the call
|
|
102
|
+
owns; a sandbox `connect_or_create` merely connected to is left running.
|
|
103
|
+
The stop is best-effort: if it fails, the interrupt is still what you
|
|
104
|
+
see, and the sandbox is torn down when its object is garbage-collected.
|
|
105
|
+
- **Ctrl-C (`SIGINT`)** during those four, and **any interrupt** during
|
|
106
|
+
every other call (`exec_stream`, streams, SSH clients, …): nothing is
|
|
107
|
+
stopped at once; the result is released — and a sandbox torn down — when
|
|
108
|
+
its unreferenced object is garbage-collected.
|
|
109
|
+
|
|
110
|
+
Native calls are still uninterruptible, so the exception is raised once the
|
|
111
|
+
call has finished, and in the first case only after the cleanup stop as well
|
|
112
|
+
(a timed-out create therefore returns later than before). One visible
|
|
113
|
+
difference for `Thread.handle_interrupt(X => :on_blocking)`: a native call
|
|
114
|
+
now raises an exception that is already deferred *before* it starts, where
|
|
115
|
+
it used to raise it after finishing. The four acquisition methods above are
|
|
116
|
+
the exception — they hold every queued interrupt back until the sandbox is
|
|
117
|
+
in the caller's hands, so there a deferred exception is delivered after the
|
|
118
|
+
acquisition (at the thread's next blocking operation), not before it.
|
|
119
|
+
- **RBS: `Klass.new` now type-checks with its real arguments.** 24 classes
|
|
120
|
+
whose constructor takes arguments had no `initialize` declaration, so RBS
|
|
121
|
+
synthesized a zero-argument `new` for them.
|
|
122
|
+
- **Docs:** `docs/surface.md` now lists every `create`/`modify` keyword;
|
|
123
|
+
`DESIGN.md` no longer names a runtime tag the gem stopped pinning long ago;
|
|
124
|
+
the `with_backend` example in `docs/runtime.md` is valid Ruby. `0.9.3`
|
|
125
|
+
(never tagged or published) is folded into `0.10.0`, both in this file and
|
|
126
|
+
in the README's Versioning table, and this file now defines the compare
|
|
127
|
+
links for `0.9.1`–`0.13.0`.
|
|
128
|
+
|
|
11
129
|
## [0.17.1] - 2026-10-12
|
|
12
130
|
|
|
13
131
|
Gem-only security and hardening release; the embedded runtime stays at
|
|
@@ -659,8 +777,10 @@ could make a later CLI copy-out overwrite an arbitrary host file — the SDK
|
|
|
659
777
|
|
|
660
778
|
Ruby bindings for the additive `v0.6.6` SDK surface (upstream #1099 / #1128),
|
|
661
779
|
bringing the gem to parity with the Python and Node SDKs, which already ship
|
|
662
|
-
this API.
|
|
663
|
-
|
|
780
|
+
this API. This release also adopts the upstream runtime **`v0.6.3` →
|
|
781
|
+
`v0.6.6`**: that work was prepared as `0.9.3`, which was never tagged or
|
|
782
|
+
published, so the runtime bump and the bindings for its new surface first
|
|
783
|
+
shipped together here. Neither part is a breaking change.
|
|
664
784
|
|
|
665
785
|
### Added
|
|
666
786
|
|
|
@@ -693,16 +813,18 @@ Deliberately **not** exposed, matching the Python and Node SDKs (both surface
|
|
|
693
813
|
these only through the CLI): `modify`'s `oci_upper_size` overlay-grow field, and
|
|
694
814
|
the `SandboxMetricsReport` / per-sandbox metrics-report helpers.
|
|
695
815
|
|
|
696
|
-
|
|
816
|
+
### Runtime adoption v0.6.3 → v0.6.6 (prepared as 0.9.3, first released in 0.10.0)
|
|
817
|
+
|
|
818
|
+
`0.9.3` was never tagged or published; everything below shipped in `0.10.0`.
|
|
697
819
|
|
|
698
820
|
Adopts upstream runtime **`v0.6.3` → `v0.6.6`** (spanning upstream `v0.6.4` and
|
|
699
821
|
`v0.6.6`; `v0.6.5` was yanked upstream — its GitHub release was pulled and the
|
|
700
822
|
type-model refactor it shipped, #1014, was reverted in `v0.6.6`, #1143). The
|
|
701
823
|
upstream SDK crate's public API grew only *additive* surface (live
|
|
702
|
-
modify/resize, ping/touch
|
|
703
|
-
|
|
824
|
+
modify/resize, ping/touch). The Ruby bindings for it are the `### Added`
|
|
825
|
+
entries above.
|
|
704
826
|
|
|
705
|
-
|
|
827
|
+
#### Fixed
|
|
706
828
|
|
|
707
829
|
- **Snapshot restore survives upstream tag republishes** (upstream #1130):
|
|
708
830
|
restore now pulls the OCI image by its pinned manifest digest
|
|
@@ -722,7 +844,7 @@ so this is a pure runtime bump with no Ruby API change.
|
|
|
722
844
|
- **Filesystem snapshot `readdir` streams entries** (upstream #1133), fixing
|
|
723
845
|
unbounded memory growth (RSS leak) when listing very large directories.
|
|
724
846
|
|
|
725
|
-
|
|
847
|
+
#### Changed
|
|
726
848
|
|
|
727
849
|
- **Embedded runtime is now `v0.6.6`** (`Microsandbox::RUNTIME_VERSION` /
|
|
728
850
|
`Microsandbox.runtime_version`). Also inherited:
|
|
@@ -1391,12 +1513,20 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
|
|
|
1391
1513
|
core crate has Apple-native deps). Until precompiled gems are published,
|
|
1392
1514
|
installing from source requires a Rust toolchain (stable >= 1.91).
|
|
1393
1515
|
|
|
1394
|
-
[Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.
|
|
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
|
|
1518
|
+
[0.17.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.1...v0.17.2
|
|
1395
1519
|
[0.17.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.17.0...v0.17.1
|
|
1396
1520
|
[0.17.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.16.0...v0.17.0
|
|
1397
1521
|
[0.16.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.15.0...v0.16.0
|
|
1398
1522
|
[0.15.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.14.0...v0.15.0
|
|
1399
1523
|
[0.14.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.13.0...v0.14.0
|
|
1524
|
+
[0.13.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.12.0...v0.13.0
|
|
1525
|
+
[0.12.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.11.0...v0.12.0
|
|
1526
|
+
[0.11.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.10.0...v0.11.0
|
|
1527
|
+
[0.10.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.2...v0.10.0
|
|
1528
|
+
[0.9.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.1...v0.9.2
|
|
1529
|
+
[0.9.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.9.0...v0.9.1
|
|
1400
1530
|
[0.9.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.2...v0.9.0
|
|
1401
1531
|
[0.8.2]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.1...v0.8.2
|
|
1402
1532
|
[0.8.1]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.8.0...v0.8.1
|
data/Cargo.lock
CHANGED
data/DESIGN.md
CHANGED
|
@@ -50,7 +50,7 @@ multi-threaded tokio runtime via `runtime.block_on(fut)`.
|
|
|
50
50
|
|
|
51
51
|
### GVL release
|
|
52
52
|
|
|
53
|
-
`block_on` is wrapped in `nogvl` (`
|
|
53
|
+
`block_on` is wrapped in `nogvl` (`rb_thread_call_without_gvl2`) so the Ruby
|
|
54
54
|
Global VM Lock is **released** while a sandbox operation is in flight. Without
|
|
55
55
|
this, a long-running `exec` would freeze every other Ruby thread/fiber in the
|
|
56
56
|
process (a real problem under Rails/Puma). The `nogvl` closure runs pure Rust
|
|
@@ -59,21 +59,179 @@ 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
61
|
**The *calling* thread is not preemptible during the call.** `nogvl` passes a
|
|
62
|
-
null unblock-function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
62
|
+
null unblock-function, so while a native call blocks, the thread that issued it
|
|
63
|
+
cannot be interrupted: `Timeout::timeout` (which relies on `Thread#raise`),
|
|
64
|
+
`Thread#kill`, and `SIGINT` (Ctrl-C) are all deferred until the call returns on
|
|
65
|
+
its own. The GVL release keeps *other* threads live; it does **not** make the
|
|
66
|
+
calling thread cancelable. This is harmless for the bounded calls
|
|
67
|
+
(`exec`/`shell` with a `timeout:`, `AgentClient` with a `timeout:`) because the
|
|
68
|
+
deadline fires inside the future, but the unbounded/streaming paths —
|
|
69
|
+
`exec`/`shell` with `timeout: nil`, `ExecHandle#recv`/`#wait`,
|
|
70
|
+
`log_stream`/`metrics_stream` `recv` (especially `follow: true`),
|
|
71
|
+
`Sandbox#wait`/`SandboxHandle#wait_until_stopped`, and `AgentClient#request`
|
|
72
|
+
with no timeout — can block their caller indefinitely if the guest wedges or a
|
|
73
|
+
relay drops. **Bound such calls with the explicit `timeout:` knobs rather than
|
|
74
|
+
wrapping them in `Timeout::timeout`**, which will not fire while the native
|
|
75
|
+
call is in flight.
|
|
76
|
+
|
|
77
|
+
### Interrupts never lose a result
|
|
78
|
+
|
|
79
|
+
An interrupt that arrives during a native call is delivered **after the call
|
|
80
|
+
returns**, and the call's result is not lost. Ruby delivers `Thread#raise`,
|
|
81
|
+
`Thread#kill` and `SIGINT` with a `longjmp`, which skips Rust frames without
|
|
82
|
+
running destructors; if that happened while the value a call had just produced
|
|
83
|
+
(a booted sandbox, a guest exec session, megabytes of output) lived only in a
|
|
84
|
+
Rust frame, it would be neither returned to Ruby nor dropped. The plain
|
|
85
|
+
`rb_thread_call_without_gvl` does exactly that — it checks interrupts once more
|
|
86
|
+
after the callback, before returning — which is why it is not used. Two rules
|
|
87
|
+
in `runtime.rs` replace it:
|
|
88
|
+
|
|
89
|
+
1. `nogvl` checks interrupts itself, under `rb_protect`, **only before the work
|
|
90
|
+
starts**: once up front, and again whenever `rb_thread_call_without_gvl2`
|
|
91
|
+
declined to run the callback because an interrupt was pending at entry. A
|
|
92
|
+
raise or kill there is an ordinary `Err` that unwinds the Rust frames
|
|
93
|
+
normally — nothing has happened yet. After the callback has run, nothing is
|
|
94
|
+
checked. (`nogvl` and `block_on` therefore return `Result`, and every call
|
|
95
|
+
site propagates it with `?`. A bound method that needs two awaits does both
|
|
96
|
+
in one `block_on`, so no value sits between two entry checks.)
|
|
97
|
+
2. Between `block_on` returning and the bound method returning its value, the
|
|
98
|
+
binding does not call into Ruby: no `funcall` (the VM checks interrupts
|
|
99
|
+
after every one), only C-level Hash/Array/String construction and
|
|
100
|
+
`TypedData` wrapping. The class lookup magnus would otherwise do with
|
|
101
|
+
`const_get` on a type's first wrap is resolved at extension load instead.
|
|
102
|
+
|
|
103
|
+
So the next interrupt checkpoint after a native call is the Ruby VM's own,
|
|
104
|
+
reached once the result is a Ruby object. What happens to it then:
|
|
105
|
+
|
|
106
|
+
- **`Sandbox.create`, `Sandbox.connect_or_create`, `Sandbox.start`, and
|
|
107
|
+
`PullSession#sandbox`** mask interrupts (`Thread.handle_interrupt(Object =>
|
|
108
|
+
:never)`) around the native call plus the wrap, and an `ensure` **stops the
|
|
109
|
+
sandbox** when an exception or a kill cuts in before the caller has the
|
|
110
|
+
object — but only a sandbox the call owns (`Sandbox#owns_lifecycle?`, the
|
|
111
|
+
same predicate as `connect_or_create`'s block form): one that was already
|
|
112
|
+
running for someone else is left running. A `SIGINT` cannot be masked this
|
|
113
|
+
way; it can arrive before the wrapper is assigned, and then the next case
|
|
114
|
+
applies.
|
|
115
|
+
- **Every other wrapper** (`ExecHandle`, streams, SSH clients, write sinks, …)
|
|
116
|
+
and every plain result simply becomes garbage; GC frees the wrapper and the
|
|
117
|
+
Rust value's `Drop` closes the session or connection.
|
|
118
|
+
|
|
119
|
+
The calls themselves remain uninterruptible, so `Timeout.timeout { Sandbox.create(...) }`
|
|
120
|
+
still waits for the create to finish — and now also for the stop that cleans
|
|
121
|
+
it up — before `Timeout::Error` surfaces. That stop is best-effort (a failure
|
|
122
|
+
is swallowed so the interrupt is what the caller sees; GC then tears the
|
|
123
|
+
sandbox down).
|
|
124
|
+
|
|
125
|
+
`Thread.handle_interrupt(X => :on_blocking)` treats a native call as the
|
|
126
|
+
blocking point at its *start*: the bridge raises an exception that is already
|
|
127
|
+
deferred before the call begins, and one that arrives during the call waits
|
|
128
|
+
for the thread's next blocking operation. The four acquisition methods are
|
|
129
|
+
the exception: their own `Object => :never` mask sits on top of the caller's,
|
|
130
|
+
so a deferred exception is not raised at the native call's entry there — the
|
|
131
|
+
sandbox is returned and the exception is delivered at the next blocking
|
|
132
|
+
operation after the acquisition.
|
|
133
|
+
|
|
134
|
+
`spec/fake_runtime/interrupt_safety_spec.rb` pins all of this against the real
|
|
135
|
+
extension, driving it with a fake `msb` (`spec/support/fake_runtime/`) whose
|
|
136
|
+
log shows whether a sandbox was stopped, dropped, or left attached.
|
|
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.
|
|
77
235
|
|
|
78
236
|
## Error mapping
|
|
79
237
|
|
|
@@ -185,7 +343,7 @@ extension.
|
|
|
185
343
|
`ext/microsandbox/Cargo.toml` depends on the core crate via a **pinned git tag**
|
|
186
344
|
(`microsandbox` / `microsandbox-network`, pinned to the same tag as
|
|
187
345
|
`Microsandbox::RUNTIME_VERSION` — currently `v0.6.18`), so the gem builds anywhere
|
|
188
|
-
— CI
|
|
346
|
+
— CI and end-user source installs —
|
|
189
347
|
without an adjacent checkout. For fast local development against a sibling
|
|
190
348
|
microsandbox checkout, copy `.cargo/config.toml.example` to `.cargo/config.toml`
|
|
191
349
|
(gitignored); its `paths` override builds against the local crates instead of
|
|
@@ -195,25 +353,13 @@ git. The override must never be committed — it would break container builds.
|
|
|
195
353
|
|
|
196
354
|
* **Source gem**: compiles the extension via `extconf.rb` (rb-sys
|
|
197
355
|
`create_rust_makefile`); requires a Rust toolchain (MSRV below).
|
|
198
|
-
* **Precompiled platform gems**: built
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
host runtime comes from the `microsandbox-rb-binaries` gem — or, without it, is
|
|
204
|
-
fetched on first use (see above). The guest `agentd` is baked into
|
|
205
|
-
the extension by *target* arch (`filesystem/build.rs` uses
|
|
206
|
-
`CARGO_CFG_TARGET_ARCH` + `include_bytes!`), so it cross-compiles correctly;
|
|
207
|
-
the real cross work is linking the *target* native libs — `libcap-ng` on Linux
|
|
208
|
-
(via Debian multiarch for `aarch64-linux`) and the Hypervisor + Security
|
|
209
|
-
frameworks on macOS (via osxcross; `arm64-darwin` is the platform still to
|
|
210
|
-
confirm — if osxcross can't link them, move it to a native `macos-14` runner).
|
|
211
|
-
The job is gated to `workflow_dispatch` and **not** auto-published on tags:
|
|
212
|
-
since CI can't boot a microVM to prove a built gem actually works, gems are
|
|
213
|
-
promoted to the publish path manually after per-platform validation. Published
|
|
214
|
-
to RubyGems via Trusted Publishing (OIDC). See [docs/releasing.md](docs/releasing.md).
|
|
356
|
+
* **Precompiled platform gems**: not built or published. Every platform
|
|
357
|
+
installs the source gem, which compiles the extension via `rb_sys` at install
|
|
358
|
+
time; the host runtime comes from the `microsandbox-rb-binaries` gem — or,
|
|
359
|
+
without it, is fetched on first use (see above). The companion binaries gems
|
|
360
|
+
below are unaffected. See [docs/releasing.md](docs/releasing.md).
|
|
215
361
|
* **Runtime binaries gems** (`microsandbox-rb-binaries`): a distinct artifact
|
|
216
|
-
from the
|
|
362
|
+
from the SDK gem — no Ruby code beyond a small
|
|
217
363
|
locator module, just the verified upstream `msb` + `libkrunfw` for one
|
|
218
364
|
platform. CI's `binaries` job vendors and builds all three platforms on every
|
|
219
365
|
run and smoke-tests the host one; the `package` job installs the source gem
|
|
@@ -269,7 +415,8 @@ create options, full mount options (tmpfs/disk + stat-virtualization/
|
|
|
269
415
|
host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
|
|
270
416
|
|
|
271
417
|
A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
|
|
272
|
-
upstream-gated — they exist at the pinned
|
|
418
|
+
upstream-gated — they exist at the pinned runtime,
|
|
419
|
+
`Microsandbox::RUNTIME_VERSION`): per-published-port host
|
|
273
420
|
**bind address** (ports always bind loopback), network **interface overrides**,
|
|
274
421
|
and inline **named-volume create-mode** (pre-create with `Volume.create`, then
|
|
275
422
|
mount with `{ named: }`). These slot in module-by-module exactly as the existing
|
data/README.md
CHANGED
|
@@ -40,7 +40,7 @@ gem compiles the Rust extension, so the first install takes a few minutes.
|
|
|
40
40
|
- Linux with KVM enabled, or macOS on Apple Silicon. Cloud-only users
|
|
41
41
|
(`MSB_BACKEND=cloud`) need neither.
|
|
42
42
|
- A stable Rust toolchain (1.91 or newer) on `PATH` to compile the source gem.
|
|
43
|
-
Precompiled extension gems
|
|
43
|
+
Precompiled extension gems are not published; see
|
|
44
44
|
[docs/releasing.md](docs/releasing.md).
|
|
45
45
|
- The `msb` runtime and `libkrunfw` firmware, from the companion gem above or
|
|
46
46
|
downloaded on first use. Provision ahead of time (a Docker layer, an
|
|
@@ -167,10 +167,10 @@ 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
|
-
| Release process,
|
|
173
|
+
| Release process, release preflight, binaries gems | [docs/releasing.md](docs/releasing.md) |
|
|
174
174
|
| Architecture: native extension, sync bridge, GVL, error mapping | [DESIGN.md](DESIGN.md) |
|
|
175
175
|
| Companion runtime gem internals | [binaries/README.md](https://github.com/ya-luotao/microsandbox-rb/blob/main/binaries/README.md) |
|
|
176
176
|
|
|
@@ -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.
|
|
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
|
|
|
@@ -237,8 +237,7 @@ constants against this gem's. Every release records its runtime in
|
|
|
237
237
|
| `0.9.0` | `v0.6.1` | adopts upstream `v0.6.0`+`v0.6.1` (zombie-runtime wait fix, secret substitution through CONNECT proxies, stale-sandbox cleanup); upstream public API is additive — no Ruby surface change |
|
|
238
238
|
| `0.9.1` | `v0.6.2` | adopts upstream `v0.6.2` (faster image loads/pulls via early cache gate + zlib-rs); upstream API unchanged — no Ruby surface change |
|
|
239
239
|
| `0.9.2` | `v0.6.3` | adopts upstream `v0.6.3` (v4-only sandboxes stop advertising AAAA DNS answers — fixes guest gRPC/c-ares preferring unreachable IPv6; scoped upstream TLS verification); glue moves to `*_local` SDK variants — no Ruby surface change |
|
|
240
|
-
| `0.
|
|
241
|
-
| `0.10.0` | `v0.6.6` | `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
|
|
240
|
+
| `0.10.0` | `v0.6.6` | adopts upstream `v0.6.4`+`v0.6.6` (`v0.6.5` was yanked upstream): snapshot restore by pinned digest — fixes fatal restore-after-tag-republish bug, fragmented-UDP/PMTU relay fixes, exec kills the whole process group, ephemeral stop-wait tolerance, readdir RSS-leak fix; plus `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
|
|
242
241
|
| `0.11.0` | `v0.6.7` | adopts upstream `v0.6.7` (**breaking**): network profiles replace `public_only`/`non_local`, structured `root_disk:` replaces `oci_upper_size:` (deprecated alias kept), snapshot descriptor contract (`create` re-keyed by name, `save`/`load` rename, `snapshot_to` removed, on-disk auto-migration), `Image.load`/`Image.save`, `follow_root_symlinks:`; runtime carries the GHSA-4vq3-cjpp-v7fg `msb copy` fix |
|
|
243
242
|
| `0.12.0` | `v0.6.8` | adopts upstream `v0.6.8` (**breaking**): `Sandbox.list`/`.list_with` return a cursor-paginated `SandboxPage` (`limit:`/`cursor:` keywords), `UnsupportedError` re-keyed by structured operations with `#operation`/`#hint`; runtime adds a shared log registry for followed streams and cloud exec/ssh reconnects |
|
|
244
243
|
| `0.13.0` | `v0.6.9` | adopts upstream `v0.6.9` (**breaking**): a bare `MSB_API_KEY` no longer selects the cloud backend (explicit `MSB_BACKEND=cloud` or a cloud profile required; invalid cloud config fails closed with `InvalidConfigError`); snapshot payload integrity becomes opt-in (`record_integrity:`, `verify` can report `:not_recorded`). Parity: default-workload execution (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`), flat root disks (`RootDisk.flat`), `modify(root_disk_size:)`, `rate_limiter:`, `vsock:`, `default_backend_info`, `Volume.get_default` |
|
|
@@ -247,13 +246,15 @@ constants against this gem's. Every release records its runtime in
|
|
|
247
246
|
| `0.16.0` | `v0.6.16` | adopts upstream `v0.6.15`+`v0.6.16` step by step: mount fallback ownership, readonly-mount write-probe fix, log retrieval rerouted through the SDK backends, config overlaid by field presence, network-slot recycling. Parity: per-mount `uid:`/`gid:`, and the convergent lifecycle — `Sandbox.connect_or_create`, `#id`, `#wait_for_status`, `#restart`, `#destroy`, `SandboxHandle#connect_or_start`, `SandboxReplacedError` |
|
|
248
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:` |
|
|
249
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
|
+
| `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
|
|
|
253
254
|
```bash
|
|
254
255
|
bin/setup # bundle install + compile the native extension
|
|
255
256
|
bundle exec rake compile # rebuild the extension (debug); compile:release for optimized
|
|
256
|
-
bundle exec rake spec # unit specs (no runtime needed)
|
|
257
|
+
bundle exec rake spec # unit + fake-runtime specs (no runtime needed)
|
|
257
258
|
MICROSANDBOX_INTEGRATION=1 bundle exec rspec spec/integration # boot real microVMs (KVM / Apple Silicon)
|
|
258
259
|
bundle exec standardrb # Ruby lint
|
|
259
260
|
cargo fmt --check --manifest-path ext/microsandbox/Cargo.toml
|
data/SECURITY.md
CHANGED
|
@@ -32,7 +32,7 @@ entry in some of them refers to the upstream project's own Ruby gem, not to
|
|
|
32
32
|
|
|
33
33
|
| Advisory | Severity | Affected `microsandbox-rb` | Status |
|
|
34
34
|
|---|---|---|---|
|
|
35
|
-
| [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. |
|
|
35
|
+
| [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. Tracked for this gem as [GHSA-qrhv-m6hf-9cmg](https://github.com/ya-luotao/microsandbox-rb/security/advisories/GHSA-qrhv-m6hf-9cmg). |
|
|
36
36
|
| [GHSA-cg9x-2vfm-3j58](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-cg9x-2vfm-3j58) — NAT64 addresses can bypass destination-group network policy | High | all versions (runtime `< v0.7.5`) | **Not fixed in this gem yet**; needs the runtime fix. Only exploitable where the host network routes a NAT64 prefix. Mitigate by denying the prefix explicitly (see below) or blocking it at the host. |
|
|
37
37
|
| [GHSA-3mpp-52gc-935r](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-3mpp-52gc-935r) — hostname allowlist bypass via mismatched HTTP `Host` / `:authority` | High | `<= 0.16.0` | Runtime fix included from `0.17.0`. If you rely on a hostname allowlist for HTTPS without TLS interception, also pass `strict: true` (off by default in this gem). |
|
|
38
38
|
| [GHSA-gj24-29j2-3x78](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-gj24-29j2-3x78) — egress allowlist bypass on malformed HTTP/1 request lines | Medium | `<= 0.12.0` | Runtime fix included from `0.13.0`. |
|
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/execution.md
CHANGED
|
@@ -76,7 +76,14 @@ Don't share one handle across threads.
|
|
|
76
76
|
The GVL is released during sandbox calls, so *other* Ruby threads keep
|
|
77
77
|
running. The *calling* thread blocks uninterruptibly until the call returns:
|
|
78
78
|
`Timeout::timeout`, `Thread#kill`, and Ctrl-C cannot interrupt a blocked native
|
|
79
|
-
call
|
|
79
|
+
call — they are delivered once it has returned. Its result is not lost when
|
|
80
|
+
that happens: an interrupted `exec_stream` handle, stream or SSH client is
|
|
81
|
+
closed when the unreferenced object is garbage-collected, and a
|
|
82
|
+
`Sandbox.create` interrupted by a `Timeout`/`Thread#raise` or a `Thread#kill`
|
|
83
|
+
stops the sandbox it booted (after a Ctrl-C it is torn down by garbage
|
|
84
|
+
collection instead; see
|
|
85
|
+
[lifecycle.md](lifecycle.md#timeouts-and-interrupts-around-a-create)). Bound
|
|
86
|
+
long-running work with a real deadline where one exists:
|
|
80
87
|
|
|
81
88
|
- `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds.
|
|
82
89
|
- `AgentClient.connect_sandbox` / `connect_path` take a `timeout:` that bounds
|
data/docs/lifecycle.md
CHANGED
|
@@ -57,6 +57,39 @@ Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
|
|
|
57
57
|
`ENTRYPOINT`/`CMD`. See [execution.md](execution.md#default-workload) for
|
|
58
58
|
`exec_default`.
|
|
59
59
|
|
|
60
|
+
### Timeouts and interrupts around a create
|
|
61
|
+
|
|
62
|
+
A create cannot be interrupted while it runs, so `Timeout.timeout(5) {
|
|
63
|
+
Sandbox.create(...) }` does not return after 5 seconds: `Timeout::Error` is
|
|
64
|
+
raised only once the create has finished. When that happens the sandbox is
|
|
65
|
+
**stopped before the exception reaches you** — the call returns only after the
|
|
66
|
+
create *and* that cleanup stop have completed, so it takes longer than the
|
|
67
|
+
create alone — and nothing is left running without an owner. The same holds
|
|
68
|
+
for `Thread#raise` and `Thread#kill`, and for `Sandbox.connect_or_create`,
|
|
69
|
+
`Sandbox.start` and `PullSession#sandbox`. Two details:
|
|
70
|
+
|
|
71
|
+
- The sandbox is stopped, not removed. Like any stopped sandbox it keeps its
|
|
72
|
+
name until you `Sandbox.remove` it (or pass `ephemeral: true`), so retrying
|
|
73
|
+
the create under the same name raises `SandboxAlreadyExistsError`.
|
|
74
|
+
- Only a sandbox the interrupted call owns is stopped. `connect_or_create`
|
|
75
|
+
that merely connected to a sandbox already running for someone else leaves
|
|
76
|
+
it running, exactly as its block form does.
|
|
77
|
+
|
|
78
|
+
The cleanup stop is best-effort: if it fails, the interrupt is still the
|
|
79
|
+
exception you get, and the sandbox is torn down when its now unreferenced
|
|
80
|
+
object is garbage-collected.
|
|
81
|
+
|
|
82
|
+
A Ctrl-C (`SIGINT`) is the one interrupt that cannot be held back until the
|
|
83
|
+
sandbox is in your hands: one that lands during the create is raised the
|
|
84
|
+
moment the create returns, before anything can stop the sandbox, which is then
|
|
85
|
+
torn down when the unreferenced object is garbage-collected instead of
|
|
86
|
+
immediately.
|
|
87
|
+
|
|
88
|
+
These methods hold queued interrupts back while they acquire the sandbox, so
|
|
89
|
+
an exception you deferred with `Thread.handle_interrupt(X => :on_blocking)` is
|
|
90
|
+
not raised by them: you get the sandbox, and the exception arrives at the
|
|
91
|
+
thread's next blocking operation.
|
|
92
|
+
|
|
60
93
|
## Convergent lifecycle
|
|
61
94
|
|
|
62
95
|
Idempotent operations that take a name to the state you want, whatever state
|