kobako 0.21.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff585432220deec2b7a21f062f070e6b13c2d95529b8e636c70452bb377f24f1
4
- data.tar.gz: 63401be88c3d166e3ebca35106e5c4b8ca005490da41c4625a42cbeda0c04ae5
3
+ metadata.gz: 771a65c05a0d065db98f2644a978e4f82ab0cedb453f3cb7f6e61aea936be707
4
+ data.tar.gz: 330ab0d4762778e9608325be5b6fc737eeb82e0ff1b4d30eeac29ff8fac9902b
5
5
  SHA512:
6
- metadata.gz: 4bf8515bd5840ab2e8288e7a7f5942f50c402724f670efa570719f9be3181c7052044d2ab51f3fd42fffc4b61f7f269abaad7afa866b20f0a7b7f57d53a12bd9
7
- data.tar.gz: 52fe4c1ddedd100757b0fcdb952c662585d30e21003df98b5cc97bea22eb20545c530accd87d7f2d684aac9eb64339e4b147537719df89355b890daa346081b5
6
+ metadata.gz: 6fd6084f9566a7f9466f8b58f308914dae445c2ee43ed4cd1ccd721db01d609b99257f404e0ff00ae6e5fb3bbac45e453afc933eb1d3b23210df709ca9914962
7
+ data.tar.gz: c113d2ef16f413ad41c4d97cb3d5675d4f2ac151879257ffc9f3d918a27f471667d936c7fe13120d9a78e73ab6fee041700e3bed8f3315c9d680d742213014ac
@@ -1 +1 @@
1
- {".":"0.21.1","wasm/kobako-core":"0.13.1","wasm/kobako-mruby":"0.13.1","wasm/kobako-io":"0.13.1","wasm/kobako-json":"0.13.1","wasm/kobako-regexp":"0.13.1","wasm/kobako-baker":"0.13.1","crates/kobako-transport":"0.13.1","crates/kobako-codec":"0.13.1","crates/kobako-runtime":"0.13.1","crates/kobako-wasmtime":"0.13.1","crates/kobako":"0.13.1"}
1
+ {".":"0.22.0","wasm/kobako-core":"0.14.0","wasm/kobako-mruby":"0.14.0","wasm/kobako-io":"0.14.0","wasm/kobako-json":"0.14.0","wasm/kobako-regexp":"0.14.0","wasm/kobako-baker":"0.14.0","crates/kobako-transport":"0.14.0","crates/kobako-codec":"0.14.0","crates/kobako-runtime":"0.14.0","crates/kobako-wasmtime":"0.14.0","crates/kobako":"0.14.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.0](https://github.com/elct9620/kobako/compare/v0.21.1...v0.22.0) (2026-08-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **spec:** give the ubiquitous language a place N-6 can be checked from ([ab44dba](https://github.com/elct9620/kobako/commit/ab44dba0b08e558a5b96b95b3c4d1c70bf6bc7ad))
9
+ * **transport:** give a failed Service call a class that says why ([b39f176](https://github.com/elct9620/kobako/commit/b39f1761e9922b7ac761571393f9a6f4478ae522))
10
+ * **transport:** let a guest block's exception continue as itself ([c8b1de7](https://github.com/elct9620/kobako/commit/c8b1de7154336fab8fa20c0d7a5b3baaad11d551))
11
+ * **transport:** separate a failed exchange from a failed Service ([fb92515](https://github.com/elct9620/kobako/commit/fb92515fc76ffebbb8491b525d53b1725cf97dd0))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **codec:** keep a value the packer cannot walk inside the codec taxonomy ([ac99d58](https://github.com/elct9620/kobako/commit/ac99d58f0c9a274b230d645d7c37aba88516eab2))
17
+ * **regexp:** stop raising where MRI answers no match ([d229974](https://github.com/elct9620/kobako/commit/d2299740a2a0206c88e7c397cdb72dc730c4c1cf))
18
+ * **transport:** answer a held block failure only to the block that raised ([24dfd6e](https://github.com/elct9620/kobako/commit/24dfd6ec999e0ba1587b9c69da6d2b29563fc875))
19
+ * **transport:** report a yield the host cannot write as the Service's ([19459f8](https://github.com/elct9620/kobako/commit/19459f87555190043127290441bf6b95bcad93d6))
20
+ * **transport:** report an answer the host cannot write as the Service's ([bff4cdc](https://github.com/elct9620/kobako/commit/bff4cdccc076859d7da43f5dedb9a283ffed94ee))
21
+ * **transport:** spend a block's failure when its Service yields again ([3042a72](https://github.com/elct9620/kobako/commit/3042a72b9e3bb0d6a1d5cb5734d7eaee6a669e85))
22
+ * **wire:** let a Fault reader survive what it predates ([eeabfb7](https://github.com/elct9620/kobako/commit/eeabfb7aa4de3d08852ca1b41517b924d72a7312))
23
+
3
24
  ## [0.21.1](https://github.com/elct9620/kobako/compare/v0.21.0...v0.21.1) (2026-07-30)
4
25
 
5
26
 
data/Cargo.lock CHANGED
@@ -858,7 +858,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
858
858
 
859
859
  [[package]]
860
860
  name = "kobako"
861
- version = "0.21.1"
861
+ version = "0.22.0"
862
862
  dependencies = [
863
863
  "kobako-runtime",
864
864
  "kobako-transport",
@@ -869,18 +869,18 @@ dependencies = [
869
869
 
870
870
  [[package]]
871
871
  name = "kobako-runtime"
872
- version = "0.13.1"
872
+ version = "0.14.0"
873
873
  dependencies = [
874
874
  "kobako-transport",
875
875
  ]
876
876
 
877
877
  [[package]]
878
878
  name = "kobako-transport"
879
- version = "0.13.1"
879
+ version = "0.14.0"
880
880
 
881
881
  [[package]]
882
882
  name = "kobako-wasmtime"
883
- version = "0.13.1"
883
+ version = "0.14.0"
884
884
  dependencies = [
885
885
  "kobako-runtime",
886
886
  "kobako-transport",
data/README.md CHANGED
@@ -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,12 @@
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
+
3
10
  ## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.13.0...kobako-runtime-v0.13.1) (2026-07-30)
4
11
 
5
12
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  [package]
15
15
  name = "kobako-runtime"
16
- version = "0.13.1"
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.1", 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.1" # x-release-please-version
29
+ kobako-runtime = "0.14.0" # x-release-please-version
30
30
  ```
31
31
 
32
32
  ## License
@@ -1,5 +1,18 @@
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
+
3
16
  ## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-transport-v0.13.0...kobako-transport-v0.13.1) (2026-07-30)
4
17
 
5
18
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  [package]
14
14
  name = "kobako-transport"
15
- version = "0.13.1"
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.1" # 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,12 @@
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
+
3
10
  ## [0.13.1](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.13.0...kobako-wasmtime-v0.13.1) (2026-07-30)
4
11
 
5
12
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  [package]
17
17
  name = "kobako-wasmtime"
18
- version = "0.13.1"
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,10 +28,10 @@ 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.1", 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.1", 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`
@@ -24,7 +24,7 @@ SDK consumes the same surface.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- kobako-wasmtime = "0.13.1" # x-release-please-version
27
+ kobako-wasmtime = "0.14.0" # x-release-please-version
28
28
  ```
29
29
 
30
30
  ## License
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.21.1"
3
+ version = "0.22.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -30,10 +30,24 @@ module Kobako
30
30
  # rescue below maps the two violations the guard does not reach onto the
31
31
  # same error: an integer outside i64..u64 (+RangeError+) and any
32
32
  # packer-internal +NoMethodError+.
33
+ #
34
+ # A value that nests without bound — a reference cycle necessarily
35
+ # does — exhausts the packer's own recursion instead, which Ruby
36
+ # reports outside +StandardError+. Mapping it keeps an unwritable
37
+ # value a wire violation the dispatch boundary can answer, rather than
38
+ # one that escapes every caller's rescue and traps the invocation.
39
+ #
40
+ # The refusal is spent once per thread: a thread that has absorbed one
41
+ # such overflow aborts on the next instead of raising, and a Hash cycle
42
+ # never reaches Ruby at all — the packer walks a Hash through C frames
43
+ # that carry no stack guard. Bounding the walk before the packer is
44
+ # handed the value is what would make the refusal repeatable.
33
45
  def self.encode(value)
34
46
  FACTORY.dump(value)
35
47
  rescue ::RangeError, ::NoMethodError => e
36
48
  raise UnsupportedTypeError, e.message
49
+ rescue ::SystemStackError
50
+ raise InvalidTypeError, "value nests deeper than this host can write (a reference cycle necessarily does)"
37
51
  end
38
52
  end
39
53
  end
@@ -18,7 +18,8 @@ module Kobako
18
18
  class TruncatedInputError < Error; end
19
19
 
20
20
  # The type byte at the current position is not in the 11-entry kobako
21
- # type mapping (e.g. an unknown ext code, or a reserved msgpack tag).
21
+ # type mapping (e.g. an unknown ext code, or a reserved msgpack tag)
22
+ # or, on encode, a value nesting past what the packer can walk.
22
23
  class InvalidTypeError < Error; end
23
24
 
24
25
  # A msgpack +str+ payload was not valid UTF-8, or an ext 0x00 Symbol
data/lib/kobako/errors.rb CHANGED
@@ -116,12 +116,45 @@ module Kobako
116
116
 
117
117
  # Service layer. Raised when a Service capability call inside a mruby
118
118
  # script reported an application-level failure that the script did not
119
- # rescue.
119
+ # rescue. The base class covers a Service that ran and raised; the two
120
+ # subclasses below cover the calls that never reached one, so a Host App
121
+ # routes them apart with +rescue+ instead of by reading the message.
120
122
  class ServiceError < Error
121
123
  include Diagnosable
122
124
  include CarriesExecution
123
125
  end
124
126
 
127
+ # The ServiceError subclass raised when the call reached no Service
128
+ # method: the bound path holds nothing, the Capability Handle is not
129
+ # live in this invocation, or the method is absent or outside the guest
130
+ # surface. The causes stay indistinguishable — an opaque target must
131
+ # disclose nothing about which methods it defines — so what a Host App
132
+ # learns is that this call will not succeed by being retried.
133
+ class NoServiceError < ServiceError; end
134
+
135
+ # The ServiceError subclass raised when the call reached the Service
136
+ # method but its arguments did not fit — an unknown keyword, or an
137
+ # arity mismatch.
138
+ class ServiceArgumentError < ServiceError; end
139
+
140
+ # Raised at a Service method's +yield+ site when the guest block it
141
+ # yielded to raised. A Service rescues it the way it would rescue a
142
+ # block's exception without a Sandbox between the two frames; leaving it
143
+ # unrescued returns it to the guest, which re-raises the exception it
144
+ # raised in the first place. It therefore never reaches the Host App as
145
+ # an invocation outcome and carries no Execution. +klass+ names the
146
+ # guest-side class, which has no host counterpart to rebuild.
147
+ class BlockError < Error
148
+ include Diagnosable
149
+ end
150
+
151
+ # Raised at a Service method's +yield+ site when a yield argument has no
152
+ # wire representation, so the round-trip never left the host and the
153
+ # block never ran. As BlockError, a Service rescues it at that site and
154
+ # may go on, so it never reaches the Host App as an invocation outcome;
155
+ # unrescued, the dispatch answers as that Service having failed.
156
+ class YieldValueError < Error; end
157
+
125
158
  # HandleExhaustedError is the canonical SandboxError subclass for the
126
159
  # id-cap-hit path. Raised when the per-invocation Handle ID counter in
127
160
  # Catalog::Handles reaches +0x7fff_ffff+ (2³¹ − 1) and further
@@ -17,12 +17,16 @@ module Kobako
17
17
  ORIGIN_SANDBOX = "sandbox"
18
18
  ORIGIN_SERVICE = "service"
19
19
 
20
- # The guest-written class names that select a +SandboxError+ subclass.
21
- # A name absent here settles as plain +SandboxError+, so the guest
22
- # widens the taxonomy only by naming a class the host already defines.
20
+ # The guest-written class names that narrow the class a Panic settles
21
+ # as. A name absent here settles as the base class its origin already
22
+ # chose, so the guest widens the taxonomy only by naming a class the
23
+ # host already defines.
23
24
  SUBCLASSES = {
24
25
  "Kobako::BytecodeError" => BytecodeError,
25
- "Kobako::UndefinedEntrypointError" => UndefinedEntrypointError
26
+ "Kobako::UndefinedEntrypointError" => UndefinedEntrypointError,
27
+ "Kobako::Transport::Error" => Kobako::Transport::Error,
28
+ "Kobako::NoServiceError" => NoServiceError,
29
+ "Kobako::ServiceArgumentError" => ServiceArgumentError
26
30
  }.freeze
27
31
 
28
32
  module_function
@@ -52,13 +56,17 @@ module Kobako
52
56
  UndefinedEntrypointError.new(message, name: entrypoint, available: available.map(&:to_sym), **attribution)
53
57
  end
54
58
 
55
- # +origin == "service"+ selects ServiceError; a sandbox-origin failure
56
- # naming one of the guest-written subclass names selects that subclass
57
- # so callers can rescue that path specifically.
59
+ # +origin+ picks the branch, and the guest-written class name may
60
+ # narrow within it so callers can rescue one path specifically. A name
61
+ # naming a class outside the branch its origin chose is ignored rather
62
+ # than honoured: what a guest calls its exception must not move the
63
+ # failure to a layer the attribution did not put it in.
58
64
  def error_class(origin, klass)
59
- return ServiceError if origin == ORIGIN_SERVICE
65
+ base = origin == ORIGIN_SERVICE ? ServiceError : SandboxError
66
+ selected = SUBCLASSES.fetch(klass, base)
67
+ return base unless selected <= base
60
68
 
61
- SUBCLASSES.fetch(klass, SandboxError)
69
+ selected
62
70
  end
63
71
 
64
72
  # An arm the host cannot settle: the guest wrote nothing, or wrote
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../codec"
4
+ require_relative "../errors"
4
5
  require_relative "../payload"
5
6
  require_relative "call"
6
7
  require_relative "reflection"
@@ -36,6 +37,29 @@ module Kobako
36
37
  # not part of the public Kobako error taxonomy.
37
38
  class UndefinedTargetError < StandardError; end
38
39
 
40
+ # The codec fault of a request that never became a call, restated in
41
+ # the vocabulary the guest can act on while staying inside the codec
42
+ # taxonomy the fault boundary sorts on.
43
+ class UnreadableRequestError < Kobako::Codec::Error; end
44
+
45
+ # The category kobako's own refusals answer under, keyed by the class
46
+ # each is raised as and ordered most specific first. A class absent
47
+ # here is the Service's own exception, which answers under +runtime+
48
+ # wearing the +<class>: <message>+ shape that says so.
49
+ #
50
+ # +Kobako::Codec::Error+ is the floor rather than a path of its own:
51
+ # a codec fault reaching the boundary unnamed is the exchange failing,
52
+ # and must not be dressed as something a Service raised.
53
+ OWN_FAULTS = {
54
+ HandleExhaustedError => "internal",
55
+ UndefinedTargetError => "undefined",
56
+ ArgumentError => "argument",
57
+ YieldValueError => "runtime",
58
+ Kobako::Codec::Error => "internal",
59
+ Kobako::SandboxError => "runtime"
60
+ }.freeze
61
+ private_constant :OWN_FAULTS
62
+
39
63
  # Answer a single routed Call with +[ok, bytes]+, which the native
40
64
  # side puts on the Reply's ok or fault arm. Invoked from the
41
65
  # per-invocation dispatch Proc that
@@ -61,7 +85,7 @@ module Kobako
61
85
  # stays uncaught and traps the invocation rather than being masked as a
62
86
  # rescuable fault.
63
87
  rescue StandardError => e
64
- [false, *caught_fault(e)] # : [bool, String, String?]
88
+ [false, *caught_fault(e, yielder)] # : [bool, String, String?]
65
89
  ensure
66
90
  yielder&.invalidate!
67
91
  end
@@ -70,12 +94,23 @@ module Kobako
70
94
  # the +catch+ frame a guest +break+ unwinds to. Split from #dispatch
71
95
  # so the reply-shaping and the failure boundary stay one glance wide.
72
96
  def run(call, resolver, handler, yielder)
73
- arguments, carried_handle = Kobako::Codec.track_handles { Payload::Arguments.decode(call.payload) }
97
+ arguments, carried_handle = decode_arguments(call.payload)
74
98
  receiver = resolve_target(call.target, resolver, handler)
75
99
  args, kwargs = resolve_call_args(arguments, handler, carried_handle)
76
100
  catch(BREAK_THROW) { invoke(receiver, call.method_name, args, kwargs, yielder) }
77
101
  end
78
102
 
103
+ # Decode the Call's payload into its arguments, reporting whether any
104
+ # Capability Handle crossed. A codec fault here is a request that
105
+ # never became a call, restated so it cannot read as an unwritable
106
+ # reply — the same restatement #encode_ok makes in the other
107
+ # direction.
108
+ def decode_arguments(payload)
109
+ Kobako::Codec.track_handles { Payload::Arguments.decode(payload) }
110
+ rescue Kobako::Codec::Error => e
111
+ raise UnreadableRequestError, "Sandbox could not read the request: #{e.message}"
112
+ end
113
+
79
114
  # Resolve positional and keyword arguments off the decoded payload in
80
115
  # one step. +carried_handle+ reports whether the decode carried any
81
116
  # Capability Handle; when it did not, every argument resolves to
@@ -95,14 +130,24 @@ module Kobako
95
130
  # is the +StandardError+ caught by #dispatch's rescue; the category
96
131
  # tells the guest which kind of failure it was so it can raise the
97
132
  # matching proxy-side error.
98
- def caught_fault(error)
99
- case error
100
- when Kobako::Codec::Error then fault("runtime",
101
- "Sandbox received a malformed request: #{error.message}")
102
- when UndefinedTargetError then fault("undefined", error.message)
103
- when ArgumentError then fault("argument", error.message)
104
- else fault("runtime", "#{error.class}: #{error.message}")
105
- end
133
+ #
134
+ # The class prefix marks a Service's own exception and nothing else:
135
+ # it is the +<class>: <message>+ shape a Host App is told to keep
136
+ # secrets out of, so wearing it says the Service raised. kobako's own
137
+ # refusals answer under their own wording instead of borrowing that
138
+ # shape.
139
+ #
140
+ # The guest's own block failing is not the Service's to report at
141
+ # all, so the Yielder that raised it is asked first — it recognises
142
+ # its own by identity and words the failure the guest's way.
143
+ def caught_fault(error, yielder)
144
+ block_failure = yielder&.fault_text(error)
145
+ return fault("block", block_failure) if block_failure
146
+
147
+ own = OWN_FAULTS.find { |klass, _| error.is_a?(klass) }
148
+ return fault(own.last, error.message) if own
149
+
150
+ fault("runtime", "#{error.class}: #{error.message}")
106
151
  end
107
152
 
108
153
  # Dispatch +method+ on +target+. +kwargs+ is already Symbol-keyed
@@ -182,10 +227,18 @@ module Kobako
182
227
  # +UnsupportedTypeError+; the rescue routes it through the
183
228
  # Catalog::Handles via #wrap_as_handle and re-encodes with the
184
229
  # Capability Handle in place. The happy path encodes exactly once.
230
+ #
231
+ # Any other codec fault is the answer failing to encode rather than the
232
+ # request failing to decode, and a Service is the only side that can
233
+ # change what it returns — so it is named here, where the direction is
234
+ # known, instead of falling to the boundary's codec floor and reporting
235
+ # as an exchange that produced no Service outcome.
185
236
  def encode_ok(value, handler)
186
237
  Kobako::Codec::Encoder.encode(value)
187
238
  rescue Kobako::Codec::UnsupportedTypeError
188
239
  encode_ok(wrap_as_handle(value, handler), handler)
240
+ rescue Kobako::Codec::Error => e
241
+ raise Kobako::SandboxError, "Sandbox could not write the Service's answer: #{e.message}"
189
242
  end
190
243
 
191
244
  # Allocate +value+ in the Sandbox's Catalog::Handles and return a +Handle+
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../codec"
4
+ require_relative "../errors"
4
5
 
5
6
  module Kobako
6
7
  # See lib/kobako/transport.rb for the umbrella module doc; this file
@@ -20,8 +21,8 @@ module Kobako
20
21
  # * ok — return the decoded value to +yield+'s caller
21
22
  # * break — +throw break_tag, value+ so the Dispatcher's +catch+
22
23
  # frame unwinds the Service method
23
- # * error — raise the guest's class and message at the Service's
24
- # yield site
24
+ # * error — raise a BlockError at the Service's yield site,
25
+ # carrying the guest's class on +#klass+
25
26
  #
26
27
  # The Dispatcher calls #invalidate! from its +ensure+ block once
27
28
  # dispatch completes; any later call to a stashed Yielder then raises
@@ -40,6 +41,23 @@ module Kobako
40
41
  @break_tag = break_tag
41
42
  @handler = handler
42
43
  @active = true
44
+ @raised = nil
45
+ end
46
+
47
+ # The text the guest's own block failure crosses back as, or +nil+
48
+ # when +error+ is not the BlockError this Yielder raised. Identity
49
+ # rather than class: a Service that rescued the block's failure and
50
+ # raised its own has reported something else, and the guest must
51
+ # hear about that instead.
52
+ #
53
+ # The class travels with the message because not every block failure
54
+ # is an exception the guest holds — a block value the guest refused
55
+ # has a class to raise under and no object to continue.
56
+ def fault_text(error)
57
+ raised = @raised
58
+ return if raised.nil? || !error.equal?(raised)
59
+
60
+ "#{raised.klass}: #{raised.message}"
43
61
  end
44
62
 
45
63
  # Re-enter the guest with +args+ and reify the Yield Reply into
@@ -52,8 +70,8 @@ module Kobako
52
70
  def yield(*args)
53
71
  raise LocalJumpError, "guest block invoked after host dispatch frame returned" unless @active
54
72
 
55
- arm, body, klass = @yield_to_guest.call(Kobako::Codec::Encoder.encode(args))
56
- raise "#{klass}: #{body}" if arm == :error
73
+ arm, body, klass = @yield_to_guest.call(encode_args(args))
74
+ raise remember(BlockError.new(body, klass: klass)) if arm == :error
57
75
 
58
76
  value, carried_handle = decode_body(body)
59
77
  throw @break_tag, value if arm == :break
@@ -76,6 +94,24 @@ module Kobako
76
94
 
77
95
  private
78
96
 
97
+ # Encode what the Service is yielding. A value outside the wire type
98
+ # set fails here, before the guest is re-entered, and is restated so
99
+ # the Service reads a refusal of its own argument rather than a codec
100
+ # class it never named.
101
+ def encode_args(args)
102
+ Kobako::Codec::Encoder.encode(args)
103
+ rescue Kobako::Codec::Error => e
104
+ raise YieldValueError, "Service yielded a value the block cannot receive: #{e.message}"
105
+ end
106
+
107
+ # Hold onto the BlockError being raised so #fault_text can recognise
108
+ # it if it comes back unrescued, and return it so the raise site reads
109
+ # as one statement. Only the newest is kept: a Service that rescued
110
+ # an earlier one and yielded again has already handled it.
111
+ def remember(error)
112
+ @raised = error
113
+ end
114
+
79
115
  # Decode a value-carrying arm's payload, answering the value and
80
116
  # whether the decode carried a Capability Handle. The tracking
81
117
  # bracket opens only around this decode: the guest re-entry may run
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.21.1"
4
+ VERSION = "0.22.0"
5
5
  end
@@ -47,6 +47,19 @@ module Kobako
47
47
  include CarriesExecution
48
48
  end
49
49
 
50
+ class NoServiceError < ServiceError
51
+ end
52
+
53
+ class ServiceArgumentError < ServiceError
54
+ end
55
+
56
+ class BlockError < Error
57
+ include Diagnosable
58
+ end
59
+
60
+ class YieldValueError < Error
61
+ end
62
+
50
63
  class HandleExhaustedError < SandboxError
51
64
  end
52
65
 
@@ -2,7 +2,7 @@ module Kobako
2
2
  module Outcome
3
3
  ORIGIN_SANDBOX: String
4
4
  ORIGIN_SERVICE: String
5
- SUBCLASSES: Hash[String, singleton(SandboxError)]
5
+ SUBCLASSES: Hash[String, (singleton(SandboxError) | singleton(ServiceError))]
6
6
 
7
7
  type panic_fields = [String, String, String, Array[String], Array[String]]
8
8
 
@@ -4,8 +4,13 @@ module Kobako
4
4
  class UndefinedTargetError < StandardError
5
5
  end
6
6
 
7
+ class UnreadableRequestError < Kobako::Codec::Error
8
+ end
9
+
7
10
  BREAK_THROW: Symbol
8
11
 
12
+ OWN_FAULTS: Hash[singleton(StandardError), String]
13
+
9
14
 
10
15
 
11
16
 
@@ -15,7 +20,9 @@ module Kobako
15
20
 
16
21
  def self?.resolve_call_args: (Kobako::Payload::Arguments arguments, Kobako::Codec::_HandleTable handler, bool carried_handle) -> [Array[untyped], Hash[Symbol, untyped]]
17
22
 
18
- def self?.caught_fault: (StandardError error) -> [String, String]
23
+ def self?.decode_arguments: (String payload) -> [Kobako::Payload::Arguments, bool]
24
+
25
+ def self?.caught_fault: (StandardError error, Kobako::Transport::Yielder? yielder) -> [String, String]
19
26
 
20
27
  def self?.invoke: (untyped target, String method, Array[untyped] args, Hash[Symbol, untyped] kwargs, ?Kobako::Transport::Yielder? yielder) -> untyped
21
28
 
@@ -5,9 +5,12 @@ module Kobako
5
5
  @break_tag: Symbol
6
6
  @handler: Kobako::Codec::_HandleTable
7
7
  @active: bool
8
+ @raised: Kobako::BlockError?
8
9
 
9
10
  def initialize: (Kobako::Transport::_GuestYielder yield_to_guest, Symbol break_tag, Kobako::Codec::_HandleTable handler) -> void
10
11
 
12
+ def fault_text: (Exception error) -> String?
13
+
11
14
  def yield: (*untyped args) -> untyped
12
15
 
13
16
  def to_proc: () -> Proc
@@ -16,6 +19,10 @@ module Kobako
16
19
 
17
20
  private
18
21
 
22
+ def encode_args: (Array[untyped] args) -> String
23
+
24
+ def remember: (Kobako::BlockError error) -> Kobako::BlockError
25
+
19
26
  def decode_body: (String body) -> [untyped, bool]
20
27
 
21
28
  def restore: (untyped value, bool carried_handle) -> untyped
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kobako
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.1
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aotokitsuruya