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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +36 -0
  4. data/Cargo.lock +111 -310
  5. data/README.md +10 -10
  6. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  7. data/crates/kobako-runtime/Cargo.toml +2 -2
  8. data/crates/kobako-runtime/README.md +1 -1
  9. data/crates/kobako-transport/CHANGELOG.md +20 -0
  10. data/crates/kobako-transport/Cargo.toml +1 -1
  11. data/crates/kobako-transport/README.md +1 -1
  12. data/crates/kobako-transport/src/envelope/bytes.rs +7 -0
  13. data/crates/kobako-transport/src/envelope/error_record.rs +1 -2
  14. data/crates/kobako-transport/src/envelope/fault.rs +72 -23
  15. data/crates/kobako-transport/src/envelope/reply.rs +3 -5
  16. data/crates/kobako-wasmtime/CHANGELOG.md +19 -0
  17. data/crates/kobako-wasmtime/Cargo.toml +5 -5
  18. data/crates/kobako-wasmtime/README.md +1 -1
  19. data/crates/kobako-wasmtime/src/abi.rs +70 -0
  20. data/crates/kobako-wasmtime/src/driver.rs +12 -54
  21. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -6
  22. data/crates/kobako-wasmtime/src/lib.rs +3 -0
  23. data/crates/kobako-wasmtime/src/trap.rs +6 -5
  24. data/data/kobako.wasm +0 -0
  25. data/ext/kobako/Cargo.toml +1 -1
  26. data/lib/kobako/codec/encoder.rb +14 -0
  27. data/lib/kobako/codec/error.rb +2 -1
  28. data/lib/kobako/errors.rb +34 -1
  29. data/lib/kobako/outcome.rb +17 -9
  30. data/lib/kobako/transport/dispatcher.rb +63 -10
  31. data/lib/kobako/transport/yielder.rb +40 -4
  32. data/lib/kobako/version.rb +1 -1
  33. data/sig/kobako/errors.rbs +13 -0
  34. data/sig/kobako/outcome.rbs +1 -1
  35. data/sig/kobako/transport/dispatcher.rbs +8 -1
  36. data/sig/kobako/transport/yielder.rbs +7 -0
  37. 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. Two runnable Rust hosts show the choice: [`plugin-rs`](examples/plugin-rs) builds on the SDK, and [`wire-rs`](examples/wire-rs) assembles a host by hand on the low-level crates.
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 ~28 µs host-side `Sandbox.new`. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a significant gain (~4-5% in the [serverless example](examples/serverless/README.md), and proportionally less once the script itself does real work). 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)).
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) | ~28 µs |
503
- | Warm `#eval("nil")` on a reused Sandbox | ~71 µs |
504
- | Warm `#run(:Entrypoint, ...)` dispatch | ~97 µs |
505
- | Service call amortized inside one invocation | ~6.8 µs |
506
- | Snippet replay per invocation | ~7.6 µs each |
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 17k `#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.
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.2 µs), because `=~` eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests.
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.13.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.13.0", path = "../kobako-transport" }
30
+ kobako-transport = { version = "0.14.0", path = "../kobako-transport" }
@@ -26,7 +26,7 @@ map the neutral types onto their own language surface.
26
26
 
27
27
  ```toml
28
28
  [dependencies]
29
- kobako-runtime = "0.13.0" # x-release-please-version
29
+ kobako-runtime = "0.14.0" # x-release-please-version
30
30
  ```
31
31
 
32
32
  ## License
@@ -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
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  [package]
14
14
  name = "kobako-transport"
15
- version = "0.13.0"
15
+ version = "0.14.0"
16
16
  edition = "2021"
17
17
  description = "The kobako core envelope — the fixed host↔guest wire frame every assembly shares."
18
18
  license = "Apache-2.0"
@@ -35,7 +35,7 @@ the golden vectors in this crate are derived from that document.
35
35
 
36
36
  ```toml
37
37
  [dependencies]
38
- kobako-transport = "0.13.0" # x-release-please-version
38
+ kobako-transport = "0.14.0" # x-release-please-version
39
39
  ```
40
40
 
41
41
  ## License
@@ -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 one of three reserved type names the guest maps to a proxy-side
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 closed category and a message —
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 of the three failures a Fault reports.
21
+ /// Which failure a Fault reports.
20
22
  ///
21
- /// A tag rather than a name because the set is closed: a category outside
22
- /// these three is unrepresentable, so no endpoint decides what an unknown
23
- /// one means. `Undefined` stays indistinguishable across its causes — an
24
- /// unbound path, an unknown method, a rejected name — so a guest probing
25
- /// the surface learns nothing from which refusal it got.
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
- fn from_tag(tag: u8) -> Result<Self, DecodeError> {
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 => Ok(FaultKind::Runtime),
48
- KIND_ARGUMENT => Ok(FaultKind::Argument),
49
- KIND_UNDEFINED => Ok(FaultKind::Undefined),
50
- _ => Err(DecodeError::new(
51
- "Fault kind must be 0 (runtime), 1 (argument), or 2 (undefined)",
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 the
66
- /// name is outside the closed set.
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 a_kind_outside_the_closed_set_is_refused() {
130
- let encoded = vec![3, 0, 0, 0, 0];
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
- assert!(
133
- Fault::read(&mut r).is_err(),
134
- "a Fault kind byte outside the three the contract fixes must be refused, not carried"
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 outside the closed set must not resolve to a kind"
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
- TAG_FAULT => {
36
- let fault = Fault::read(&mut reader)?;
37
- reader.finish()?;
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.13.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.13.0", path = "../kobako-runtime" }
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.13.0", path = "../kobako-transport" }
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 = "46.0.1", default-features = false, features = [
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 = "46.0.1", default-features = false, features = ["p1"] }
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.
@@ -24,7 +24,7 @@ SDK consumes the same surface.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- kobako-wasmtime = "0.13.0" # x-release-please-version
27
+ kobako-wasmtime = "0.14.0" # x-release-please-version
28
28
  ```
29
29
 
30
30
  ## License
@@ -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 + type checks
34
- // done once in `instance_pre::cached_instance_pre`). Every
35
- // invocation instantiates a fresh instance from it and discards the
36
- // whole Store afterwards — the per-invocation instance discipline.
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, and verify
45
- /// the artifact's ABI version. Every failure is a `SetupError` for
46
- /// the frontend to attribute — Engine and Module never leave the
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
- let instance_pre = instance_pre::cached_instance_pre(path)?;
50
- let driver = Self {
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 leaves only the
9
- //! `instantiate` call itself on the `Driver::new` hot path.
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 and resolving the Module's imports on a miss. Compilation
31
- /// faults surface through `cached_module`; import-resolution faults
32
- /// return a runtime-dead `SetupError` (boundary → `Kobako::SetupError`).
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")