kobako 0.26.0 → 0.28.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +103 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +156 -118
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +38 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +10 -12
  11. data/crates/kobako-runtime/src/error.rs +8 -9
  12. data/crates/kobako-runtime/src/lib.rs +1 -1
  13. data/crates/kobako-runtime/src/profile.rs +1 -1
  14. data/crates/kobako-runtime/src/runtime.rs +31 -17
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -10
  16. data/crates/kobako-runtime/src/yielder.rs +9 -6
  17. data/crates/kobako-transport/CHANGELOG.md +35 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +43 -27
  20. data/crates/kobako-transport/src/abi.rs +11 -14
  21. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  22. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  23. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  24. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  25. data/crates/kobako-transport/src/envelope/reply.rs +77 -53
  26. data/crates/kobako-transport/src/envelope/request.rs +295 -0
  27. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  28. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  29. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  30. data/crates/kobako-transport/src/envelope.rs +19 -12
  31. data/crates/kobako-transport/src/lib.rs +0 -2
  32. data/crates/kobako-wasmtime/CHANGELOG.md +51 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +8 -10
  35. data/crates/kobako-wasmtime/src/abi.rs +5 -8
  36. data/crates/kobako-wasmtime/src/ambient.rs +2 -2
  37. data/crates/kobako-wasmtime/src/cache.rs +25 -59
  38. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  39. data/crates/kobako-wasmtime/src/config.rs +11 -14
  40. data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
  41. data/crates/kobako-wasmtime/src/driver.rs +133 -86
  42. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  43. data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
  44. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
  45. data/crates/kobako-wasmtime/src/invocation.rs +55 -372
  46. data/crates/kobako-wasmtime/src/io.rs +286 -0
  47. data/crates/kobako-wasmtime/src/lib.rs +3 -24
  48. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  49. data/crates/kobako-wasmtime/src/trap.rs +32 -45
  50. data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
  51. data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
  52. data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
  53. data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
  54. data/data/kobako.wasm +0 -0
  55. data/ext/kobako/Cargo.toml +2 -2
  56. data/ext/kobako/src/runtime/bridge.rs +38 -47
  57. data/ext/kobako/src/runtime/errors.rs +26 -32
  58. data/ext/kobako/src/runtime/gvl.rs +4 -12
  59. data/ext/kobako/src/runtime.rs +169 -212
  60. data/lib/kobako/capture.rb +1 -4
  61. data/lib/kobako/catalog/extensions.rb +21 -59
  62. data/lib/kobako/catalog/flags.rb +41 -0
  63. data/lib/kobako/catalog/handles.rb +7 -54
  64. data/lib/kobako/catalog/services.rb +7 -61
  65. data/lib/kobako/catalog/snippets.rb +42 -66
  66. data/lib/kobako/catalog.rb +2 -10
  67. data/lib/kobako/codec/decoder.rb +9 -28
  68. data/lib/kobako/codec/encoder.rb +3 -22
  69. data/lib/kobako/codec/error.rb +6 -27
  70. data/lib/kobako/codec/ext_types.rb +72 -94
  71. data/lib/kobako/codec/handle_walk.rb +65 -130
  72. data/lib/kobako/codec/nesting.rb +3 -8
  73. data/lib/kobako/codec/state.rb +4 -10
  74. data/lib/kobako/codec/utils.rb +6 -34
  75. data/lib/kobako/codec.rb +3 -22
  76. data/lib/kobako/constant_name.rb +8 -0
  77. data/lib/kobako/context.rb +51 -112
  78. data/lib/kobako/errors.rb +51 -127
  79. data/lib/kobako/execution.rb +16 -21
  80. data/lib/kobako/extension.rb +23 -40
  81. data/lib/kobako/handle.rb +7 -12
  82. data/lib/kobako/outcome.rb +51 -80
  83. data/lib/kobako/payload/arguments.rb +1 -16
  84. data/lib/kobako/payload.rb +4 -12
  85. data/lib/kobako/pool.rb +23 -53
  86. data/lib/kobako/runtime.rb +7 -21
  87. data/lib/kobako/sandbox.rb +96 -133
  88. data/lib/kobako/sandbox_options.rb +38 -84
  89. data/lib/kobako/snippet/binary.rb +1 -16
  90. data/lib/kobako/snippet/source.rb +1 -16
  91. data/lib/kobako/snippet.rb +1 -9
  92. data/lib/kobako/transport/call.rb +1 -14
  93. data/lib/kobako/transport/dispatcher.rb +112 -195
  94. data/lib/kobako/transport/error.rb +6 -20
  95. data/lib/kobako/transport/exposure.rb +5 -10
  96. data/lib/kobako/transport/reflection.rb +39 -50
  97. data/lib/kobako/transport/run.rb +17 -71
  98. data/lib/kobako/transport/yielder.rb +21 -73
  99. data/lib/kobako/transport.rb +3 -15
  100. data/lib/kobako/unresolved.rb +4 -11
  101. data/lib/kobako/usage.rb +2 -6
  102. data/lib/kobako/version.rb +1 -1
  103. data/sig/kobako/catalog/extensions.rbs +2 -2
  104. data/sig/kobako/catalog/flags.rbs +17 -0
  105. data/sig/kobako/catalog/services.rbs +0 -6
  106. data/sig/kobako/catalog/snippets.rbs +4 -4
  107. data/sig/kobako/codec/ext_types.rbs +10 -8
  108. data/sig/kobako/codec/handle_walk.rbs +9 -7
  109. data/sig/kobako/codec/utils.rbs +2 -2
  110. data/sig/kobako/codec.rbs +2 -2
  111. data/sig/kobako/constant_name.rbs +3 -0
  112. data/sig/kobako/context.rbs +2 -3
  113. data/sig/kobako/errors.rbs +6 -0
  114. data/sig/kobako/outcome.rbs +6 -8
  115. data/sig/kobako/runtime.rbs +12 -8
  116. data/sig/kobako/sandbox.rbs +4 -0
  117. data/sig/kobako/sandbox_options.rbs +2 -6
  118. data/sig/kobako/transport/dispatcher.rbs +14 -18
  119. data/sig/kobako/transport/reflection.rbs +5 -3
  120. data/sig/kobako/transport/run.rbs +1 -3
  121. data/sig/kobako/transport/yielder.rbs +3 -3
  122. data/sig/kobako/transport.rbs +7 -6
  123. metadata +16 -4
  124. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  125. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  126. data/rust-toolchain.toml +0 -11
