kobako 0.28.0 → 0.29.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +170 -0
  4. data/Cargo.lock +111 -82
  5. data/README.md +154 -36
  6. data/ROADMAP.md +5 -0
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +86 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +13 -8
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  13. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  14. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  15. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  18. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  19. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  20. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  21. data/crates/kobako-runtime/src/grant.rs +20 -0
  22. data/crates/kobako-runtime/src/lib.rs +11 -1
  23. data/crates/kobako-runtime/src/mount.rs +178 -0
  24. data/crates/kobako-runtime/src/profile.rs +4 -4
  25. data/crates/kobako-runtime/src/runtime.rs +12 -0
  26. data/crates/kobako-runtime/src/snapshot.rs +20 -5
  27. data/crates/kobako-runtime/src/yielder.rs +11 -6
  28. data/crates/kobako-transport/CHANGELOG.md +17 -0
  29. data/crates/kobako-transport/Cargo.toml +1 -1
  30. data/crates/kobako-transport/README.md +1 -1
  31. data/crates/kobako-transport/src/abi.rs +1 -1
  32. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  33. data/crates/kobako-transport/src/envelope/preamble.rs +12 -12
  34. data/crates/kobako-transport/src/envelope/reply.rs +32 -14
  35. data/crates/kobako-transport/src/envelope/request.rs +18 -8
  36. data/crates/kobako-wasmtime/CHANGELOG.md +119 -0
  37. data/crates/kobako-wasmtime/Cargo.toml +21 -16
  38. data/crates/kobako-wasmtime/README.md +5 -3
  39. data/crates/kobako-wasmtime/src/abi.rs +66 -32
  40. data/crates/kobako-wasmtime/src/ambient.rs +63 -4
  41. data/crates/kobako-wasmtime/src/cache.rs +185 -74
  42. data/crates/kobako-wasmtime/src/config.rs +58 -1
  43. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  44. data/crates/kobako-wasmtime/src/dispatch.rs +47 -29
  45. data/crates/kobako-wasmtime/src/driver.rs +46 -44
  46. data/crates/kobako-wasmtime/src/guest_mem.rs +13 -7
  47. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  48. data/crates/kobako-wasmtime/src/instance_pre.rs +17 -45
  49. data/crates/kobako-wasmtime/src/invocation.rs +35 -46
  50. data/crates/kobako-wasmtime/src/io.rs +5 -202
  51. data/crates/kobako-wasmtime/src/lib.rs +4 -0
  52. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  53. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  54. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  55. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  56. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  57. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  58. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  59. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  60. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  61. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  62. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  63. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  64. data/crates/kobako-wasmtime/src/trap.rs +39 -19
  65. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  66. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  67. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  68. data/crates/kobako-wasmtime/tests/preamble_flags.rs +3 -2
  69. data/crates/kobako-wasmtime/tests/snippet_compile.rs +4 -3
  70. data/crates/kobako-wasmtime/tests/support/mod.rs +9 -13
  71. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  72. data/crates/kobako-wasmtime/tests/unsupported_call.rs +1 -0
  73. data/data/kobako.wasm +0 -0
  74. data/ext/kobako/Cargo.toml +4 -2
  75. data/ext/kobako/src/runtime/bridge.rs +154 -81
  76. data/ext/kobako/src/runtime/escape.rs +53 -0
  77. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  78. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  79. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  80. data/ext/kobako/src/runtime/gvl.rs +7 -1
  81. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  82. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  83. data/ext/kobako/src/runtime.rs +120 -199
  84. data/lib/kobako/catalog/flags.rb +2 -14
  85. data/lib/kobako/catalog/grants.rb +30 -0
  86. data/lib/kobako/catalog/mount.rb +27 -0
  87. data/lib/kobako/catalog/services.rb +11 -0
  88. data/lib/kobako/catalog/snippets.rb +5 -9
  89. data/lib/kobako/catalog.rb +11 -0
  90. data/lib/kobako/codec/decoder.rb +1 -0
  91. data/lib/kobako/codec/ext_types.rb +1 -1
  92. data/lib/kobako/codec/state.rb +19 -3
  93. data/lib/kobako/codec/value_budget.rb +86 -0
  94. data/lib/kobako/codec.rb +7 -0
  95. data/lib/kobako/context.rb +38 -11
  96. data/lib/kobako/errors.rb +15 -14
  97. data/lib/kobako/filesystem.rb +38 -0
  98. data/lib/kobako/pool.rb +2 -2
  99. data/lib/kobako/sandbox.rb +40 -12
  100. data/lib/kobako/sandbox_options.rb +39 -12
  101. data/lib/kobako/snippet/{binary.rb → bytecode.rb} +1 -1
  102. data/lib/kobako/snippet.rb +1 -1
  103. data/lib/kobako/transport/block_failure.rb +35 -0
  104. data/lib/kobako/transport/dispatcher.rb +30 -26
  105. data/lib/kobako/transport/exposure.rb +5 -7
  106. data/lib/kobako/transport/reflection.rb +79 -23
  107. data/lib/kobako/transport/yielder.rb +8 -30
  108. data/lib/kobako/transport.rb +1 -0
  109. data/lib/kobako/version.rb +1 -1
  110. data/lib/kobako.rb +1 -0
  111. data/sig/kobako/catalog/flags.rbs +0 -4
  112. data/sig/kobako/catalog/grants.rbs +13 -0
  113. data/sig/kobako/catalog/mount.rbs +28 -0
  114. data/sig/kobako/catalog/services.rbs +4 -0
  115. data/sig/kobako/catalog/snippets.rbs +1 -1
  116. data/sig/kobako/catalog.rbs +1 -0
  117. data/sig/kobako/codec/state.rbs +7 -1
  118. data/sig/kobako/codec/value_budget.rbs +23 -0
  119. data/sig/kobako/codec.rbs +2 -0
  120. data/sig/kobako/context.rbs +14 -6
  121. data/sig/kobako/errors.rbs +4 -4
  122. data/sig/kobako/filesystem.rbs +88 -0
  123. data/sig/kobako/guest_yielder.rbs +9 -0
  124. data/sig/kobako/pool.rbs +1 -1
  125. data/sig/kobako/runtime.rbs +21 -49
  126. data/sig/kobako/sandbox.rbs +7 -1
  127. data/sig/kobako/sandbox_options.rbs +24 -2
  128. data/sig/kobako/snapshot.rbs +20 -0
  129. data/sig/kobako/snippet/bytecode.rbs +12 -0
  130. data/sig/kobako/transport/block_failure.rbs +13 -0
  131. data/sig/kobako/transport/dispatcher.rbs +3 -3
  132. data/sig/kobako/transport/exposure.rbs +1 -1
  133. data/sig/kobako/transport/reflection.rbs +14 -1
  134. data/sig/kobako/transport/yielder.rbs +3 -7
  135. data/sig/kobako/transport.rbs +2 -2
  136. metadata +60 -7
  137. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -0,0 +1,178 @@
