kobako 0.21.0 → 0.22.0
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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +36 -0
- data/Cargo.lock +111 -310
- data/README.md +10 -10
- data/crates/kobako-runtime/CHANGELOG.md +14 -0
- data/crates/kobako-runtime/Cargo.toml +2 -2
- data/crates/kobako-runtime/README.md +1 -1
- data/crates/kobako-transport/CHANGELOG.md +20 -0
- data/crates/kobako-transport/Cargo.toml +1 -1
- data/crates/kobako-transport/README.md +1 -1
- data/crates/kobako-transport/src/envelope/bytes.rs +7 -0
- data/crates/kobako-transport/src/envelope/error_record.rs +1 -2
- data/crates/kobako-transport/src/envelope/fault.rs +72 -23
- data/crates/kobako-transport/src/envelope/reply.rs +3 -5
- data/crates/kobako-wasmtime/CHANGELOG.md +19 -0
- data/crates/kobako-wasmtime/Cargo.toml +5 -5
- data/crates/kobako-wasmtime/README.md +1 -1
- data/crates/kobako-wasmtime/src/abi.rs +70 -0
- data/crates/kobako-wasmtime/src/driver.rs +12 -54
- data/crates/kobako-wasmtime/src/instance_pre.rs +11 -6
- data/crates/kobako-wasmtime/src/lib.rs +3 -0
- data/crates/kobako-wasmtime/src/trap.rs +6 -5
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/lib/kobako/codec/encoder.rb +14 -0
- data/lib/kobako/codec/error.rb +2 -1
- data/lib/kobako/errors.rb +34 -1
- data/lib/kobako/outcome.rb +17 -9
- data/lib/kobako/transport/dispatcher.rb +63 -10
- data/lib/kobako/transport/yielder.rb +40 -4
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/errors.rbs +13 -0
- data/sig/kobako/outcome.rbs +1 -1
- data/sig/kobako/transport/dispatcher.rbs +8 -1
- data/sig/kobako/transport/yielder.rbs +7 -0
- metadata +2 -1
data/README.md
CHANGED
|
@@ -77,7 +77,7 @@ The host embeds the sandbox and owns the SPEC wire codec. Choose by your host la
|
|
|
77
77
|
| Rust SDK | `kobako` (crates.io) | `kobako = "0.12"` | A Rust host — the same behavior contract behind an idiomatic Rust API |
|
|
78
78
|
| Low-level crates | `kobako-wasmtime` + `kobako-runtime` + `kobako-transport` + `kobako-codec` | Cargo deps | A custom host, or driving the wire directly in another language |
|
|
79
79
|
|
|
80
|
-
The Rust crates are documented on [crates.io](https://crates.io/crates/kobako); the Ruby gem is this README.
|
|
80
|
+
The Rust crates are documented on [crates.io](https://crates.io/crates/kobako); the Ruby gem is this README. Three runnable Rust hosts show the choice: [`plugin-rs`](examples/plugin-rs) builds on the SDK, [`wire-rs`](examples/wire-rs) assembles a host by hand on the low-level crates, and [`fixed-schema-rs`](examples/fixed-schema-rs) replaces what both of those keep — the payload schema — with a guest and a host that agree on protobuf.
|
|
81
81
|
|
|
82
82
|
### Pre-built Guest Binaries
|
|
83
83
|
|
|
@@ -321,7 +321,7 @@ For workloads that must be isolated from each other (one Sandbox per tenant, per
|
|
|
321
321
|
|
|
322
322
|
For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes and hands each one to a single exclusive holder at a time ([`docs/behavior/runtime.md`](docs/behavior/runtime.md) B-46..B-48). Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
|
|
323
323
|
|
|
324
|
-
`Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. B-49 bakes the shared boot state into the artifact and every dynamic script still compiles and runs per invocation, so all a pool actually saves is the
|
|
324
|
+
`Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. B-49 bakes the shared boot state into the artifact and every dynamic script still compiles and runs per invocation, so all a pool actually saves is the host-side `Sandbox.new` — now under 3 µs, an order of magnitude below the invocation that follows it. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a gain worth the coupling. What a Pool buys is warm setup and exclusive checkout, not isolation: a Sandbox holds no state from any run, so Threads sharing one are equally safe (see [Concurrency](#concurrency)).
|
|
325
325
|
|
|
326
326
|
```ruby
|
|
327
327
|
pool = Kobako::Pool.new(slots: 4) do |sandbox|
|
|
@@ -498,17 +498,17 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
|
|
|
498
498
|
| Phase | Cost |
|
|
499
499
|
|--------------------------------------------------------------|-----------------------|
|
|
500
500
|
| First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
|
|
501
|
-
| First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~3 ms one-time
|
|
502
|
-
| Subsequent `Sandbox.new` (caches warm) | ~
|
|
503
|
-
| Warm `#eval
|
|
504
|
-
| Warm `#run(:Entrypoint, ...)` dispatch | ~
|
|
505
|
-
| Service call amortized inside one invocation | ~
|
|
506
|
-
| Snippet replay per invocation | ~7.
|
|
501
|
+
| First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~1.3 ms one-time |
|
|
502
|
+
| Subsequent `Sandbox.new` (caches warm) | ~3 µs |
|
|
503
|
+
| Warm `#eval` with one Service call on a reused Sandbox | ~71 µs |
|
|
504
|
+
| Warm `#run(:Entrypoint, ...)` dispatch | ~81 µs |
|
|
505
|
+
| Service call amortized inside one invocation | ~5.7 µs |
|
|
506
|
+
| Snippet replay per invocation | ~7.5 µs each |
|
|
507
507
|
| Per additional idle Sandbox (RSS) | ~1 KB |
|
|
508
508
|
|
|
509
|
-
The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~33 MB total. Under the default `gvl: :hold`, wasm work is GVL-serialized: aggregate throughput stays around
|
|
509
|
+
The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~33 MB total. Under the default `gvl: :hold`, wasm work is GVL-serialized: aggregate throughput stays around 16k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. Opting a Sandbox into `gvl: :release` lifts that ceiling for compute-bound scripts (see [Concurrency](#concurrency)). A +10% regression on any SPEC-mandated benchmark blocks release.
|
|
510
510
|
|
|
511
|
-
Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#11` in [`benchmark/README.md`](benchmark/README.md)). There `=~` (~5 µs/match) costs about 4× `match?` (~1.
|
|
511
|
+
Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#11` in [`benchmark/README.md`](benchmark/README.md)). There `=~` (~5 µs/match) costs about 4.5× `match?` (~1.1 µs), because `=~` eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests.
|
|
512
512
|
|
|
513
513
|
```bash
|
|
514
514
|
bundle exec rake bench # every gated regression benchmark (~5-8 min)
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.13.1...kobako-runtime-v0.14.0) (2026-08-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Miscellaneous Chores
|
|
7
|
+
|
|
8
|
+
* **kobako-runtime:** Synchronize kobako crates versions
|
|
9
|
+
|
|
10
|
+
## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.13.0...kobako-runtime-v0.13.1) (2026-07-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Miscellaneous Chores
|
|
14
|
+
|
|
15
|
+
* **kobako-runtime:** Synchronize kobako crates versions
|
|
16
|
+
|
|
3
17
|
## [0.13.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.12.0...kobako-runtime-v0.13.0) (2026-07-29)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
[package]
|
|
15
15
|
name = "kobako-runtime"
|
|
16
|
-
version = "0.
|
|
16
|
+
version = "0.14.0"
|
|
17
17
|
edition = "2021"
|
|
18
18
|
description = "Engine-neutral host runtime contract for embedding kobako Wasm guests."
|
|
19
19
|
license = "Apache-2.0"
|
|
@@ -27,4 +27,4 @@ categories = ["wasm", "virtualization"]
|
|
|
27
27
|
# in-tree builds (and the Ruby gem, which ships both crates) resolving
|
|
28
28
|
# locally.
|
|
29
29
|
[dependencies]
|
|
30
|
-
kobako-transport = { version = "0.
|
|
30
|
+
kobako-transport = { version = "0.14.0", path = "../kobako-transport" }
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/elct9620/kobako/compare/kobako-transport-v0.13.1...kobako-transport-v0.14.0) (2026-08-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **transport:** let a guest block's exception continue as itself ([c8b1de7](https://github.com/elct9620/kobako/commit/c8b1de7154336fab8fa20c0d7a5b3baaad11d551))
|
|
9
|
+
* **transport:** separate a failed exchange from a failed Service ([fb92515](https://github.com/elct9620/kobako/commit/fb92515fc76ffebbb8491b525d53b1725cf97dd0))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **wire:** let a Fault reader survive what it predates ([eeabfb7](https://github.com/elct9620/kobako/commit/eeabfb7aa4de3d08852ca1b41517b924d72a7312))
|
|
15
|
+
|
|
16
|
+
## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-transport-v0.13.0...kobako-transport-v0.13.1) (2026-07-30)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Miscellaneous Chores
|
|
20
|
+
|
|
21
|
+
* **kobako-transport:** Synchronize kobako crates versions
|
|
22
|
+
|
|
3
23
|
## [0.13.0](https://github.com/elct9620/kobako/compare/kobako-transport-v0.12.0...kobako-transport-v0.13.0) (2026-07-29)
|
|
4
24
|
|
|
5
25
|
|
|
@@ -80,6 +80,13 @@ impl<'a> Reader<'a> {
|
|
|
80
80
|
&self.bytes[self.pos..]
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/// Consume whatever is left without reading it. The extension seam:
|
|
84
|
+
/// a field a later contract version appends is invisible to a reader
|
|
85
|
+
/// built before it, and must not read as a framing desync.
|
|
86
|
+
pub(crate) fn skip_unknown_fields(&mut self) {
|
|
87
|
+
self.pos = self.bytes.len();
|
|
88
|
+
}
|
|
89
|
+
|
|
83
90
|
/// Refuse anything left over. Used by envelopes whose last field is
|
|
84
91
|
/// self-delimiting, where trailing bytes signal a framing desync.
|
|
85
92
|
pub(crate) fn finish(self) -> Result<(), DecodeError> {
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
//! A block failure and an invocation failure carry the same three fields,
|
|
4
4
|
//! so the host re-raises from either without consulting a payload codec.
|
|
5
5
|
//! Distinct from a Fault, which travels the other way and is categorized
|
|
6
|
-
//! by
|
|
7
|
-
//! error.
|
|
6
|
+
//! by a reserved category name the guest maps to a proxy-side error.
|
|
8
7
|
|
|
9
8
|
use super::bytes::{Reader, Writer};
|
|
10
9
|
use super::DecodeError;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//! The host refusing or failing a Call.
|
|
2
2
|
//!
|
|
3
|
-
//! Every byte of a Fault is kobako's — a
|
|
3
|
+
//! Every byte of a Fault is kobako's — a category and a message —
|
|
4
4
|
//! so it rides the envelope and a guest reads a refusal with no payload
|
|
5
5
|
//! codec at all.
|
|
6
6
|
//!
|
|
@@ -15,15 +15,20 @@ use super::DecodeError;
|
|
|
15
15
|
const KIND_RUNTIME: u8 = 0;
|
|
16
16
|
const KIND_ARGUMENT: u8 = 1;
|
|
17
17
|
const KIND_UNDEFINED: u8 = 2;
|
|
18
|
+
const KIND_INTERNAL: u8 = 3;
|
|
19
|
+
const KIND_BLOCK: u8 = 4;
|
|
18
20
|
|
|
19
|
-
/// Which
|
|
21
|
+
/// Which failure a Fault reports.
|
|
20
22
|
///
|
|
21
|
-
///
|
|
22
|
-
///
|
|
23
|
-
///
|
|
24
|
-
///
|
|
25
|
-
///
|
|
23
|
+
/// The set grows, so a reader that meets a category it does not know
|
|
24
|
+
/// degrades rather than failing the exchange. `Undefined` stays
|
|
25
|
+
/// indistinguishable across its causes — an unbound path, an unknown
|
|
26
|
+
/// method, a rejected name — so a guest probing the surface learns
|
|
27
|
+
/// nothing from which refusal it got, which is also what makes it the
|
|
28
|
+
/// landing place for a category this reader predates: it attributes
|
|
29
|
+
/// nothing to the Service that a newer category might contradict.
|
|
26
30
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
31
|
+
#[non_exhaustive]
|
|
27
32
|
pub enum FaultKind {
|
|
28
33
|
/// A Ruby exception raised inside the Service method.
|
|
29
34
|
Runtime,
|
|
@@ -31,6 +36,18 @@ pub enum FaultKind {
|
|
|
31
36
|
Argument,
|
|
32
37
|
/// No such member or method.
|
|
33
38
|
Undefined,
|
|
39
|
+
/// The exchange itself failed, so no Service outcome exists to
|
|
40
|
+
/// report — the request was unreadable, or the host ran out of the
|
|
41
|
+
/// resources the exchange needs. Separate from `Runtime` because
|
|
42
|
+
/// retrying the same call against a working host would behave
|
|
43
|
+
/// differently, which is not true of a Service that raised.
|
|
44
|
+
Internal,
|
|
45
|
+
/// The caller's own block raised while the Service was yielding to
|
|
46
|
+
/// it, and the Service did not rescue it. The failure is the
|
|
47
|
+
/// caller's, so it carries no host detail: the caller still holds
|
|
48
|
+
/// the exception it raised and re-raises that, which is what the
|
|
49
|
+
/// same code would do without a Sandbox between the two frames.
|
|
50
|
+
Block,
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
impl FaultKind {
|
|
@@ -39,17 +56,20 @@ impl FaultKind {
|
|
|
39
56
|
FaultKind::Runtime => KIND_RUNTIME,
|
|
40
57
|
FaultKind::Argument => KIND_ARGUMENT,
|
|
41
58
|
FaultKind::Undefined => KIND_UNDEFINED,
|
|
59
|
+
FaultKind::Internal => KIND_INTERNAL,
|
|
60
|
+
FaultKind::Block => KIND_BLOCK,
|
|
42
61
|
}
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
|
|
64
|
+
/// Read a category from its tag, degrading one this reader predates
|
|
65
|
+
/// to the category that claims the least about what failed.
|
|
66
|
+
fn from_tag(tag: u8) -> Self {
|
|
46
67
|
match tag {
|
|
47
|
-
KIND_RUNTIME =>
|
|
48
|
-
KIND_ARGUMENT =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)),
|
|
68
|
+
KIND_RUNTIME => FaultKind::Runtime,
|
|
69
|
+
KIND_ARGUMENT => FaultKind::Argument,
|
|
70
|
+
KIND_INTERNAL => FaultKind::Internal,
|
|
71
|
+
KIND_BLOCK => FaultKind::Block,
|
|
72
|
+
_ => FaultKind::Undefined,
|
|
53
73
|
}
|
|
54
74
|
}
|
|
55
75
|
|
|
@@ -59,16 +79,20 @@ impl FaultKind {
|
|
|
59
79
|
FaultKind::Runtime => "runtime",
|
|
60
80
|
FaultKind::Argument => "argument",
|
|
61
81
|
FaultKind::Undefined => "undefined",
|
|
82
|
+
FaultKind::Internal => "internal",
|
|
83
|
+
FaultKind::Block => "block",
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
/// Read a category from the name a frontend uses, or `None` when
|
|
66
|
-
///
|
|
87
|
+
/// Read a category from the name a frontend uses, or `None` when this
|
|
88
|
+
/// build predates the name.
|
|
67
89
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
68
90
|
match name {
|
|
69
91
|
"runtime" => Some(FaultKind::Runtime),
|
|
70
92
|
"argument" => Some(FaultKind::Argument),
|
|
71
93
|
"undefined" => Some(FaultKind::Undefined),
|
|
94
|
+
"internal" => Some(FaultKind::Internal),
|
|
95
|
+
"block" => Some(FaultKind::Block),
|
|
72
96
|
_ => None,
|
|
73
97
|
}
|
|
74
98
|
}
|
|
@@ -91,8 +115,12 @@ impl Fault {
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
pub(crate) fn read(reader: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
|
94
|
-
let kind = FaultKind::from_tag(reader.u8()?)
|
|
118
|
+
let kind = FaultKind::from_tag(reader.u8()?);
|
|
95
119
|
let message = reader.text()?.to_owned();
|
|
120
|
+
// A Fault gains fields as the contract does, and it is the last
|
|
121
|
+
// thing a Reply carries, so whatever follows the fields this
|
|
122
|
+
// reader knows belongs to a later version of them.
|
|
123
|
+
reader.skip_unknown_fields();
|
|
96
124
|
Ok(Fault { kind, message })
|
|
97
125
|
}
|
|
98
126
|
|
|
@@ -111,6 +139,8 @@ mod tests {
|
|
|
111
139
|
FaultKind::Runtime,
|
|
112
140
|
FaultKind::Argument,
|
|
113
141
|
FaultKind::Undefined,
|
|
142
|
+
FaultKind::Internal,
|
|
143
|
+
FaultKind::Block,
|
|
114
144
|
] {
|
|
115
145
|
let fault = Fault::new(kind, "boom");
|
|
116
146
|
let mut w = Writer::new();
|
|
@@ -126,12 +156,29 @@ mod tests {
|
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
#[test]
|
|
129
|
-
fn
|
|
130
|
-
let encoded = vec![
|
|
159
|
+
fn a_kind_the_reader_does_not_know_degrades_to_undefined() {
|
|
160
|
+
let encoded = vec![0xfe, 0, 0, 0, 1, b'x'];
|
|
131
161
|
let mut r = Reader::new(&encoded);
|
|
132
|
-
|
|
133
|
-
Fault::read(&mut r)
|
|
134
|
-
|
|
162
|
+
assert_eq!(
|
|
163
|
+
Fault::read(&mut r),
|
|
164
|
+
Ok(Fault::new(FaultKind::Undefined, "x")),
|
|
165
|
+
"a Fault category added after this reader was built must deliver its message \
|
|
166
|
+
under the category that claims the least, not fail the exchange"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn a_field_the_reader_does_not_know_is_skipped() {
|
|
172
|
+
let mut w = Writer::new();
|
|
173
|
+
Fault::new(FaultKind::Undefined, "no such method").write(&mut w);
|
|
174
|
+
w.bytes(b"a field a later contract version appended");
|
|
175
|
+
let encoded = w.into_bytes();
|
|
176
|
+
let mut r = Reader::new(&encoded);
|
|
177
|
+
assert_eq!(
|
|
178
|
+
Fault::read(&mut r),
|
|
179
|
+
Ok(Fault::new(FaultKind::Undefined, "no such method")),
|
|
180
|
+
"a Fault field added after this reader was built must be skipped, so the \
|
|
181
|
+
fields it does know still arrive"
|
|
135
182
|
);
|
|
136
183
|
}
|
|
137
184
|
|
|
@@ -155,6 +202,8 @@ mod tests {
|
|
|
155
202
|
FaultKind::Runtime,
|
|
156
203
|
FaultKind::Argument,
|
|
157
204
|
FaultKind::Undefined,
|
|
205
|
+
FaultKind::Internal,
|
|
206
|
+
FaultKind::Block,
|
|
158
207
|
] {
|
|
159
208
|
assert_eq!(
|
|
160
209
|
FaultKind::from_name(kind.name()),
|
|
@@ -165,7 +214,7 @@ mod tests {
|
|
|
165
214
|
assert_eq!(
|
|
166
215
|
FaultKind::from_name("other"),
|
|
167
216
|
None,
|
|
168
|
-
"a name
|
|
217
|
+
"a name this build predates must not resolve to a kind"
|
|
169
218
|
);
|
|
170
219
|
}
|
|
171
220
|
}
|
|
@@ -32,11 +32,9 @@ impl Reply {
|
|
|
32
32
|
let tag = reader.u8()?;
|
|
33
33
|
match tag {
|
|
34
34
|
TAG_OK => Ok(Reply::Ok(reader.remaining().to_vec())),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Ok(Reply::Fault(fault))
|
|
39
|
-
}
|
|
35
|
+
// No `finish` here: a Fault owns everything after the tag,
|
|
36
|
+
// including the fields a later contract version appends.
|
|
37
|
+
TAG_FAULT => Ok(Reply::Fault(Fault::read(&mut reader)?)),
|
|
40
38
|
_ => Err(DecodeError::new("Reply tag must be 0 (ok) or 1 (fault)")),
|
|
41
39
|
}
|
|
42
40
|
}
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.13.1...kobako-wasmtime-v0.14.0) (2026-08-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Miscellaneous Chores
|
|
7
|
+
|
|
8
|
+
* **kobako-wasmtime:** Synchronize kobako crates versions
|
|
9
|
+
|
|
10
|
+
## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.13.0...kobako-wasmtime-v0.13.1) (2026-07-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **host:** keep nothing in the ABI probe's capture pipes ([a3d6c1c](https://github.com/elct9620/kobako/commit/a3d6c1c8edb9964a7d8ab162b2e379b39dbbddb9))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Performance Improvements
|
|
19
|
+
|
|
20
|
+
* **host:** ask an artifact its ABI version once, not once per Sandbox ([693b94f](https://github.com/elct9620/kobako/commit/693b94f878e56815fe3faeae94e5c4d0cd9c8bb6))
|
|
21
|
+
|
|
3
22
|
## [0.13.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.12.0...kobako-wasmtime-v0.13.0) (2026-07-29)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
[package]
|
|
17
17
|
name = "kobako-wasmtime"
|
|
18
|
-
version = "0.
|
|
18
|
+
version = "0.14.0"
|
|
19
19
|
edition = "2021"
|
|
20
20
|
description = "wasmtime implementation of the kobako host runtime contract."
|
|
21
21
|
license = "Apache-2.0"
|
|
@@ -28,17 +28,17 @@ categories = ["wasm", "virtualization"]
|
|
|
28
28
|
# The engine-neutral contract this crate implements. The version pin
|
|
29
29
|
# rides the linked release group; the path keeps in-tree builds (and
|
|
30
30
|
# the Ruby gem, which ships both crates) resolving locally.
|
|
31
|
-
kobako-runtime = { version = "0.
|
|
31
|
+
kobako-runtime = { version = "0.14.0", path = "../kobako-runtime" }
|
|
32
32
|
# The fixed tier: the core envelope this driver shuttles and the ABI
|
|
33
33
|
# values it drives the exports against.
|
|
34
|
-
kobako-transport = { version = "0.
|
|
34
|
+
kobako-transport = { version = "0.14.0", path = "../kobako-transport" }
|
|
35
35
|
# wasmtime — host-side embedder for kobako.wasm. We disable default-features
|
|
36
36
|
# and opt back in only what kobako needs: a Cranelift-backed runtime that can
|
|
37
37
|
# compile a pre-built wasm32-wasip1 module on the host triple, plus the `wat`
|
|
38
38
|
# feature so test fixtures can be expressed as text.
|
|
39
39
|
# `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
|
|
40
40
|
# are intentionally off — kobako runs short-lived synchronous sandboxes.
|
|
41
|
-
wasmtime = { version = "
|
|
41
|
+
wasmtime = { version = "47.0.2", default-features = false, features = [
|
|
42
42
|
"cranelift",
|
|
43
43
|
"runtime",
|
|
44
44
|
"gc",
|
|
@@ -52,7 +52,7 @@ wasmtime = { version = "46.0.1", default-features = false, features = [
|
|
|
52
52
|
# WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
|
|
53
53
|
# `p2` (component-model) and `p0`/`p3` (async) because kobako runs
|
|
54
54
|
# synchronous sandboxes only.
|
|
55
|
-
wasmtime-wasi = { version = "
|
|
55
|
+
wasmtime-wasi = { version = "47.0.2", default-features = false, features = ["p1"] }
|
|
56
56
|
# sha2 keys the on-disk compiled-module cache by Guest Binary content
|
|
57
57
|
# (see cache.rs); a collision would load the wrong artifact, so the
|
|
58
58
|
# hash must be cryptographic.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//! The Guest Binary's declared ABI version check.
|
|
2
|
+
//!
|
|
3
|
+
//! An artifact reports one ABI version, so the answer belongs to the
|
|
4
|
+
//! module and not to the sandbox asking for it. The check therefore runs
|
|
5
|
+
//! alongside the `InstancePre` it guards (`crate::instance_pre`) and is
|
|
6
|
+
//! amortised with it, rather than once per `Driver::new`.
|
|
7
|
+
|
|
8
|
+
use wasmtime::{AsContextMut, InstancePre, Store as WtStore};
|
|
9
|
+
|
|
10
|
+
use crate::cache::shared_engine;
|
|
11
|
+
use crate::config::Config;
|
|
12
|
+
use crate::invocation::Invocation;
|
|
13
|
+
use crate::{frames, trap};
|
|
14
|
+
use kobako_runtime::error::SetupError;
|
|
15
|
+
use kobako_runtime::profile::Profile;
|
|
16
|
+
use kobako_transport::abi::ABI_VERSION;
|
|
17
|
+
|
|
18
|
+
/// Caps for the throwaway probe instance. Instantiation runs the
|
|
19
|
+
/// artifact's start section, so the capture pipes are sized to keep
|
|
20
|
+
/// nothing — the probe reads neither — and the strongest isolation rung
|
|
21
|
+
/// is taken, since reading an artifact's version grants no ambient
|
|
22
|
+
/// authority. The timeout and memory caps stay off because both arm per
|
|
23
|
+
/// invocation and there is none here.
|
|
24
|
+
const PROBE_CONFIG: Config = Config {
|
|
25
|
+
timeout: None,
|
|
26
|
+
memory_limit: None,
|
|
27
|
+
stdout_limit: Some(0),
|
|
28
|
+
stderr_limit: Some(0),
|
|
29
|
+
profile: Profile::Hermetic,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/// Instantiate a throwaway probe instance from `pre` and require the
|
|
33
|
+
/// guest's `__kobako_abi_version` export to equal `ABI_VERSION`. An
|
|
34
|
+
/// absent export or a non-equal value is a deterministic artifact
|
|
35
|
+
/// fault, so every failure is a `SetupError` for the frontend to
|
|
36
|
+
/// attribute. The frameless WASI context keeps a third-party guest
|
|
37
|
+
/// whose start section touches WASI on the `SetupError` path instead of
|
|
38
|
+
/// panicking in `Invocation::wasi_mut`.
|
|
39
|
+
pub(crate) fn verify(pre: &InstancePre<Invocation>) -> Result<(), SetupError> {
|
|
40
|
+
let mut store = WtStore::new(shared_engine()?, Invocation::new(None));
|
|
41
|
+
// Epoch interruption is on engine-wide and a fresh Store's deadline
|
|
42
|
+
// has already elapsed, so the probe must name one it cannot reach.
|
|
43
|
+
store.set_epoch_deadline(trap::NO_TIMEOUT_EPOCH_DELTA);
|
|
44
|
+
frames::install_wasi_frames(&mut store, &PROBE_CONFIG, &[])
|
|
45
|
+
.map_err(|t| SetupError::Dead(t.to_string()))?;
|
|
46
|
+
let instance = pre
|
|
47
|
+
.instantiate(store.as_context_mut())
|
|
48
|
+
.map_err(trap::instantiate_err)?;
|
|
49
|
+
let probe = instance
|
|
50
|
+
.get_typed_func::<(), u32>(store.as_context_mut(), "__kobako_abi_version")
|
|
51
|
+
.map_err(|_| {
|
|
52
|
+
SetupError::Dead(format!(
|
|
53
|
+
"the Guest Binary does not export __kobako_abi_version; \
|
|
54
|
+
rebuild it against ABI version {ABI_VERSION}"
|
|
55
|
+
))
|
|
56
|
+
})?;
|
|
57
|
+
let reported = probe.call(store.as_context_mut(), ()).map_err(|e| {
|
|
58
|
+
SetupError::Dead(format!(
|
|
59
|
+
"failed to read the Guest Binary's ABI version: {e}"
|
|
60
|
+
))
|
|
61
|
+
})?;
|
|
62
|
+
if reported != ABI_VERSION {
|
|
63
|
+
return Err(SetupError::Dead(format!(
|
|
64
|
+
"the Guest Binary reports ABI version {reported}, but this host \
|
|
65
|
+
implements ABI version {ABI_VERSION}; rebuild the Guest Binary \
|
|
66
|
+
against the host's version"
|
|
67
|
+
)));
|
|
68
|
+
}
|
|
69
|
+
Ok(())
|
|
70
|
+
}
|
|
@@ -26,14 +26,13 @@ use kobako_runtime::profile::Profile;
|
|
|
26
26
|
use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
|
|
27
27
|
use kobako_runtime::snapshot::{Capture, Completion, Snapshot, Usage};
|
|
28
28
|
|
|
29
|
-
use kobako_transport::abi::ABI_VERSION;
|
|
30
|
-
|
|
31
29
|
/// The wasmtime execution unit behind one sandbox runtime.
|
|
32
30
|
pub struct Driver {
|
|
33
|
-
// Pre-linked instantiation template (import wiring
|
|
34
|
-
// done once in
|
|
35
|
-
//
|
|
36
|
-
//
|
|
31
|
+
// Pre-linked instantiation template (import wiring, type checks,
|
|
32
|
+
// and the ABI version check done once in
|
|
33
|
+
// `instance_pre::cached_instance_pre`). Every invocation
|
|
34
|
+
// instantiates a fresh instance from it and discards the whole
|
|
35
|
+
// Store afterwards — the per-invocation instance discipline.
|
|
37
36
|
instance_pre: WtInstancePre<Invocation>,
|
|
38
37
|
// Every cap forwarded from the Sandbox; see `Config`.
|
|
39
38
|
config: Config,
|
|
@@ -41,56 +40,15 @@ pub struct Driver {
|
|
|
41
40
|
|
|
42
41
|
impl Driver {
|
|
43
42
|
/// Construct a Driver from a wasm file path, using the process-wide
|
|
44
|
-
/// shared Engine and per-path Module / InstancePre caches
|
|
45
|
-
///
|
|
46
|
-
///
|
|
47
|
-
/// driver.
|
|
43
|
+
/// shared Engine and per-path Module / InstancePre caches. The
|
|
44
|
+
/// artifact's ABI version is verified with the cached template
|
|
45
|
+
/// (`crate::abi`). Every failure is a `SetupError` for the frontend
|
|
46
|
+
/// to attribute — Engine and Module never leave the driver.
|
|
48
47
|
pub fn new(path: &Path, config: Config) -> Result<Self, SetupError> {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
instance_pre,
|
|
48
|
+
Ok(Self {
|
|
49
|
+
instance_pre: instance_pre::cached_instance_pre(path)?,
|
|
52
50
|
config,
|
|
53
|
-
}
|
|
54
|
-
driver.probe_abi_version()?;
|
|
55
|
-
Ok(driver)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/// Instantiate a throwaway probe instance at construction and require
|
|
59
|
-
/// the guest's `__kobako_abi_version` export to equal `ABI_VERSION`.
|
|
60
|
-
/// An absent export or a non-equal value is a deterministic artifact
|
|
61
|
-
/// fault. The probe Store drops here; invocation instances are
|
|
62
|
-
/// created per invoke. The frameless WASI context keeps a third-party
|
|
63
|
-
/// guest whose start section touches WASI on the `SetupError` path
|
|
64
|
-
/// instead of panicking in `Invocation::wasi_mut`.
|
|
65
|
-
fn probe_abi_version(&self) -> Result<(), SetupError> {
|
|
66
|
-
let mut store = self.new_store()?;
|
|
67
|
-
frames::install_wasi_frames(&mut store, &self.config, &[])
|
|
68
|
-
.map_err(|t| SetupError::Dead(t.to_string()))?;
|
|
69
|
-
let instance = self
|
|
70
|
-
.instance_pre
|
|
71
|
-
.instantiate(store.as_context_mut())
|
|
72
|
-
.map_err(trap::instantiate_err)?;
|
|
73
|
-
let probe = instance
|
|
74
|
-
.get_typed_func::<(), u32>(store.as_context_mut(), "__kobako_abi_version")
|
|
75
|
-
.map_err(|_| {
|
|
76
|
-
SetupError::Dead(format!(
|
|
77
|
-
"the Guest Binary does not export __kobako_abi_version; \
|
|
78
|
-
rebuild it against ABI version {ABI_VERSION}"
|
|
79
|
-
))
|
|
80
|
-
})?;
|
|
81
|
-
let reported = probe.call(store.as_context_mut(), ()).map_err(|e| {
|
|
82
|
-
SetupError::Dead(format!(
|
|
83
|
-
"failed to read the Guest Binary's ABI version: {e}"
|
|
84
|
-
))
|
|
85
|
-
})?;
|
|
86
|
-
if reported != ABI_VERSION {
|
|
87
|
-
return Err(SetupError::Dead(format!(
|
|
88
|
-
"the Guest Binary reports ABI version {reported}, but this host \
|
|
89
|
-
implements ABI version {ABI_VERSION}; rebuild the Guest Binary \
|
|
90
|
-
against the host's version"
|
|
91
|
-
)));
|
|
92
|
-
}
|
|
93
|
-
Ok(())
|
|
51
|
+
})
|
|
94
52
|
}
|
|
95
53
|
|
|
96
54
|
/// Build the per-invocation Store: a fresh `Invocation` wired with
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
//! compiled Module are identical for every `Kobako::Runtime` on the
|
|
6
6
|
//! same Guest Binary — both host closures read all their state from
|
|
7
7
|
//! the `Invocation` inside the calling Store, never from the Runtime.
|
|
8
|
-
//! Caching the resolved `InstancePre` per path
|
|
9
|
-
//!
|
|
8
|
+
//! Caching the resolved `InstancePre` per path — together with the ABI
|
|
9
|
+
//! version check it guards (`crate::abi`) — takes both off the
|
|
10
|
+
//! `Driver::new` hot path.
|
|
10
11
|
//!
|
|
11
12
|
//! Concurrency: see `crate::cache` — under Ruby's GVL the Mutex serves
|
|
12
13
|
//! `Sync` bounds rather than real contention.
|
|
@@ -20,16 +21,17 @@ use wasmtime_wasi::p1;
|
|
|
20
21
|
|
|
21
22
|
use crate::cache::{cached_module, shared_engine};
|
|
22
23
|
use crate::invocation::Invocation;
|
|
23
|
-
use crate::{dispatch, trap};
|
|
24
|
+
use crate::{abi, dispatch, trap};
|
|
24
25
|
use kobako_runtime::error::SetupError;
|
|
25
26
|
|
|
26
27
|
static INSTANCE_PRE_CACHE: OnceLock<Mutex<HashMap<PathBuf, InstancePre<Invocation>>>> =
|
|
27
28
|
OnceLock::new();
|
|
28
29
|
|
|
29
30
|
/// Look up `path` in the per-path `InstancePre` cache, wiring the
|
|
30
|
-
/// Linker
|
|
31
|
-
///
|
|
32
|
-
///
|
|
31
|
+
/// Linker, resolving the Module's imports, and verifying the artifact's
|
|
32
|
+
/// ABI version on a miss. Compilation faults surface through
|
|
33
|
+
/// `cached_module`; import-resolution and ABI faults return a
|
|
34
|
+
/// runtime-dead `SetupError` (boundary → `Kobako::SetupError`).
|
|
33
35
|
pub(crate) fn cached_instance_pre(path: &Path) -> Result<InstancePre<Invocation>, SetupError> {
|
|
34
36
|
let cache = INSTANCE_PRE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
|
35
37
|
|
|
@@ -47,6 +49,9 @@ pub(crate) fn cached_instance_pre(path: &Path) -> Result<InstancePre<Invocation>
|
|
|
47
49
|
let pre = linker
|
|
48
50
|
.instantiate_pre(&module)
|
|
49
51
|
.map_err(trap::instantiate_err)?;
|
|
52
|
+
// Verify before the insert, so a rejected artifact is never
|
|
53
|
+
// remembered as a usable template.
|
|
54
|
+
abi::verify(&pre)?;
|
|
50
55
|
cache
|
|
51
56
|
.lock()
|
|
52
57
|
.expect("instance_pre cache mutex poisoned")
|