@@ -0,0 +1,105 @@
1
+ //! A receiver saying it does not serve the operation it was sent.
2
+ //!
3
+ //! It is an answer rather than a breakage, so it rides every operation's
4
+ //! answer as its own arm and the caller decides whether to fall back or
5
+ //! give up. It names no operation: the caller knows what it sent.
6
+
7
+ use super::bytes::{Reader, Writer};
8
+ use super::{tag, DecodeError};
9
+
10
+ /// The only kind defined. A kind this reader predates reads as this one,
11
+ /// so a later kind still delivers its message.
12
+ const KIND_UNSUPPORTED: u8 = 0;
13
+
14
+ /// The refusal a receiver answers with when it does not serve an
15
+ /// operation.
16
+ #[derive(Debug, Clone, PartialEq, Eq)]
17
+ pub struct Unsupported {
18
+ pub message: String,
19
+ }
20
+
21
+ impl Unsupported {
22
+ pub fn new(message: impl Into<String>) -> Self {
23
+ Unsupported {
24
+ message: message.into(),
25
+ }
26
+ }
27
+
28
+ /// This refusal as a whole answer. Every operation's answer spells
29
+ /// the arm the same way, so a receiver refusing a tag it cannot
30
+ /// read needs no answer type to refuse it in.
31
+ pub fn encode(&self) -> Vec<u8> {
32
+ let mut writer = Writer::new();
33
+ writer
34
+ .u8(tag::UNSUPPORTED)
35
+ .u8(KIND_UNSUPPORTED)
36
+ .bytes(self.message.as_bytes());
37
+ writer.into_bytes()
38
+ }
39
+
40
+ pub(crate) fn read(reader: &mut Reader<'_>) -> Result<Self, DecodeError> {
41
+ reader.u8()?;
42
+ let message = reader.text()?.to_owned();
43
+ // The last thing its answer carries, so whatever follows belongs
44
+ // to a later version of these fields, as for a Fault.
45
+ reader.skip_unknown_fields();
46
+ Ok(Unsupported { message })
47
+ }
48
+ }
49
+
50
+ #[cfg(test)]
51
+ mod tests {
52
+ use super::super::{CompileReply, Outcome, Reply, YieldReply};
53
+ use super::*;
54
+
55
+ // @behavior WE-069
56
+ #[test]
57
+ fn an_unsupported_answer_round_trips_on_every_operation() {
58
+ let refusal = Unsupported::new("not served");
59
+ assert_eq!(
60
+ refusal.encode(),
61
+ Reply::Unsupported(refusal.clone()).encode(),
62
+ "an Unsupported encoded on its own must be the same answer every operation carries"
63
+ );
64
+ assert_eq!(
65
+ (
66
+ Reply::decode(&Reply::Unsupported(refusal.clone()).encode()),
67
+ YieldReply::decode(&YieldReply::Unsupported(refusal.clone()).encode()),
68
+ CompileReply::decode(&CompileReply::Unsupported(refusal.clone()).encode()),
69
+ Outcome::decode(&Outcome::Unsupported(refusal.clone()).encode()),
70
+ ),
71
+ (
72
+ Ok(Reply::Unsupported(refusal.clone())),
73
+ Ok(YieldReply::Unsupported(refusal.clone())),
74
+ Ok(CompileReply::Unsupported(refusal.clone())),
75
+ Ok(Outcome::Unsupported(refusal)),
76
+ ),
77
+ "an Unsupported through each answer's encode and decode must arrive as it was \
78
+ given"
79
+ );
80
+ }
81
+
82
+ // @behavior WE-070
83
+ #[test]
84
+ fn a_kind_this_reader_predates_still_delivers_its_message() {
85
+ let bytes = [0x15, 9, 0, 0, 0, 1, b'm'];
86
+ assert_eq!(
87
+ Reply::decode(&bytes),
88
+ Ok(Reply::Unsupported(Unsupported::new("m"))),
89
+ "an Unsupported of an unknown kind through Reply::decode must read as the one \
90
+ kind defined, its message intact"
91
+ );
92
+ }
93
+
94
+ // @behavior WE-090
95
+ #[test]
96
+ fn a_field_this_reader_predates_is_skipped() {
97
+ let bytes = [0x15, 0, 0, 0, 0, 1, b'm', 0xff];
98
+ assert_eq!(
99
+ Reply::decode(&bytes),
100
+ Ok(Reply::Unsupported(Unsupported::new("m"))),
101
+ "an Unsupported carrying a trailing field through Reply::decode must skip it, \
102
+ its message intact"
103
+ );
104
+ }
105
+ }
@@ -6,40 +6,47 @@
6
6
  //! tier that only routes messages therefore needs no payload codec at all,