1
+ //! A Mount: the Grant handing a Filesystem to the guest at an absolute
2
+ //! guest path, with the access it allows there. The Mount decides each
3
+ //! open and each change itself, so a runtime asks it rather than reading
4
+ //! its access, and a Filesystem of a third party's checks nothing.
5
+
6
+ use std::fmt;
7
+ use std::sync::Arc;
8
+
9
+ use crate::filesystem::{Access, Filesystem};
10
+
11
+ /// The Host App's decision for one path a guest opens or changes, made in
12
+ /// a Mount's access's place.
13
+ pub trait Policy: Send + Sync {
14
+ /// Whether the guest may reach `path`, the absolute guest path, with
15
+ /// `access`.
16
+ fn allows(&self, path: &str, access: Access) -> bool;
17
+ }
18
+
19
+ /// Why a Mount refused a call.
20
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
21
+ pub enum Denial {
22
+ /// A change beneath a Mount allowing only reads.
23
+ ReadOnly,
24
+ /// The Mount's Policy said no.
25
+ Refused,
26
+ }
27
+
28
+ /// A Filesystem at an absolute guest path. Its path bounds what a Policy
29
+ /// can allow either way.
30
+ #[derive(Clone)]
31
+ pub struct Mount {
32
+ at: String,
33
+ access: Access,
34
+ filesystem: Arc<dyn Filesystem>,
35
+ policy: Option<Arc<dyn Policy>>,
36
+ }
37
+
38
+ /// A path no Mount can stand at: one that is not an absolute guest path.
39
+ #[derive(Clone, Debug, PartialEq, Eq)]
40
+ pub struct MountPathError(String);
41
+
42
+ impl fmt::Display for MountPathError {
43
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44
+ write!(
45
+ f,
46
+ "mount path must be an absolute guest path, got {:?}",
47
+ self.0
48
+ )
49
+ }
50
+ }
51
+
52
+ impl std::error::Error for MountPathError {}
53
+
54
+ /// An absolute guest path has no empty, `.`, or `..` component; the root
55
+ /// is `/` alone.
56
+ fn is_guest_path(at: &str) -> bool {
57
+ at == "/"
58
+ || at.strip_prefix('/').is_some_and(|rest| {
59
+ rest.split('/')
60
+ .all(|part| !part.is_empty() && part != "." && part != "..")
61
+ })
62
+ }
63
+
64
+ impl Mount {
65
+ /// A Mount of `filesystem` at `at`, refused unless `at` is an absolute
66
+ /// guest path, which every path the Mount answers for is built on.
67
+ pub fn new(
68
+ at: impl Into<String>,
69
+ filesystem: Arc<dyn Filesystem>,
70
+ access: Access,
71
+ ) -> Result<Self, MountPathError> {
72
+ let at = at.into();
73
+ if !is_guest_path(&at) {
74
+ return Err(MountPathError(at));
75
+ }
76
+ Ok(Self {
77
+ at,
78
+ access,
79
+ filesystem,
80
+ policy: None,
81
+ })
82
+ }
83
+
84
+ /// This Mount with `policy` deciding each open and each change in its
85
+ /// access's place.
86
+ pub fn with_policy(self, policy: Arc<dyn Policy>) -> Self {
87
+ Self {
88
+ policy: Some(policy),
89
+ ..self
90
+ }
91
+ }
92
+
93
+ /// The absolute guest path the Filesystem appears at.
94
+ pub fn at(&self) -> &str {
95
+ &self.at
96
+ }
97
+
98
+ /// The Filesystem an engine hands each call beneath this Mount.
99
+ pub fn filesystem(&self) -> &Arc<dyn Filesystem> {
100
+ &self.filesystem
101
+ }
102
+
103
+ /// Whether a change beneath this Mount may be allowed at all: it was made
104
+ /// to be written, or a Policy decides each change in its access's place.
105
+ /// The Policy itself is not asked.
106
+ pub fn allows_writes(&self) -> bool {
107
+ self.policy.is_some() || self.access == Access::ReadWrite
108
+ }
109
+
110
+ /// The absolute guest path `path`, relative to this Mount's root,
111
+ /// stands for.
112
+ pub fn guest_path(&self, path: &str) -> String {
113
+ match (self.at.as_str(), path) {
114
+ (at, "") => at.to_string(),
115
+ ("/", path) => format!("/{path}"),
116
+ (at, path) => format!("{at}/{path}"),
117
+ }
118
+ }
119
+
120
+ /// Whether the guest may reach `path`, relative to this Mount's root,
121
+ /// with `access`. A Policy judges the path as the Filesystem stores
122
+ /// its names, so a host folding case or Unicode form cannot hand it
123
+ /// one spelling of an entry it refuses by another; a Filesystem that
124
+ /// cannot spell it refuses.
125
+ pub fn check(&self, path: &str, access: Access) -> Result<(), Denial> {
126
+ match &self.policy {
127
+ Some(policy) => {
128
+ let stored = self
129
+ .filesystem
130
+ .stored_path(path)
131
+ .map_err(|_| Denial::Refused)?;
132
+ if policy.allows(&self.guest_path(&stored), access) {
133
+ Ok(())
134
+ } else {
135
+ Err(Denial::Refused)
136
+ }
137
+ }
138
+ None if access == Access::ReadWrite && self.access == Access::Read => {
139
+ Err(Denial::ReadOnly)
140
+ }
141
+ None => Ok(()),
142
+ }
143
+ }
144
+
145
+ /// Whether a directory may move beneath this Mount. Under a Policy it
146
+ /// may not: everything beneath it would take paths the Policy never
147
+ /// judged, so a move is refused as one across devices and the guest
148
+ /// copies entry by entry instead, as Landlock refuses such a move.
149
+ pub fn moves_directories(&self) -> bool {
150
+ self.policy.is_none()
151
+ }
152
+
153
+ /// Whether a path beneath this Mount may pass through a link the
154
+ /// Filesystem holds. Under a Policy it may not: the link leads to a
155
+ /// path the Policy never judged, so the call is refused as one past
156
+ /// too many links.
157
+ pub fn follows_links(&self) -> bool {
158
+ self.policy.is_none()
159
+ }
160
+
161
+ /// This Mount as a new Invocation starts from it, over the Filesystem
162
+ /// that Invocation receives; beneath a Policy, the one that follows no
163
+ /// link, so each call reaches the path the Policy judged.
164
+ pub fn for_invocation(&self) -> Self {
165
+ let filesystem = self
166
+ .filesystem
167
+ .for_invocation()
168
+ .unwrap_or_else(|| self.filesystem.clone());
169
+ let filesystem = match self.policy {
170
+ Some(_) => filesystem.for_policy().unwrap_or(filesystem),
171
+ None => filesystem,
172
+ };
173
+ Self {
174
+ filesystem,
175
+ ..self.clone()
176
+ }
177
+ }
178
+ }
@@ -9,10 +9,10 @@
9
9
  /// The ordered isolation ladder a runtime builds one rung of.