7
7
  //! and the one an assembly does use is its own choice.
8
8
  //!
9
- //! A decoded envelope borrows the buffer it came from, so a payload reaches
10
- //! its reader as a view rather than a copy. The two fields a reader keeps
11
- //! past that buffer's life — a binding's path and a snippet's body, read
12
- //! from a frame at boot and consumed later — are copied at decode.
9
+ //! A decoded Call or Upcall borrows the buffer it came from, so a dispatched
10
+ //! payload reaches its reader as a view rather than a copy. Every other
11
+ //! message copies what it holds at decode.
13
12
  //!
14
- //! No type here is `#[non_exhaustive]`. Every arm is a tag byte the layout
15
- //! document fixes, so the set grows only with an ABI version — and when it
13
+ //! Every answer is exhaustive. Each arm is a tag byte the layout document
14
+ //! fixes, so an answer's set grows only with an ABI version — and when it
16
15
  //! does, a peer that has not been taught the new arm should fail to
17
- //! compile rather than fall into a wildcard and answer a message it cannot
18
- //! read.
16
+ //! compile rather than fall into a wildcard and answer a message it
17
+ //! cannot read. Three kinds of type are the exception, because a reader
18
+ //! answers what it predates instead of refusing it: a request reads an
19
+ //! unassigned tag as unknown, a Fault's category degrades, and a
20
+ //! Preamble skips an advisory record of a kind it does not know.
19
21
  //!
20
22
  //! This layer has one implementation, so its `golden_layout_*` tests are
21
23
  //! what hold it to the layout document. They spell each tag as the literal
22
24
  //! byte that document fixes, never as the constant beside it: a golden
23
25
  //! written from the constant compares the implementation to itself.
24
- //!
25
- //! [core envelope]: ../../../docs/wire/envelope.md
26
26
 
27
27
  pub(crate) mod bytes;
28
28
  pub mod call;
29
+ pub mod compile_reply;
29
30
  pub mod error_record;
30
31
  pub mod fault;
31
- pub mod invocation_frames;
32
32
  pub mod outcome;
33
+ pub mod preamble;
33
34
  pub mod reply;
35
+ pub mod request;
34
36
  pub mod run;
37
+ pub(crate) mod tag;
38
+ pub mod unsupported;
35
39
 
36
40
  pub use call::{Call, Target};
41
+ pub use compile_reply::CompileReply;
37
42
  pub use error_record::ErrorRecord;
38
43
  pub use fault::{Fault, FaultKind};
39
- pub use invocation_frames::{Bindings, Snippet, Snippets};
40
44
  pub use outcome::{Origin, Outcome, Panic};
45
+ pub use preamble::{Bindings, Flag, Preamble, Snippet, Snippets};
41
46
  pub use reply::{Reply, YieldReply};
47
+ pub use request::{Downcall, UnknownTag, Upcall};
42
48
  pub use run::Run;
49
+ pub use unsupported::Unsupported;
43
50
 
44
51
  use std::fmt;
45
52
 
@@ -14,8 +14,6 @@
14
14
  //! interpreter, or a schema — and nothing may come to: a tier every other
15
15
  //! tier composes against cannot carry a choice any of them might want to
16
16
  //! make differently.
17
- //!
18
- //! [core envelope]: ../../../docs/wire/envelope.md
19
17
 
20
18
  pub mod abi;