10
10
  ///
11
11
  /// `Hermetic` is the full ambient-denial posture: ambient time and
12
- /// entropy denied at the WASI boundary, no filesystem / environment /
13
- /// network reachability, and no host import beyond the wire ABI's
14
- /// `__kobako_upcall`. `Permissive` differs in exactly one grant —
15
- /// live ambient time and entropy at the WASI boundary. Ordering
12
+ /// entropy denied at the WASI boundary, no ambient filesystem /
13
+ /// environment / network reachability, and no host import beyond the wire ABI's
14
+ /// `__kobako_upcall`. `Permissive` differs only in the ambient
15
+ /// authority it grants — live time and entropy at the WASI boundary. Ordering
16
16
  /// follows strength (`Permissive < Hermetic`), so a floor check is a
17
17
  /// plain comparison.
18
18
  ///
@@ -13,6 +13,7 @@ use kobako_transport::envelope::Preamble;
13
13
 
14
14
  use crate::dispatch::DispatchHandler;
15
15
  use crate::error::InvokeError;
16
+ use crate::grant::{Grant, GrantKind};
16
17
  use crate::profile::Profile;
17
18
  use crate::snapshot::Snapshot;
18
19
 
@@ -49,13 +50,24 @@ pub enum Entry<'a> {
49
50
  /// wrapper and GC-marking it; the runtime itself touches no frontend