21
19
  pub mod envelope;
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.17.0...kobako-wasmtime-v0.18.0) (2026-10-13)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * the kobako and kobako-wasmtime crates declare a minimum supported Rust version of 1.96.
9
+ * **transport:** the stdin layout a Guest Binary reads changed within ABI 4. kobako_transport::envelope::Frames is replaced by Preamble, which is #[non_exhaustive] and built from Default; Bindings and Snippets no longer have their own decode and encode. Guest::eval and Guest::run, MrbGuest::eval and MrbGuest::run, and Runtime::invoke take a Preamble in place of Frames. kobako_core re-exports Preamble and Flag in place of Frames.
10
+ * **transport:** the ABI version is 4, so a Guest Binary built for ABI 3 is refused at Sandbox construction. kobako_core::export_guest! emits __kobako_downcall in place of __kobako_eval, __kobako_run, __kobako_yield_to_block and __kobako_take_outcome, and the host import is __kobako_upcall. kobako_core::abi::{take_outcome, write_outcome, write_reply, decode_run} are replaced by serve and answer, and the frames module is no longer public. Reply, YieldReply and Outcome gain an Unsupported arm and take their tags from the shared registry, and kobako_core::DispatchError and kobako::YieldError gain an Unsupported arm. A yield to a guest without block support raises Kobako::UnsupportedError at the Service's yield instead of failing the invocation as a trap.
11
+ * **runtime:** Runtime::invoke takes &kobako_transport::envelope::Frames in place of kobako_runtime::Frames, whose byte fields are gone. Guest::eval and Guest::run (and their MrbGuest flows) receive the decoded Frames, eval also its source; kobako_core::frames::read_frame is replaced by read_eval_frames and read_frames.
12
+ * **runtime:** kobako_runtime::snapshot::Completion::Outcome holds a decoded kobako_transport::envelope::Outcome instead of Vec<u8>; an engine decodes the result itself and reports an unframed one as
13
+ * **runtime:** kobako_runtime::runtime::Entry::Run holds { entrypoint: &str, payload: Vec<u8> } instead of { envelope: &[u8] }; a frontend passes the fields, and an engine encodes the Run envelope itself.
14
+ * **runtime:** kobako_runtime::yielder::Yielder::yield_to_block returns Result<YieldReply, Trap> instead of the encoded bytes; an engine implementing it decodes the reply itself.
15
+ * a memory budget or deadline reached during a Service answer or a yielded block now raises Kobako::MemoryLimitError or
16
+
17
+ ### Features
18
+
19
+ * **guest:** raise Kobako::UnsupportedError when a host does not serve a call ([db003af](https://github.com/elct9620/kobako/commit/db003af75cf2d11b5f644986aa77c0408f5f59ba)), closes [#73](https://github.com/elct9620/kobako/issues/73)
20
+ * **runtime:** compile a Preamble's snippets on an instance of their own ([303432d](https://github.com/elct9620/kobako/commit/303432dd90ed62dc378391515e68e784e1b473a4)), closes [#75](https://github.com/elct9620/kobako/issues/75)
21
+ * **transport:** carry each operation as a tag on one channel per direction ([c7d574b](https://github.com/elct9620/kobako/commit/c7d574b59a42e58632f2bdc46cc9a8e4c7ac6121)), closes [#73](https://github.com/elct9620/kobako/issues/73)
22
+ * **transport:** keep an unknown request's body, and refuse tag 0x00 ([edb64df](https://github.com/elct9620/kobako/commit/edb64df8f87694d17c5b6fd71ac63329023306ec)), closes [#73](https://github.com/elct9620/kobako/issues/73)
23
+ * **transport:** set an invocation up from a Preamble of tagged records ([84d4498](https://github.com/elct9620/kobako/commit/84d4498464f94d7312f26931ddd4ec43894a6654))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * end the invocation as the trap a host callback into the guest hits ([b8f47d3](https://github.com/elct9620/kobako/commit/b8f47d3699a19adcfd5596af93136401a286c761))
29
+ * keep a compile's bytecode only when the Preamble still fits it ([765db08](https://github.com/elct9620/kobako/commit/765db08ad31c8f5163794c7e144355f3237af93b))
30
+ * **wasmtime:** keep a compile answer only when it covers every source snippet ([93e9ddd](https://github.com/elct9620/kobako/commit/93e9ddd79f714446ae713a3145c59d3d2f4145dc))
31
+ * **wasmtime:** tell every frontend how to recover, not only the gem ([cab805a](https://github.com/elct9620/kobako/commit/cab805ab62441a0ca2c78775b7a0d7ce9cc05c43))
32
+ * **wasmtime:** word a missing or mistyped export one way ([8ab0aee](https://github.com/elct9620/kobako/commit/8ab0aeec8bda2960b6cab0502ef51860ea9093b8))
33
+
34
+
35
+ ### Code Refactoring
36
+
37
+ * **runtime:** hand a frontend the decoded Outcome ([0da6aac](https://github.com/elct9620/kobako/commit/0da6aac0f7943d694a5a4ab9439b2d6c527ae1b0)), closes [#73](https://github.com/elct9620/kobako/issues/73)
38
+ * **runtime:** hand a frontend the decoded Yield Reply ([e80ab54](https://github.com/elct9620/kobako/commit/e80ab54e36c2a80b0f746c675f85bd1238fd4331)), closes [#73](https://github.com/elct9620/kobako/issues/73)
39
+ * **runtime:** let the ABI endpoints frame and read the Frames ([dea069a](https://github.com/elct9620/kobako/commit/dea069ac49a59c12a6f7102ed3cf2925f316cbff)), closes [#73](https://github.com/elct9620/kobako/issues/73)
40
+ * **runtime:** let the engine frame a run's entrypoint and payload ([62f9c94](https://github.com/elct9620/kobako/commit/62f9c9407ff3ce690723625bea075fa49d2dda47)), closes [#73](https://github.com/elct9620/kobako/issues/73)
41
+
42
+
43
+ ### Build System
44
+
45
+ * declare the Rust 1.96 floor wasmtime 49 sets ([f49a2df](https://github.com/elct9620/kobako/commit/f49a2df88fa51d3b0eecb619cab1e98d79bb4245))
46
+
47
+ ## [0.17.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.16.0...kobako-wasmtime-v0.17.0) (2026-09-20)
48
+
49
+
50
+ ### Miscellaneous Chores
51
+
52
+ * **kobako-wasmtime:** Synchronize kobako crates versions
53
+
3
54
  ## [0.16.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.15.0...kobako-wasmtime-v0.16.0) (2026-09-15)
4
55
 
5
56
 
@@ -15,8 +15,10 @@
15
15
 
16
16
  [package]
17
17
  name = "kobako-wasmtime"
18
- version = "0.16.0"
18
+ version = "0.18.0"
19
19
  edition = "2021"
20
+ # wasmtime's own floor.
21
+ rust-version = "1.96"
20
22
  description = "wasmtime implementation of the kobako host runtime contract."
21
23
  license = "Apache-2.0"
22
24
  repository = "https://github.com/elct9620/kobako"
@@ -28,17 +30,17 @@ categories = ["wasm", "virtualization"]
28
30
  # The engine-neutral contract this crate implements. The version pin
29
31
  # rides the linked release group; the path keeps in-tree builds (and
30
32
  # the Ruby gem, which ships both crates) resolving locally.
31
- kobako-runtime = { version = "0.16.0", path = "../kobako-runtime" }
33
+ kobako-runtime = { version = "0.18.0", path = "../kobako-runtime" }
32
34
  # The fixed tier: the core envelope this driver shuttles and the ABI
33
35
  # values it drives the exports against.
34
- kobako-transport = { version = "0.16.0", path = "../kobako-transport" }
36
+ kobako-transport = { version = "0.18.0", path = "../kobako-transport" }
35
37
  # wasmtime — host-side embedder for kobako.wasm. We disable default-features
36
38
  # and opt back in only what kobako needs: a Cranelift-backed runtime that can
37
39
  # compile a pre-built wasm32-wasip1 module on the host triple, plus the `wat`
38
40
  # feature so test fixtures can be expressed as text.
39
41
  # `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
40
42
  # are intentionally off — kobako runs short-lived synchronous sandboxes.
41
- wasmtime = { version = "48.0.1", default-features = false, features = [
43
+ wasmtime = { version = "49.0.2", default-features = false, features = [
42
44
  "cranelift",
43
45
  "runtime",
44
46
  "gc",
@@ -52,7 +54,7 @@ wasmtime = { version = "48.0.1", default-features = false, features = [
52
54
  # WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
53
55
  # `p2` (component-model) and `p0`/`p3` (async) because kobako runs
54
56
  # synchronous sandboxes only.
55
- wasmtime-wasi = { version = "48.0.1", default-features = false, features = ["p1"] }
57
+ wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p1"] }
56
58
  # sha2 keys the on-disk compiled-module cache by Guest Binary content
57
59
  # (see cache.rs); a collision would load the wrong artifact, so the
58
60
  # hash must be cryptographic.
@@ -6,16 +6,14 @@ The [wasmtime](https://wasmtime.dev) implementation of the
6
6
 
7
7
  `Driver` implements the contract's `Runtime` trait over wasmtime and
8
8
  owns every engine-bound mechanic, so frontends see only the neutral
9
- contract surface:
9
+ contract surface.
10
10
 
11
- - process-wide Engine and compiled-Module caches with an on-disk AOT
12
- (`.cwasm`) artifact cache keyed by Guest Binary content
13
- - a pre-linked `InstancePre` per guest path; every invocation runs on
14
- a fresh instance and discards its Store afterwards
15
- - the epoch-based wall-clock timeout and the per-invocation
16
- linear-memory cap
17
- - ambient denial: frozen WASI clocks and a constant RNG, so a guest
18
- observes no real time and no real entropy
11
+ | Mechanic | What the driver holds |
12
+ |---|---|
13
+ | Compilation | process-wide Engine and compiled-Module caches, with an on-disk AOT (`.cwasm`) artifact cache keyed by Guest Binary content |
14
+ | Instantiation | a pre-linked `InstancePre` per guest path; every invocation runs on a fresh instance and discards its Store afterwards |
15
+ | Bounds | the epoch-based wall-clock timeout and the per-invocation linear-memory cap |
16
+ | Ambient denial | under the default hermetic profile, frozen clocks and a constant RNG |
19
17
 
20
18
  The kobako Ruby gem's native ext is the first frontend; a Rust host
21
19
  SDK consumes the same surface.
@@ -24,7 +22,7 @@ SDK consumes the same surface.
24
22
 
25
23
  ```toml
26
24
  [dependencies]
27
- kobako-wasmtime = "0.16.0" # x-release-please-version
25
+ kobako-wasmtime = "0.18.0" # x-release-please-version
28
26
  ```
29
27
 
30
28
  ## License
@@ -10,7 +10,7 @@ use wasmtime::{AsContextMut, InstancePre, Store as WtStore};
10
10
  use crate::cache::shared_engine;
11
11
  use crate::config::Config;
12
12
  use crate::invocation::Invocation;
13
- use crate::{frames, trap};
13
+ use crate::{io, trap};
14
14
  use kobako_runtime::error::SetupError;
15
15
  use kobako_runtime::profile::Profile;
16
16
  use kobako_transport::abi::ABI_VERSION;
@@ -29,19 +29,16 @@ const PROBE_CONFIG: Config = Config {
29
29
  profile: Profile::Hermetic,
30
30
  };
31
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
32
+ /// An absent or unequal version is a deterministic artifact fault, so
33
+ /// every failure is a `SetupError`. The probe's own WASI context keeps a
34
+ /// guest whose start section touches WASI on that path instead of
38
35
  /// panicking in `Invocation::wasi_mut`.
39
36
  pub(crate) fn verify(pre: &InstancePre<Invocation>) -> Result<(), SetupError> {
40
37
  let mut store = WtStore::new(shared_engine()?, Invocation::new(None));
41
38
  // Epoch interruption is on engine-wide and a fresh Store's deadline
42
39
  // has already elapsed, so the probe must name one it cannot reach.
43
40
  store.set_epoch_deadline(trap::NO_TIMEOUT_EPOCH_DELTA);
44
- frames::install_wasi_frames(&mut store, &PROBE_CONFIG, &[])
41
+ io::install_wasi(&mut store, &PROBE_CONFIG, &[])
45
42
  .map_err(|t| SetupError::Dead(t.to_string()))?;
46
43
  let instance = pre
47
44
  .instantiate(store.as_context_mut())
@@ -1,6 +1,6 @@
1
1
  //! Denial of guest ambient authority at the WASI layer — the one grant
2
2
  //! that separates the hermetic rung from permissive;
3
- //! `frames::install_wasi_frames` wires these sources on `Hermetic` only.
3
+ //! `io::install_wasi` wires these sources on `Hermetic` only.
4
4
  //!
5
5
  //! `wasmtime-wasi`'s `WasiCtxBuilder` defaults the guest's `wasi:clocks` to
6
6
  //! the host wall / monotonic clock and `wasi:random` to a fresh per-context
@@ -8,7 +8,7 @@
8
8
  //! (`build_config/wasi.rb`), but a future libc-backed gem would silently
9
9
  //! obtain real time and host entropy — a covert timing channel and a
10
10
  //! nondeterminism source the hermetic posture deliberately excludes
11
- //! (docs/security-model.md).
11
+ //! (docs/guides/security.md).
12
12
  //! Pinning the clocks to the Unix epoch and the RNG to a constant stream
13
13
  //! makes that denial a property of the host, not merely of the gem allowlist.
14
14
  //!
@@ -1,8 +1,8 @@
1
1
  //! Process-wide caches for the wasmtime `Engine` and compiled
2
2
  //! `Module`, plus the on-disk compiled-artifact cache.
3
3
  //!
4
- //! SPEC.md "Code Organization" forbids exposing wasm engine types to
5
- //! the Host App or downstream gems. To amortise Engine creation and
4
+ //! Wasm engine types stay hidden from the Host App and downstream
5
+ //! gems. To amortise Engine creation and
6
6
  //! Module JIT compilation across multiple sandbox constructions, the
7
7
  //! driver keeps a process-scope shared Engine and a per-path Module
8
8
  //! cache. Both are transparent to frontends, which construct a
@@ -13,10 +13,10 @@
13
13
  //! Binary bytes; every cache failure falls
14
14
  //! back to in-process compilation.
15
15
  //!
16
- //! Concurrency: under Ruby's GVL only one thread can execute Rust code
17
- //! at a time, so the Mutex is held briefly during HashMap insert/lookup
18
- //! and serves to satisfy `Sync` bounds rather than to arbitrate real
19
- //! contention.
16
+ //! Concurrency: the Mutex is held only for the HashMap lookup and insert,
17
+ //! never across a compile, so threads building Drivers at once do not
18
+ //! wait on each other. Two concurrent misses on one path both compile,
19
+ //! and the later insert wins with an equivalent Module.
20
20
 
21
21
  use std::collections::HashMap;
22
22
  use std::fmt::Write as _;
@@ -43,21 +43,10 @@ static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new
43
43
  /// 10 ms across the whole process).
44
44
  const EPOCH_TICK: Duration = Duration::from_millis(10);
45
45
 
46
- /// Return the process-wide wasmtime Engine, building it on first call.
47
- ///
48
- /// Enables the wasm exceptions proposal so `kobako.wasm` (which uses
49
- /// `try_table` / `exnref` / `tag` for mruby's setjmp-via-new-EH path)
50
- /// can be loaded. The mruby wasi build config uses
51
- /// `-mllvm -wasm-use-legacy-eh=false`, which generates new-style
52
- /// exception handling instructions in the wasm32 object files;
53
- /// wasmtime must have the proposal enabled to parse and JIT those
54
- /// instructions.
55
- ///
56
- /// Also enables `epoch_interruption(true)` so every Store can install an
57
- /// `epoch_deadline_callback` for the per-run wall-clock cap
58
- /// cap. The first call spawns the process-singleton ticker
59
- /// thread that drives `engine.increment_epoch()` at `EPOCH_TICK`
60
- /// cadence; subsequent calls reuse the same engine and ticker.
46
+ /// The exceptions proposal is on because the mruby wasi build emits
47
+ /// new-style exception handling (`-mllvm -wasm-use-legacy-eh=false`) for
48
+ /// its setjmp path. Epoch interruption backs the wall-clock cap, driven by
49
+ /// one process-wide ticker thread.
61
50
  pub(crate) fn shared_engine() -> Result<&'static WtEngine, SetupError> {
62
51
  if let Some(engine) = SHARED_ENGINE.get() {
63
52
  return Ok(engine);
@@ -72,11 +61,6 @@ pub(crate) fn shared_engine() -> Result<&'static WtEngine, SetupError> {
72
61
  Ok(engine)
73
62
  }
74
63
 
75
- /// Spawn the process-singleton epoch ticker. The thread holds a clone of
76
- /// the shared Engine (`wasmtime::Engine` is reference-counted internally)
77
- /// and ticks the epoch counter at `EPOCH_TICK` cadence. Idempotent
78
- /// across reentrant calls to `shared_engine` because `OnceLock`
79
- /// gates the spawn.
80
64
  fn spawn_epoch_ticker(engine: WtEngine) {
81
65
  static TICKER_SPAWNED: OnceLock<()> = OnceLock::new();
82
66
  TICKER_SPAWNED.get_or_init(|| {
@@ -90,11 +74,8 @@ fn spawn_epoch_ticker(engine: WtEngine) {
90
74
  });
91
75
  }
92
76
 
93
- /// Look up `path` in the per-path Module cache, compiling and inserting
94
- /// the artifact on a miss. Returns `SetupError::ModuleNotBuilt`
95
- /// (boundary → `Kobako::ModuleNotBuiltError`) when the file is missing —
96
- /// the headline error for the common pre-build state on a fresh clone
97
- /// before `rake compile`.
77
+ /// A missing file answers `SetupError::ModuleNotBuilt`, the common state of
78
+ /// a fresh clone before `rake compile`.
98
79
  pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
99
80
  let cache = MODULE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
100
81
 
@@ -109,7 +90,7 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
109
90
 
110
91
  if !path.exists() {
111
92
  return Err(SetupError::ModuleNotBuilt(format!(
112
- "Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
93
+ "Sandbox runtime not found at {}",
113
94
  path.display()
114
95
  )));
115
96
  }
@@ -145,17 +126,11 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
145
126
  /// whole window are removed by `prune_stale`.
146
127
  const ARTIFACT_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
147
128
 
148
- /// Compute the disk-cache location for a Guest Binary's compiled
149
- /// artifact: `$XDG_CACHE_HOME/kobako` (falling back to
150
- /// `~/.cache/kobako`) `/<sha256 of the wasm bytes>-<crate version>.cwasm`.
151
- /// Content addressing makes a rebuilt Guest Binary a new cache entry
152
- /// rather than an invalidation problem; the crate-version segment keeps
153
- /// two installed kobako-wasmtime versions (each pinning its own
154
- /// wasmtime) from sharing a key and recompile-thrashing each other's
155
- /// entry. wasmtime
156
- /// itself rejects an artifact produced by an incompatible wasmtime
157
- /// version or Config at deserialize time. Returns `None` when no home
158
- /// directory is available — the caller then just compiles in-process.
129
+ /// Content addressing makes a rebuilt Guest Binary a new entry rather than
130
+ /// an invalidation problem; the crate-version segment keeps two installed
131
+ /// kobako-wasmtime versions, each pinning its own wasmtime, from thrashing
132
+ /// one key. wasmtime itself rejects an artifact from an incompatible
133
+ /// version or Config at deserialize time.
159
134
  fn artifact_path(wasm_bytes: &[u8]) -> Option<PathBuf> {
160
135
  let base = std::env::var_os("XDG_CACHE_HOME")
161
136
  .map(PathBuf::from)
@@ -169,11 +144,8 @@ fn artifact_path(wasm_bytes: &[u8]) -> Option<PathBuf> {
169
144
  Some(base.join("kobako").join(name))
170
145
  }
171
146
 
172
- /// Best-effort load of a previously serialized compiled artifact.
173
- /// Any failure — absent file, truncated bytes, wasmtime version or
174
- /// Config mismatch — returns `None` and the caller recompiles. A hit
175
- /// refreshes the file's mtime so `prune_stale`'s retention window
176
- /// measures time since last use, not since creation.
147
+ /// A hit refreshes the file's mtime, so the retention window measures time
148
+ /// since last use rather than since creation.
177
149
  fn load_artifact(engine: &WtEngine, artifact: &Path) -> Option<WtModule> {
178
150
  if !artifact.exists() || !artifact.parent().is_some_and(dir_is_private) {
179
151
  return None;
@@ -192,11 +164,9 @@ fn load_artifact(engine: &WtEngine, artifact: &Path) -> Option<WtModule> {
192
164
  Some(module)
193
165
  }
194
166
 
195
- /// Best-effort write of a freshly compiled artifact. The temp-file +
196
- /// rename pair keeps concurrent processes from observing a partial
197
- /// write; every failure is swallowed because the cache is purely an
198
- /// optimisation. A successful write also triggers `prune_stale` so the
199
- /// cache directory cannot grow without bound across Guest Binary
167
+ /// The temp-file and rename pair keeps concurrent processes from observing
168
+ /// a partial write. Failures are swallowed because the cache is only an
169
+ /// optimisation, and pruning on write keeps the directory bounded across
200
170
  /// rebuilds.
201
171
  fn store_artifact(module: &WtModule, artifact: &Path) {
202
172
  let Ok(bytes) = module.serialize() else {
@@ -252,10 +222,8 @@ fn dir_is_private(_dir: &Path) -> bool {
252
222
  true
253
223
  }
254
224
 
255
- /// Remove every cache entry (`.cwasm` artifacts and crash-leftover
256
- /// `.tmp*` files) whose mtime sits past `ARTIFACT_TTL`, except the
257
- /// just-written `keep`. Live temp files are seconds old and never
258
- /// qualify; foreign file names are left untouched.
225
+ /// Live temp files are seconds old and never qualify; foreign file names
226
+ /// are left untouched.
259
227
  fn prune_stale(dir: &Path, keep: &Path) {
260
228
  let Ok(entries) = fs::read_dir(dir) else {
261
229
  return;
@@ -277,8 +245,6 @@ fn prune_stale(dir: &Path, keep: &Path) {
277
245
  }
278
246
  }
279
247
 
280
- /// Returns whether `path` carries a file name this cache wrote — a
281
- /// `.cwasm` artifact or a `.tmp*` leftover.
282
248
  fn cache_entry_name(path: &Path) -> bool {
283
249
  let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
284
250
  return false;
@@ -7,10 +7,8 @@
7
7
  //! channel) so a regression that only breaks one channel cannot sneak
8
8
  //! through the test that pins them.
9
9
 
10
- /// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
11
- /// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
12
- /// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
13
- /// `usize::MAX` when the channel is uncapped.
10
+ /// One byte past the cap, so "wrote exactly cap" and "exceeded cap" stay
11
+ /// distinguishable.
14
12
  pub(crate) fn pipe_capacity(cap: Option<usize>) -> usize {
15
13
  match cap {
16
14
  Some(c) => c.saturating_add(1),
@@ -18,13 +16,8 @@ pub(crate) fn pipe_capacity(cap: Option<usize>) -> usize {
18
16
  }
19
17
  }
20
18
 
21
- /// Pure clipping core shared by the snapshot readback: given the
22
- /// unclipped pipe snapshot (owned — truncated in place, so the readback
23
- /// costs one copy out of the pipe, not two), return the bytes Ruby
24
- /// should observe plus the truncation flag. `truncated` is `true` only
25
- /// when the snapshot strictly exceeded the cap — this is the "wrote
26
- /// `cap + 1` bytes into a `cap + 1`-sized pipe" case; "wrote exactly
27
- /// `cap` bytes" stays `false`.
19
+ /// Takes the snapshot by value and truncates in place, so the readback
20
+ /// costs one copy out of the pipe rather than two.
28
21
  pub(crate) fn clip_capture(mut raw: Vec<u8>, cap: Option<usize>) -> (Vec<u8>, bool) {
29
22
  match cap {
30
23
  Some(c) if raw.len() > c => {
@@ -18,23 +18,20 @@ use kobako_runtime::profile::Profile;
18
18
  /// The caps and requested isolation profile for one `Driver`. `None` on
19
19
  /// any cap field disables that cap.
20
20
  pub struct Config {
21
- /// Wall-clock cap for one guest `#eval` / `#run`. Stamped into a
22
- /// per-run `Instant` deadline by `Driver::prime_caps`.
21
+ /// Wall-clock cap for one guest `#eval` / `#run`; the invocation traps
22
+ /// once it passes.
23
23
  pub timeout: Option<Duration>,
24
- /// Guest linear-memory cap, in bytes. Threaded into each fresh
25
- /// `Invocation`, where the wasmtime `ResourceLimiter` callback
26
- /// consumes it from inside the engine.
24
+ /// Guest linear-memory cap, in bytes, charged to what one invocation
25
+ /// grows beyond the image it starts from.
27
26
  pub memory_limit: Option<usize>,
28
- /// Byte cap for guest stdout capture.
29
- /// Sizes the per-run `MemoryOutputPipe` and computes the truncation
30
- /// flag in `Driver::build_snapshot`.
27
+ /// Byte cap for guest stdout capture; output past it is dropped and
28
+ /// the capture marked truncated.
31
29
  pub stdout_limit: Option<usize>,
32
- /// Byte cap for guest stderr capture. Mirror of `stdout_limit`.
30
+ /// Byte cap for guest stderr capture, the same as `stdout_limit`.
33
31
  pub stderr_limit: Option<usize>,
34
- /// Isolation posture the frontend requested. The per-invocation
35
- /// WASI context is built to this rung — `Hermetic` freezes ambient
36
- /// time and entropy (`crate::ambient`), `Permissive` leaves the
37
- /// live WASI sources — and `Driver::profile` declares it back as
38
- /// the built posture.
32
+ /// Isolation posture the frontend requested. `Hermetic` freezes
33
+ /// ambient time and entropy, `Permissive` leaves the live WASI
34
+ /// sources, and `Driver::profile` declares it back as the built
35
+ /// posture.
39
36
  pub profile: Profile,
40
37
  }