50
51
  /// value.
51
52
  pub trait Runtime {
53
+ /// `grants` are this Invocation's, each of a kind `supports` declared;
54
+ /// the runtime presents them to the guest and keeps none afterwards.
52
55
  fn invoke(
53
56
  &self,
54
57
  entry: Entry<'_>,
55
58
  preamble: &Preamble,
56
59
  handler: Option<Arc<dyn DispatchHandler>>,
60
+ grants: &[Grant],
57
61
  ) -> Result<Snapshot, InvokeError>;
58
62
 
63
+ /// Whether this runtime presents a Grant of `kind` to the guest. A
64
+ /// frontend refuses to register one it does not, before any guest runs.
65
+ /// The default supports none, so an engine that has not learned Grants
66
+ /// still serves every invocation without one.
67
+ fn supports(&self, _kind: GrantKind) -> bool {
68
+ false
69
+ }
70
+
59
71
  /// The isolation profile this runtime provides. Deliberately
60
72
  /// without a default: every engine states its posture explicitly,
61
73
  /// and a frontend refuses construction when the declaration falls
@@ -18,6 +18,7 @@ use kobako_transport::envelope::Outcome;
18
18
  /// pre-invocation sentinel — no bytes, cap not reached — shared by every
19
19
  /// frontend's before-first-invocation readout.
20
20
  #[derive(Default)]
21
+ #[non_exhaustive]
21
22
  pub struct Capture {
22
23
  pub bytes: Vec<u8>,
23
24
  pub truncated: bool,
@@ -39,12 +40,13 @@ pub enum Completion {
39
40
  Trap(Trap),
40
41
  }
41
42
 
42
- /// Resource usage of one guest invocation, measured across the same
43
- /// bracket as the wall-clock / memory caps: `wall_time` is the seconds
44
- /// spent inside the guest export call; `memory_peak` is the high-water
45
- /// `memory.grow` delta in bytes past the entry-time baseline. `Default`
46
- /// (all zeros) is the pre-invocation sentinel.
43
+ /// Resource usage of one guest invocation, each measured across the span
44
+ /// its cap governs: `wall_time` is the seconds from setting the guest up to
45
+ /// its export call returning, the span the deadline covers; `memory_peak`
46
+ /// is the high-water `memory.grow` delta in bytes past the entry-time
47
+ /// baseline. `Default` (all zeros) is the pre-invocation sentinel.
47
48
  #[derive(Clone, Copy, Default)]
49
+ #[non_exhaustive]
48
50
  pub struct Usage {
49
51
  pub wall_time: f64,
50
52
  pub memory_peak: usize,
@@ -54,9 +56,22 @@ pub struct Usage {
54
56
  /// kinds: captures and usage are collected on trap and outcome alike.
55
57
  /// What a frontend exposes from the trap path is its own contract
56
58
  /// decision.
59
+ #[non_exhaustive]
57
60
  pub struct Snapshot {
58
61
  pub completion: Completion,
59
62
  pub stdout: Capture,
60
63
  pub stderr: Capture,
61
64
  pub usage: Usage,
62
65
  }
66
+
67
+ impl Snapshot {
68
+ /// What an engine hands back from one invocation.
69
+ pub fn new(completion: Completion, stdout: Capture, stderr: Capture, usage: Usage) -> Self {
70
+ Self {
71
+ completion,
72
+ stdout,
73
+ stderr,
74
+ usage,
75
+ }
76
+ }
77
+ }
@@ -15,11 +15,16 @@ use crate::error::Trap;
15
15
  /// Host-initiated re-entry into the in-flight guest instance to run a
16
16
  /// yielded block.
17
17
  ///
18
- /// `yield_to_block` ships `args` as a `yield` downcall and returns the
19
- /// decoded Yield Reply, `Unsupported` arm included, or a `Trap` — surfaced
20
- /// through the frontend's trap-error mapping — when the re-entry traps,
21
- /// the guest returns an empty or unframeable result, or a payload exceeds
22
- /// the 16 MiB cap.
18
+ /// Each open Call has a `frame`, counted from `0` at the outermost.
19
+ /// `yield_to_block` ships `args` as a `yield` downcall to the block of the
20
+ /// open Call `frame` names, and returns the decoded Yield Reply,
21
+ /// `Unsupported` arm included, or a `Trap` — surfaced through the
22
+ /// frontend's trap-error mapping — when the re-entry traps, the guest
23
+ /// returns an empty or unframeable result, or a payload exceeds the
24
+ /// 16 MiB cap.
23
25
  pub trait Yielder {
24
- fn yield_to_block(&mut self, args: &[u8]) -> Result<YieldReply, Trap>;
26
+ /// The frame of the Call this Yielder was lent for.
27
+ fn frame(&self) -> u32;
28
+
29
+ fn yield_to_block(&mut self, frame: u32, args: &[u8]) -> Result<YieldReply, Trap>;
25
30
  }
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.19.0](https://github.com/elct9620/kobako/compare/kobako-transport-v0.18.0...kobako-transport-v0.19.0) (2026-10-26)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * in kobako-transport, YieldReply::Break carries no payload and Reply gains a Break arm, so a Guest Binary built against the earlier version 5 wire must be rebuilt.
9
+ * the Guest ABI is version 5, so a Guest Binary built against version 4 is refused at Sandbox construction until rebuilt. The Yielder trait of kobako-runtime gains frame and takes the frame in yield_to_block; the Guest trait of kobako-core and the MrbGuest trait of kobako-mruby take the frame in yield_to_block; Downcall::Yield carries frame and args.
10
+
11
+ ### Features
12
+
13
+ * let a block's break or return unwind a Service as a plain method ([4e9eca0](https://github.com/elct9620/kobako/commit/4e9eca05f1647c8b6409fc00d08317b431c0c648))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * run a stored block as the block of the Call that carried it ([bf33dc1](https://github.com/elct9620/kobako/commit/bf33dc1fd40c83498c6c12b0be52b3a835a144cf))
19
+
3
20
  ## [0.18.0](https://github.com/elct9620/kobako/compare/kobako-transport-v0.17.0...kobako-transport-v0.18.0) (2026-10-13)
4
21
 
5
22
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  [package]
14
14
  name = "kobako-transport"
15
- version = "0.18.0"
15
+ version = "0.19.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"
@@ -51,7 +51,7 @@ reaches the same types through `kobako-core`.
51
51
 
52
52
  ```toml
53
53
  [dependencies]
54
- kobako-transport = "0.18.0" # x-release-please-version
54
+ kobako-transport = "0.19.0" # x-release-please-version
55
55
  ```
56
56
 
57
57
  ## License
@@ -10,7 +10,7 @@
10
10
  /// version it reports equals this one, so a framing or symbol change is
11
11
  /// an increment rather than a negotiation. A new request tag needs
12
12
  /// neither, but a new answer arm is an increment.
13
- pub const ABI_VERSION: u32 = 4;
13
+ pub const ABI_VERSION: u32 = 5;
14
14
 
15
15
  /// Width in bytes of the length prefix that frames a message on WASI
16
16
  /// stdin, such as the Preamble.
@@ -32,7 +32,8 @@ const KIND_BLOCK: u8 = 4;
32
32
  pub enum FaultKind {
33
33
  /// A Ruby exception raised inside the Service method.
34
34
  Runtime,
35
- /// The call shape did not fit the method.
35
+ /// The Service method refused its arguments, whether the call would
36
+ /// not bind or the method rejected what it was given.
36
37
  Argument,
37
38
  /// No such member or method.
38
39
  Undefined,
@@ -243,7 +243,7 @@ mod tests {
243
243
  writer.into_bytes()
244
244
  }
245
245
 
246
- fn preamble_of(records: &[Vec<u8>]) -> Vec<u8> {
246
+ fn preamble_with(records: &[Vec<u8>]) -> Vec<u8> {
247
247
  let mut bytes = (records.len() as u32).to_be_bytes().to_vec();
248
248
  for record in records {
249
249
  bytes.extend_from_slice(record);
@@ -307,7 +307,7 @@ mod tests {
307
307
  // @behavior WE-053
308
308
  #[test]
309
309
  fn an_absent_record_reads_as_empty() {
310
- let bytes = preamble_of(&[record(0, 0x07, &encode_flag(&flag("f", "", false)))]);
310
+ let bytes = preamble_with(&[record(0, 0x07, &encode_flag(&flag("f", "", false)))]);
311
311
  let preamble = Preamble::decode(&bytes).expect("a flag-only Preamble must decode");
312
312
  assert_eq!(
313
313
  (preamble.bindings, preamble.snippets),
@@ -375,7 +375,7 @@ mod tests {
375
375
  // @behavior WE-056
376
376
  #[test]
377
377
  fn an_unknown_snippet_kind_is_refused() {
378
- let bytes = preamble_of(&[record(1, 0x06, &[0, 0, 0, 1, 9])]);
378
+ let bytes = preamble_with(&[record(1, 0x06, &[0, 0, 0, 1, 9])]);
379
379
  assert!(
380
380
  Preamble::decode(&bytes).is_err(),
381
381
  "a snippet kind that is neither source nor bytecode through Preamble::decode must \
@@ -387,7 +387,7 @@ mod tests {
387
387
  #[test]
388
388
  fn a_count_the_preamble_cannot_satisfy_is_refused() {
389
389
  let records = [0xff, 0xff, 0xff, 0xff];
390
- let snippets = preamble_of(&[record(1, 0x06, &[0xff, 0xff, 0xff, 0xff])]);
390
+ let snippets = preamble_with(&[record(1, 0x06, &[0xff, 0xff, 0xff, 0xff])]);
391
391
  assert!(
392
392
  Preamble::decode(&records).is_err() && Preamble::decode(&snippets).is_err(),
393
393
  "a record or snippet count larger than what follows through Preamble::decode must \
@@ -400,7 +400,7 @@ mod tests {
400
400
  fn trailing_bytes_are_refused() {
401
401
  let mut past_the_list = Preamble::default().encode();
402
402
  past_the_list.push(0);
403
- let past_a_body = preamble_of(&[record(1, 0x05, &[0, 0, 0, 0, 0])]);
403
+ let past_a_body = preamble_with(&[record(1, 0x05, &[0, 0, 0, 0, 0])]);
404
404
  assert!(
405
405
  Preamble::decode(&past_the_list).is_err() && Preamble::decode(&past_a_body).is_err(),
406
406
  "bytes past the last record or past a record body's last field through \
@@ -411,7 +411,7 @@ mod tests {
411
411
  // @behavior WE-077
412
412
  #[test]
413
413
  fn an_advisory_record_of_an_unknown_kind_is_skipped() {
414
- let bytes = preamble_of(&[
414
+ let bytes = preamble_with(&[
415
415
  record(0, 0x7e, b"anything"),
416
416
  record(
417
417
  1,
@@ -432,7 +432,7 @@ mod tests {
432
432
  // @behavior WE-078
433
433
  #[test]
434
434
  fn a_critical_record_of_an_unknown_kind_is_refused() {
435
- let bytes = preamble_of(&[record(1, 0x7e, b"anything")]);
435
+ let bytes = preamble_with(&[record(1, 0x7e, b"anything")]);
436
436
  assert!(
437
437
  Preamble::decode(&bytes).is_err(),
438
438
  "a critical record of a kind the registry has not assigned through \
@@ -444,7 +444,7 @@ mod tests {
444
444
  #[test]
445
445
  fn a_record_kind_assigned_to_another_message_is_refused() {
446
446
  for kind in [0x00, 0x01, 0x10] {
447
- let bytes = preamble_of(&[record(0, kind, b"")]);
447
+ let bytes = preamble_with(&[record(0, kind, b"")]);
448
448
  assert!(
449
449
  Preamble::decode(&bytes).is_err(),
450
450
  "an advisory record of kind {kind:#04x}, which the registry assigns elsewhere, \
@@ -459,7 +459,7 @@ mod tests {
459
459
  let bindings = encode_bindings(&Bindings::default());
460
460
  let snippets = encode_snippets(&Snippets::default());
461
461
  for (kind, body) in [(0x05, bindings), (0x06, snippets)] {
462
- let bytes = preamble_of(&[record(1, kind, &body), record(1, kind, &body)]);
462
+ let bytes = preamble_with(&[record(1, kind, &body), record(1, kind, &body)]);
463
463
  assert!(
464
464
  Preamble::decode(&bytes).is_err(),
465
465
  "a second record of kind {kind:#04x} through Preamble::decode must be refused"
@@ -485,7 +485,7 @@ mod tests {
485
485
  // @behavior WE-082
486
486
  #[test]
487
487
  fn a_criticality_that_is_neither_value_is_refused() {
488
- let bytes = preamble_of(&[record(2, 0x07, &encode_flag(&flag("f", "", false)))]);
488
+ let bytes = preamble_with(&[record(2, 0x07, &encode_flag(&flag("f", "", false)))]);
489
489
  assert!(
490
490
  Preamble::decode(&bytes).is_err(),
491
491
  "a criticality byte other than 0 or 1 through Preamble::decode must be refused"
@@ -504,8 +504,8 @@ mod tests {
504
504
  );
505
505
  let flag = record(0, 0x07, &encode_flag(&flag("f", "", false)));
506
506
  assert_eq!(
507
- Preamble::decode(&preamble_of(&[flag.clone(), bindings.clone()])),
508
- Preamble::decode(&preamble_of(&[bindings, flag])),
507
+ Preamble::decode(&preamble_with(&[flag.clone(), bindings.clone()])),
508
+ Preamble::decode(&preamble_with(&[bindings, flag])),
509
509
  "the same records in another order through Preamble::decode must read as the same \
510
510
  Preamble"
511
511
  );
@@ -17,6 +17,9 @@ pub enum Reply {
17
17
  /// every field of a Fault is kobako's, so a guest reads a refusal
18
18
  /// whatever schema it speaks.
19
19
  Fault(Fault),
20
+ /// A block exit unwound the method; the guest continues that exit,
21
+ /// which it kept, so nothing of it crosses.
22
+ Break,
20
23
  /// The receiver does not serve the request it was sent.
21
24
  Unsupported(Unsupported),
22
25
  }
@@ -29,9 +32,13 @@ impl Reply {
29
32
  // No `finish` here: a Fault owns everything after the tag,
30
33
  // including the fields a later contract version appends.
31
34
  tag::FAULT => Ok(Reply::Fault(Fault::read(&mut reader)?)),
35
+ tag::BREAK => {
36
+ reader.finish()?;
37
+ Ok(Reply::Break)
38
+ }
32
39
  tag::UNSUPPORTED => Ok(Reply::Unsupported(Unsupported::read(&mut reader)?)),
33
40
  _ => Err(DecodeError::new(
34
- "Reply tag must be 0x10 (ok), 0x11 (fault), or 0x15 (unsupported)",
41
+ "Reply tag must be 0x10 (ok), 0x11 (fault), 0x12 (break), or 0x15 (unsupported)",
35
42
  )),
36
43
  }
37
44
  }
@@ -46,21 +53,23 @@ impl Reply {
46
53
  writer.u8(tag::FAULT);
47
54
  fault.write(&mut writer);
48
55
  }
56
+ Reply::Break => {
57
+ writer.u8(tag::BREAK);
58
+ }
49
59
  Reply::Unsupported(refusal) => return refusal.encode(),
50
60
  }
51
61
  writer.into_bytes()
52
62
  }
53
63
  }
54
64
 
55
- /// The answer to one Yield Call. Carries a `Break` outcome the dispatch
56
- /// Reply has no counterpart for, because a block can end its caller.
65
+ /// The answer to one Yield Call.
57
66
  #[derive(Debug, Clone, PartialEq, Eq)]
58
67
  pub enum YieldReply {
59
68
  /// The block completed; the bytes are its value, codec-encoded.
60
69
  Ok(Vec<u8>),
61
- /// The block ran `break`; the bytes are the break value,
62
- /// codec-encoded. It returns to the guest rather than to host code.
63
- Break(Vec<u8>),
70
+ /// The block ran `break` or `return` to a frame past the Service.
71
+ /// The guest keeps the exit, so the Service is only unwound.
72
+ Break,
64
73
  /// The block raised, or failed in a way the host re-raises at the
65
74
  /// yield site.
66
75
  Error(ErrorRecord),
@@ -73,7 +82,10 @@ impl YieldReply {
73
82
  let mut reader = Reader::new(bytes);
74
83
  match reader.u8()? {
75
84
  tag::OK => Ok(YieldReply::Ok(reader.remaining().to_vec())),
76
- tag::BREAK => Ok(YieldReply::Break(reader.remaining().to_vec())),
85
+ tag::BREAK => {
86
+ reader.finish()?;
87
+ Ok(YieldReply::Break)
88
+ }
77
89
  tag::ERROR => {
78
90
  let record = ErrorRecord::read(&mut reader)?;
79
91
  reader.finish()?;
@@ -90,8 +102,8 @@ impl YieldReply {
90
102
  YieldReply::Ok(body) => {
91
103
  writer.u8(tag::OK).remainder(body);
92
104
  }
93
- YieldReply::Break(body) => {
94
- writer.u8(tag::BREAK).remainder(body);
105
+ YieldReply::Break => {
106
+ writer.u8(tag::BREAK);
95
107
  }
96
108
  YieldReply::Error(record) => {
97
109
  writer.u8(tag::ERROR);
@@ -116,6 +128,7 @@ mod tests {
116
128
  super::super::FaultKind::Undefined,
117
129
  "no such method",
118
130
  )),
131
+ Reply::Break,
119
132
  Reply::Unsupported(Unsupported::new("no such operation")),
120
133
  ] {
121
134
  let encoded = reply.encode();
@@ -142,7 +155,7 @@ mod tests {
142
155
  #[test]
143
156
  fn an_unknown_reply_tag_is_refused() {
144
157
  assert!(
145
- Reply::decode(&[0x12, 0]).is_err(),
158
+ Reply::decode(&[0x13, 0]).is_err(),
146
159
  "a Reply tag outside its arms must be rejected as a wire violation"
147
160
  );
148
161
  }
@@ -161,7 +174,7 @@ mod tests {
161
174
  fn every_live_yield_arm_round_trips() {
162
175
  let arms = [
163
176
  YieldReply::Ok(vec![0xa4]),
164
- YieldReply::Break(vec![0xa4]),
177
+ YieldReply::Break,
165
178
  YieldReply::Error(ErrorRecord {
166
179
  name: "LocalJumpError".into(),
167
180
  message: "no block".into(),
@@ -214,6 +227,11 @@ mod tests {
214
227
  "an unsupported Reply must encode as tag byte 0x15 followed by kind 0 and the \
215
228
  message"
216
229
  );
230
+ assert_eq!(
231
+ Reply::Break.encode(),
232
+ vec![0x12],
233
+ "a break Reply must encode as tag byte 0x12 alone, since the guest keeps the exit"
234
+ );
217
235
  }
218
236
 
219
237
  // @behavior WE-068
@@ -242,9 +260,9 @@ mod tests {
242
260
  "a Yield Reply carrying the block's value must encode as tag byte 0x10"
243
261
  );
244
262
  assert_eq!(
245
- YieldReply::Break(vec![0xc0]).encode(),
246
- vec![0x12, 0xc0],
247
- "a Yield Reply carrying a break value must encode as tag byte 0x12"
263
+ YieldReply::Break.encode(),
264
+ vec![0x12],
265
+ "a Yield Reply for a block exit must encode as tag byte 0x12 alone"
248
266
  );
249
267
  assert_eq!(
250
268
  YieldReply::Unsupported(Unsupported::new("x")).encode(),
@@ -18,9 +18,9 @@ pub enum Downcall {
18
18
  Eval(Vec<u8>),
19
19
  /// Call the entrypoint the Run names.
20
20
  Run(Run),
21
- /// Re-enter the block bound to the active dispatch; the bytes are
22
- /// its arguments, codec-encoded.
23
- Yield(Vec<u8>),
21
+ /// Re-enter the block of the open Call `frame` counts to, from `0`
22
+ /// at the outermost; `args` are its arguments, codec-encoded.
23
+ Yield { frame: u32, args: Vec<u8> },
24
24
  /// Compile the Preamble's source Snippets without running them. It
25
25
  /// carries no body: what it compiles is the Preamble an invocation
26
26
  /// would read.
@@ -37,7 +37,10 @@ impl Downcall {
37
37
  Ok(match tag {
38
38
  tag::EVAL => Downcall::Eval(body.to_vec()),
39
39
  tag::RUN => Downcall::Run(Run::decode(body)?),
40
- tag::YIELD => Downcall::Yield(body.to_vec()),
40
+ tag::YIELD => Downcall::Yield {
41
+ frame: reader.u32()?,
42
+ args: reader.remaining().to_vec(),
43
+ },
41
44
  tag::COMPILE if body.is_empty() => Downcall::Compile,
42
45
  tag::COMPILE => return Err(DecodeError::new("a compile request carries no body")),
43
46
  tag => Downcall::Unknown {
@@ -52,7 +55,7 @@ impl Downcall {
52
55
  match self {
53
56
  Downcall::Eval(source) => writer.u8(tag::EVAL).remainder(source),
54
57
  Downcall::Run(run) => writer.u8(tag::RUN).remainder(&run.encode()),
55
- Downcall::Yield(args) => writer.u8(tag::YIELD).remainder(args),
58
+ Downcall::Yield { frame, args } => writer.u8(tag::YIELD).u32(*frame).remainder(args),
56
59
  Downcall::Compile => writer.u8(tag::COMPILE),
57
60
  Downcall::Unknown { tag, body } => writer.u8(tag.get()).remainder(body),
58
61
  };
@@ -146,14 +149,18 @@ mod tests {
146
149
  (
147
150
  Downcall::Eval(b"1".to_vec()).encode(),
148
151
  Downcall::Run(run).encode(),
149
- Downcall::Yield(vec![0x90]).encode(),
152
+ Downcall::Yield {
153
+ frame: 2,
154
+ args: vec![0x90],
155
+ }
156
+ .encode(),
150
157
  Downcall::Compile.encode(),
151
158
  Upcall::Call(call).encode(),
152
159
  ),
153
160
  (
154
161
  vec![0x01, b'1'],
155
162
  vec![0x02, 0, 0, 0, 1, b'M', 0x90],
156
- vec![0x03, 0x90],
163
+ vec![0x03, 0, 0, 0, 2, 0x90],
157
164
  vec![0x08],
158
165
  vec![
159
166
  0x04, // tag: call
@@ -177,7 +184,10 @@ mod tests {
177
184
  entrypoint: "Main".into(),
178
185
  payload: vec![0x90],
179
186
  }),
180
- Downcall::Yield(vec![0x91, 0x01]),
187
+ Downcall::Yield {
188
+ frame: 0,
189
+ args: vec![0x91, 0x01],
190
+ },
181
191
  Downcall::Compile,
182
192
  Downcall::Unknown {
183
193
  tag: UnknownTag::new(0xf0).unwrap(),