kobako 0.27.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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +241 -0
  4. data/Cargo.lock +132 -126
  5. data/README.md +240 -147
  6. data/ROADMAP.md +28 -22
  7. data/SECURITY.md +2 -2
  8. data/crates/kobako-runtime/CHANGELOG.md +117 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +16 -10
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/error.rs +8 -9
  13. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  14. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  15. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  18. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  19. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  20. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  21. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  22. data/crates/kobako-runtime/src/grant.rs +20 -0
  23. data/crates/kobako-runtime/src/lib.rs +12 -2
  24. data/crates/kobako-runtime/src/mount.rs +178 -0
  25. data/crates/kobako-runtime/src/profile.rs +4 -4
  26. data/crates/kobako-runtime/src/runtime.rs +43 -17
  27. data/crates/kobako-runtime/src/snapshot.rs +33 -15
  28. data/crates/kobako-runtime/src/yielder.rs +14 -6
  29. data/crates/kobako-transport/CHANGELOG.md +45 -0
  30. data/crates/kobako-transport/Cargo.toml +1 -1
  31. data/crates/kobako-transport/README.md +21 -12
  32. data/crates/kobako-transport/src/abi.rs +11 -14
  33. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  34. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  35. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  36. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  37. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  38. data/crates/kobako-transport/src/envelope/reply.rs +103 -61
  39. data/crates/kobako-transport/src/envelope/request.rs +305 -0
  40. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  41. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  42. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  43. data/crates/kobako-transport/src/envelope.rs +19 -12
  44. data/crates/kobako-transport/src/lib.rs +0 -2
  45. data/crates/kobako-wasmtime/CHANGELOG.md +163 -0
  46. data/crates/kobako-wasmtime/Cargo.toml +23 -16
  47. data/crates/kobako-wasmtime/README.md +6 -4
  48. data/crates/kobako-wasmtime/src/abi.rs +67 -36
  49. data/crates/kobako-wasmtime/src/ambient.rs +64 -5
  50. data/crates/kobako-wasmtime/src/cache.rs +204 -127
  51. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  52. data/crates/kobako-wasmtime/src/config.rs +69 -15
  53. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  54. data/crates/kobako-wasmtime/src/dispatch.rs +95 -87
  55. data/crates/kobako-wasmtime/src/driver.rs +174 -125
  56. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  57. data/crates/kobako-wasmtime/src/guest_mem.rs +151 -138
  58. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  59. data/crates/kobako-wasmtime/src/instance_pre.rs +19 -58
  60. data/crates/kobako-wasmtime/src/invocation.rs +84 -412
  61. data/crates/kobako-wasmtime/src/io.rs +89 -0
  62. data/crates/kobako-wasmtime/src/lib.rs +7 -24
  63. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  64. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  65. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  66. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  67. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  68. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  69. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  70. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  71. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  72. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  73. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  74. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  75. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  76. data/crates/kobako-wasmtime/src/trap.rs +66 -59
  77. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  78. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  79. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  80. data/crates/kobako-wasmtime/tests/preamble_flags.rs +70 -0
  81. data/crates/kobako-wasmtime/tests/snippet_compile.rs +95 -0
  82. data/crates/kobako-wasmtime/tests/support/mod.rs +30 -0
  83. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  84. data/crates/kobako-wasmtime/tests/unsupported_call.rs +51 -0
  85. data/data/kobako.wasm +0 -0
  86. data/ext/kobako/Cargo.toml +4 -2
  87. data/ext/kobako/src/runtime/bridge.rs +177 -113
  88. data/ext/kobako/src/runtime/errors.rs +26 -32
  89. data/ext/kobako/src/runtime/escape.rs +53 -0
  90. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  91. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  92. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  93. data/ext/kobako/src/runtime/gvl.rs +11 -13
  94. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  95. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  96. data/ext/kobako/src/runtime.rs +190 -312
  97. data/lib/kobako/capture.rb +1 -4
  98. data/lib/kobako/catalog/extensions.rb +21 -59
  99. data/lib/kobako/catalog/flags.rb +29 -0
  100. data/lib/kobako/catalog/grants.rb +30 -0
  101. data/lib/kobako/catalog/handles.rb +7 -54
  102. data/lib/kobako/catalog/mount.rb +27 -0
  103. data/lib/kobako/catalog/services.rb +17 -60
  104. data/lib/kobako/catalog/snippets.rb +41 -69
  105. data/lib/kobako/catalog.rb +13 -10
  106. data/lib/kobako/codec/decoder.rb +10 -28
  107. data/lib/kobako/codec/encoder.rb +3 -22
  108. data/lib/kobako/codec/error.rb +6 -27
  109. data/lib/kobako/codec/ext_types.rb +72 -94
  110. data/lib/kobako/codec/handle_walk.rb +65 -130
  111. data/lib/kobako/codec/nesting.rb +3 -8
  112. data/lib/kobako/codec/state.rb +21 -11
  113. data/lib/kobako/codec/utils.rb +6 -34
  114. data/lib/kobako/codec/value_budget.rb +86 -0
  115. data/lib/kobako/codec.rb +10 -22
  116. data/lib/kobako/constant_name.rb +8 -0
  117. data/lib/kobako/context.rb +83 -117
  118. data/lib/kobako/errors.rb +57 -132
  119. data/lib/kobako/execution.rb +16 -21
  120. data/lib/kobako/extension.rb +23 -40
  121. data/lib/kobako/filesystem.rb +38 -0
  122. data/lib/kobako/handle.rb +7 -12
  123. data/lib/kobako/outcome.rb +51 -80
  124. data/lib/kobako/payload/arguments.rb +1 -16
  125. data/lib/kobako/payload.rb +4 -12
  126. data/lib/kobako/pool.rb +25 -55
  127. data/lib/kobako/runtime.rb +7 -21
  128. data/lib/kobako/sandbox.rb +128 -137
  129. data/lib/kobako/sandbox_options.rb +71 -90
  130. data/lib/kobako/snippet/bytecode.rb +11 -0
  131. data/lib/kobako/snippet/source.rb +1 -16
  132. data/lib/kobako/snippet.rb +2 -10
  133. data/lib/kobako/transport/block_failure.rb +35 -0
  134. data/lib/kobako/transport/call.rb +1 -14
  135. data/lib/kobako/transport/dispatcher.rb +127 -206
  136. data/lib/kobako/transport/error.rb +6 -20
  137. data/lib/kobako/transport/exposure.rb +10 -17
  138. data/lib/kobako/transport/reflection.rb +108 -63
  139. data/lib/kobako/transport/run.rb +17 -71
  140. data/lib/kobako/transport/yielder.rb +20 -94
  141. data/lib/kobako/transport.rb +4 -15
  142. data/lib/kobako/unresolved.rb +4 -11
  143. data/lib/kobako/usage.rb +2 -6
  144. data/lib/kobako/version.rb +1 -1
  145. data/lib/kobako.rb +1 -0
  146. data/sig/kobako/catalog/extensions.rbs +2 -2
  147. data/sig/kobako/catalog/flags.rbs +13 -0
  148. data/sig/kobako/catalog/grants.rbs +13 -0
  149. data/sig/kobako/catalog/mount.rbs +28 -0
  150. data/sig/kobako/catalog/services.rbs +3 -5
  151. data/sig/kobako/catalog/snippets.rbs +5 -5
  152. data/sig/kobako/catalog.rbs +1 -0
  153. data/sig/kobako/codec/ext_types.rbs +10 -8
  154. data/sig/kobako/codec/handle_walk.rbs +9 -7
  155. data/sig/kobako/codec/state.rbs +7 -1
  156. data/sig/kobako/codec/utils.rbs +2 -2
  157. data/sig/kobako/codec/value_budget.rbs +23 -0
  158. data/sig/kobako/codec.rbs +4 -2
  159. data/sig/kobako/constant_name.rbs +3 -0
  160. data/sig/kobako/context.rbs +15 -8
  161. data/sig/kobako/errors.rbs +10 -4
  162. data/sig/kobako/filesystem.rbs +88 -0
  163. data/sig/kobako/guest_yielder.rbs +9 -0
  164. data/sig/kobako/outcome.rbs +6 -8
  165. data/sig/kobako/pool.rbs +1 -1
  166. data/sig/kobako/runtime.rbs +21 -45
  167. data/sig/kobako/sandbox.rbs +11 -1
  168. data/sig/kobako/sandbox_options.rbs +25 -7
  169. data/sig/kobako/snapshot.rbs +20 -0
  170. data/sig/kobako/snippet/bytecode.rbs +12 -0
  171. data/sig/kobako/transport/block_failure.rbs +13 -0
  172. data/sig/kobako/transport/dispatcher.rbs +15 -19
  173. data/sig/kobako/transport/exposure.rbs +1 -1
  174. data/sig/kobako/transport/reflection.rbs +19 -4
  175. data/sig/kobako/transport/run.rbs +1 -3
  176. data/sig/kobako/transport/yielder.rbs +5 -9
  177. data/sig/kobako/transport.rbs +9 -8
  178. metadata +75 -10
  179. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  180. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  181. data/lib/kobako/snippet/binary.rb +0 -26
  182. data/rust-toolchain.toml +0 -15
  183. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -1,494 +1,166 @@
1
- //! Per-invocation host state — the materialised
2
- //! [SPEC.md Single-Invocation Slot] (one `Invocation` per OS thread
3
- //! for the lifetime of one `Driver` invoke call).
1
+ //! Per-invocation host state — one `Invocation` per OS thread for the
2
+ //! lifetime of one `Driver` invoke call.
4
3
  //!
5
4
  //! Owned as the data of each per-invocation `wasmtime::Store`
6
5
  //! and threaded through every host import —
7
- //! the `__kobako_dispatch` dispatcher reads the bound dispatch handler,
8
- //! while `Driver::invoke` installs the invocation's WASI
9
- //! context + pipes (via `frames::install_wasi_frames`) before the guest
10
- //! export call.
6
+ //! the `__kobako_upcall` dispatcher reads the bound dispatch handler,
7
+ //! and the WASI imports read the invocation's `Wasi` context, which the
8
+ //! Store is created with.
11
9
  //!
12
- //! The slot also carries the per-invocation wall-clock deadline
13
- //! and the per-invocation linear-memory
14
- //! delta cap `MemoryLimiter`. Both are
15
- //! read from the wasmtime `epoch_deadline_callback` / `ResourceLimiter`
16
- //! callbacks installed in `Driver::new_store`. The
17
- //! memory cap measures only the `memory.grow` delta past the linear-
18
- //! memory size captured at invocation entry — the image's initial
19
- //! allocation is outside the budget.
20
- //!
21
- //! [SPEC.md Single-Invocation Slot]: ../../../SPEC.md
10
+ //! The slot also carries the per-invocation wall-clock deadline and the
11
+ //! memory cap `MemoryLimiter`, both read from the wasmtime
12
+ //! `epoch_deadline_callback` / `ResourceLimiter` callbacks installed in
13
+ //! `Driver::new_store`.
22
14
 
23
15
  use std::sync::Arc;
24
16
  use std::time::{Duration, Instant};
25
17
 
26
- use wasmtime::ResourceLimiter;
27
- use wasmtime_wasi::p1::WasiP1Ctx;
28
- use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
29
-
30
18
  use kobako_runtime::dispatch::DispatchHandler;
31
19
 
32
- /// Per-invocation host state — the data half of the Single-Invocation
33
- /// Slot. Threaded through every host import callback.
20
+ use crate::exports::Exports;
21
+ use crate::limiter::MemoryLimiter;
22
+ use crate::wasi::Wasi;
23
+
24
+ /// Per-invocation host state, owned by the invocation's own Store.
25
+ /// Threaded through every host import callback.
34
26
  ///
35
- /// All field access is mediated by methods on this type — the WASI ctx
36
- /// is rebuilt fresh before each invocation via
37
- /// `Invocation::install_wasi`, the dispatch handler is set once via
38
- /// `Invocation::bind_on_dispatch`, and captured stdout/stderr bytes
39
- /// are read after the invocation via `Invocation::stdout_bytes` /
40
- /// `Invocation::stderr_bytes`. The fields are private so the mutation
41
- /// surface stays narrow.
27
+ /// All field access is mediated by methods on this type — the `Wasi`
28
+ /// context arrives with the invocation, the dispatch handler is set once
29
+ /// via `Invocation::bind_on_dispatch`, and the captures are read back
30
+ /// through `Invocation::wasi` after the run. The fields are private so
31
+ /// the mutation surface stays narrow.
42
32
  pub(crate) struct Invocation {
43
- wasi: Option<WasiP1Ctx>,
44
- stdout_pipe: Option<MemoryOutputPipe>,
45
- stderr_pipe: Option<MemoryOutputPipe>,
33
+ wasi: Wasi,
46
34
  on_dispatch: Option<Arc<dyn DispatchHandler>>,
47
35
  deadline: Option<Instant>,
48
36
  limiter: MemoryLimiter,
49
37
  wall_entry: Option<Instant>,
50
38
  wall_time: Duration,
39
+ reentry_trap: Option<wasmtime::Error>,
40
+ exports: Option<Exports>,
41
+ open_calls: u32,
51
42
  }
52
43
 
53
44
  impl Invocation {
54
- /// Build a fresh per-Store host state. `memory_limit` carries the
55
- /// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
56
- /// it is read from the wasmtime `ResourceLimiter` callback every
57
- /// time the guest grows linear memory.
58
- pub(crate) fn new(memory_limit: Option<usize>) -> Self {
45
+ pub(crate) fn new(memory_limit: Option<usize>, wasi: Wasi) -> Self {
59
46
  Self {
60
- wasi: None,
61
- stdout_pipe: None,
62
- stderr_pipe: None,
47
+ wasi,
63
48
  on_dispatch: None,
64
49
  deadline: None,
65
50
  limiter: MemoryLimiter::new(memory_limit),
66
51
  wall_entry: None,
67
52
  wall_time: Duration::ZERO,
53
+ reentry_trap: None,
54
+ exports: None,
55
+ open_calls: 0,
68
56
  }
69
57
  }
70
58
 
71
- /// Install a freshly-built WASI context plus the matching stdout/stderr
72
- /// pipe clones. Called from `frames::install_wasi_frames`, which
73
- /// `Driver::invoke` runs at the top of every guest
74
- /// invocation.
75
- pub(crate) fn install_wasi(
76
- &mut self,
77
- wasi: WasiP1Ctx,
78
- stdout: MemoryOutputPipe,
79
- stderr: MemoryOutputPipe,
80
- ) {
81
- self.wasi = Some(wasi);
82
- self.stdout_pipe = Some(stdout);
83
- self.stderr_pipe = Some(stderr);
59
+ pub(crate) fn set_exports(&mut self, exports: Exports) {
60
+ self.exports = Some(exports);
61
+ }
62
+
63
+ /// `None` until the instance exists, so a host import reached before
64
+ /// then finds no guest to call back into.
65
+ pub(crate) fn exports(&self) -> Option<Exports> {
66
+ self.exports.clone()
67
+ }
68
+
69
+ /// The first trap wins: the upcall import ends the invocation with it,
70
+ /// so the trap keeps its own kind.
71
+ pub(crate) fn record_reentry_trap(&mut self, trap: wasmtime::Error) {
72
+ self.reentry_trap.get_or_insert(trap);
73
+ }
74
+
75
+ /// Once a callback into the guest has trapped, the guest is past
76
+ /// resuming.
77
+ pub(crate) fn reentry_trapped(&self) -> bool {
78
+ self.reentry_trap.is_some()
79
+ }
80
+
81
+ pub(crate) fn take_reentry_trap(&mut self) -> Option<wasmtime::Error> {
82
+ self.reentry_trap.take()
83
+ }
84
+
85
+ /// Count a Call the host is about to answer and name its frame, as the
86
+ /// guest counts the Calls it has open.
87
+ pub(crate) fn open_call(&mut self) -> u32 {
88
+ self.open_calls += 1;
89
+ self.open_calls - 1
90
+ }
91
+
92
+ pub(crate) fn close_call(&mut self) {
93
+ self.open_calls -= 1;
84
94
  }
85
95
 
86
- /// Bind the dispatch handler for this invocation. From this point on,
87
- /// every `__kobako_dispatch` host import invocation hands the handler
88
- /// the Call bytes and expects encoded Reply bytes back.
89
96
  pub(crate) fn bind_on_dispatch(&mut self, handler: Arc<dyn DispatchHandler>) {
90
97
  self.on_dispatch = Some(handler);
91
98
  }
92
99
 
93
- /// Snapshot the bytes captured on guest fd 1 during the most recent
94
- /// run. Empty vec before any run.
95
- pub(crate) fn stdout_bytes(&self) -> Vec<u8> {
96
- self.stdout_pipe
97
- .as_ref()
98
- .map(|p| p.contents().to_vec())
99
- .unwrap_or_default()
100
+ /// A clone, so the borrow on the `Caller` is released and the
101
+ /// dispatcher can re-borrow it to write the Reply.
102
+ pub(crate) fn on_dispatch(&self) -> Option<Arc<dyn DispatchHandler>> {
103
+ self.on_dispatch.clone()
100
104
  }
101
105
 
102
- /// Snapshot the bytes captured on guest fd 2 during the most recent
103
- /// run. Empty vec before any run.
104
- pub(crate) fn stderr_bytes(&self) -> Vec<u8> {
105
- self.stderr_pipe
106
+ /// The trap ending an invocation its host gave up on.
107
+ pub(crate) fn abandonment(&self) -> Option<wasmtime::Error> {
108
+ self.on_dispatch
106
109
  .as_ref()
107
- .map(|p| p.contents().to_vec())
108
- .unwrap_or_default()
110
+ .is_some_and(|handler| handler.abandoned())
111
+ .then(|| wasmtime::Error::msg("the host abandoned the invocation"))
109
112
  }
110
113
 
111
- /// Return a clone of the bound dispatch handler (an `Arc`, so the clone
112
- /// is a cheap refcount bump). Cloning releases the borrow on the
113
- /// `Caller` so the dispatcher can re-borrow it to write the response.
114
- /// None means no handler has been bound yet via
115
- /// `Invocation::bind_on_dispatch`.
116
- pub(crate) fn on_dispatch(&self) -> Option<Arc<dyn DispatchHandler>> {
117
- self.on_dispatch.clone()
114
+ pub(crate) fn wasi(&self) -> &Wasi {
115
+ &self.wasi
118
116
  }
119
117
 
120
- /// Mutable handle to the live WASI context. Panics if no context has
121
- /// been installed yet — every call site is downstream of
122
- /// `Invocation::install_wasi` running at the top of every `Driver`
123
- /// invoke, so reaching this branch with `None` signals a host-side
124
- /// wiring bug.
125
- pub(crate) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
126
- self.wasi.as_mut().expect(
127
- "WASI context not initialised — the driver must install frames before any WASI use",
128
- )
118
+ pub(crate) fn wasi_mut(&mut self) -> &mut Wasi {
119
+ &mut self.wasi
129
120
  }
130
121
 
131
- /// Replace the per-run wall-clock deadline. `Some(at)` makes the
132
- /// epoch-deadline callback trap once `Instant::now() >= at`; `None`
133
- /// disables the cap. Called from `Driver::prime_caps` at the top of
134
- /// every invocation (`#eval` and `#run`).
135
122
  pub(crate) fn set_deadline(&mut self, deadline: Option<Instant>) {
136
123
  self.deadline = deadline;
137
124
  }
138
125
 
139
- /// Return the current per-run deadline. Read from the epoch-deadline
140
- /// callback installed by `Driver::new_store`.
141
126
  pub(crate) fn deadline(&self) -> Option<Instant> {
142
127
  self.deadline
143
128
  }
144
129
 
145
- /// Mutable handle to the embedded `MemoryLimiter`. Required by
146
- /// the wasmtime `ResourceLimiter` callback wiring in
147
- /// `Driver::new_store`
148
- /// (`store.limiter(|state| state.limiter_mut())`); kept private to
149
- /// the wasm submodule so the only public surface for arming the
150
- /// cap goes through `Invocation::arm_memory_cap` /
151
- /// `Invocation::disarm_memory_cap`.
152
130
  pub(crate) fn limiter_mut(&mut self) -> &mut MemoryLimiter {
153
131
  &mut self.limiter
154
132
  }
155
133
 
156
- /// Arm the memory cap for one guest run with
157
- /// the current linear-memory size as the baseline. The limiter
158
- /// charges only the `memory.grow` delta past `baseline` against
159
- /// the cap, so the mruby image's initial allocation and the
160
- /// high-water mark left by prior invocations do not consume the
161
- /// budget. Paired with `Invocation::disarm_memory_cap` around the
162
- /// call to the corresponding `__kobako_*` export so post-run host
163
- /// bookkeeping (e.g. fetching the OUTCOME_BUFFER) is not
164
- /// attributed to the user script.
134
+ /// Only the `memory.grow` delta past `baseline` is charged, so the
135
+ /// image's initial allocation and the high-water mark of earlier
136
+ /// invocations do not consume the budget.
165
137
  pub(crate) fn arm_memory_cap(&mut self, baseline: usize) {
166
138
  self.limiter.activate(baseline);
167
139
  }
168
140
 
169
- /// Disarm the memory cap. See
170
- /// `Invocation::arm_memory_cap`.
141
+ /// Disarmed once the export returns, so post-run host bookkeeping such
142
+ /// as reading the answer back is not charged to the script.
171
143
  pub(crate) fn disarm_memory_cap(&mut self) {
172
144
  self.limiter.deactivate();
173
145
  }
174
146
 
175
- /// Stamp the wall-clock entry instant for the `wall_time`
176
- /// measurement. Called at the top of every
177
- /// invocation immediately before the guest export call so the
178
- /// bracket matches the `timeout` deadline accounting and
179
- /// excludes post-run host bookkeeping such as `OUTCOME_BUFFER`
180
- /// decoding.
147
+ /// Stamped right before the guest export call, so the bracket matches
148
+ /// the `timeout` accounting and excludes post-run host bookkeeping.
181
149
  pub(crate) fn start_wall_clock(&mut self) {
182
150
  self.wall_entry = Some(Instant::now());
183
151
  }
184
152
 
185
- /// Close the `wall_time` measurement
186
- /// started by `Invocation::start_wall_clock`. Idempotent — a
187
- /// stop with no matching start (e.g. if the guest export call
188
- /// never executed because of a host-side allocation failure)
189
- /// leaves the previously-recorded value untouched.
190
153
  pub(crate) fn stop_wall_clock(&mut self) {
191
154
  if let Some(entry) = self.wall_entry.take() {
192
155
  self.wall_time = entry.elapsed();
193
156
  }
194
157
  }
195
158
 
196
- /// Return the wall-clock duration the most recent invocation
197
- /// spent inside the guest export call.
198
- /// Zero before the first invocation.
199
159
  pub(crate) fn wall_time(&self) -> Duration {
200
160
  self.wall_time
201
161
  }
202
162
 
203
- /// Return the `memory_peak` — the high-
204
- /// water mark of the per-invocation `memory.grow` delta past the
205
- /// linear-memory size captured at invocation entry. Zero before
206
- /// the first invocation.
207
163
  pub(crate) fn memory_peak(&self) -> usize {
208
164
  self.limiter.peak()
209
165
  }
210
166
  }
211
-
212
- /// Resource limiter that enforces the per-invocation `memory_limit`
213
- /// cap.
214
- ///
215
- /// `max_memory` is the byte cap on per-invocation growth (`None` disables
216
- /// the cap). `baseline` is the linear-memory size captured at invocation
217
- /// entry by `MemoryLimiter::activate`; the limiter charges only the
218
- /// `memory.grow` delta past `baseline` against `max_memory`, so the
219
- /// mruby image's initial allocation and any high-water mark left by
220
- /// prior invocations on the same Sandbox do not consume the budget.
221
- /// `cap_active` gates whether the cap is enforced — wasmtime's
222
- /// `ResourceLimiter` also fires for the module's declared initial
223
- /// allocation at instantiation time, but the cap stays dormant until
224
- /// `MemoryLimiter::activate` flips the flag for one `Driver` invoke
225
- /// call. When `cap_active` is `false`, the limiter always allows
226
- /// growth.
227
- ///
228
- /// When `memory.grow` would push the per-invocation delta past
229
- /// `max_memory`, the limiter returns `MemoryLimitTrap` from
230
- /// `memory_growing`; wasmtime turns that into the trap surfaced to the
231
- /// host as a guest invocation failure.
232
- #[derive(Debug, Clone, Copy)]
233
- pub(crate) struct MemoryLimiter {
234
- max_memory: Option<usize>,
235
- baseline: usize,
236
- cap_active: bool,
237
- peak: usize,
238
- }
239
-
240
- impl MemoryLimiter {
241
- fn new(max_memory: Option<usize>) -> Self {
242
- Self {
243
- max_memory,
244
- baseline: 0,
245
- cap_active: false,
246
- peak: 0,
247
- }
248
- }
249
-
250
- /// Arm the cap so subsequent `memory.grow` calls are charged
251
- /// against `max_memory` starting from `baseline` bytes. Called via
252
- /// `Invocation::arm_memory_cap` at the top of every invocation;
253
- /// the cap is dormant by default — the module's declared initial
254
- /// memory is allocated during `Linker::instantiate` and the
255
- /// per-invocation budget excludes anything that existed before
256
- /// arming. Also clears the
257
- /// per-invocation `MemoryLimiter::peak` high-water so the
258
- /// `memory_peak` accounting restarts from
259
- /// zero for the new invocation.
260
- fn activate(&mut self, baseline: usize) {
261
- self.baseline = baseline;
262
- self.cap_active = true;
263
- self.peak = 0;
264
- }
265
-
266
- /// Disarm the cap so post-run host bookkeeping (e.g. fetching the
267
- /// OUTCOME_BUFFER, which can grow guest memory transiently) is
268
- /// not attributed to the user script. Paired with
269
- /// `MemoryLimiter::activate`.
270
- fn deactivate(&mut self) {
271
- self.cap_active = false;
272
- }
273
-
274
- /// Return the high-water mark of the per-invocation
275
- /// `memory.grow` delta past `baseline` observed since the last
276
- /// `MemoryLimiter::activate`. Read after the guest export
277
- /// returns to populate `Kobako::Usage#memory_peak`.
278
- /// Pinned to the last accepted grow —
279
- /// rejected `desired` values that trip the memory
280
- /// cap never update the peak, so the reported value never exceeds
281
- /// `memory_limit`.
282
- pub(crate) fn peak(&self) -> usize {
283
- self.peak
284
- }
285
- }
286
-
287
- impl ResourceLimiter for MemoryLimiter {
288
- fn memory_growing(
289
- &mut self,
290
- _current: usize,
291
- desired: usize,
292
- _maximum: Option<usize>,
293
- ) -> wasmtime::Result<bool> {
294
- if !self.cap_active {
295
- return Ok(true);
296
- }
297
- let delta = desired.saturating_sub(self.baseline);
298
- if let Some(limit) = self.max_memory {
299
- if delta > limit {
300
- return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
301
- }
302
- }
303
- if delta > self.peak {
304
- self.peak = delta;
305
- }
306
- Ok(true)
307
- }
308
-
309
- fn table_growing(
310
- &mut self,
311
- _current: usize,
312
- _desired: usize,
313
- _maximum: Option<usize>,
314
- ) -> wasmtime::Result<bool> {
315
- Ok(true)
316
- }
317
- }
318
-
319
- /// Marker error returned from `MemoryLimiter::memory_growing` when the
320
- /// per-invocation memory cap is exceeded. Downcast from the wasmtime
321
- /// trap error to classify the failure as a memory-limit `Trap`. Callers
322
- /// use the `Display` impl below — no field is read directly — so the
323
- /// inner state stays private.
324
- #[derive(Debug)]
325
- pub(crate) struct MemoryLimitTrap {
326
- desired: usize,
327
- limit: usize,
328
- }
329
-
330
- impl MemoryLimitTrap {
331
- /// Construct a trap with the given `desired` / `limit` pair. Used
332
- /// internally by `MemoryLimiter::memory_growing` in production and
333
- /// by the sibling-module `classify_trap` unit tests to materialise
334
- /// a representative error for downcast routing.
335
- #[cfg(test)]
336
- pub(crate) fn new(desired: usize, limit: usize) -> Self {
337
- Self { desired, limit }
338
- }
339
- }
340
-
341
- impl std::fmt::Display for MemoryLimitTrap {
342
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343
- write!(
344
- f,
345
- "memory usage exceeded memory_limit: \
346
- requested={} bytes, limit={} bytes",
347
- self.desired, self.limit
348
- )
349
- }
350
- }
351
-
352
- impl std::error::Error for MemoryLimitTrap {}
353
-
354
- /// Marker error returned from the epoch-deadline callback when the
355
- /// wall-clock deadline is exceeded. Downcast from the wasmtime trap
356
- /// error to classify the failure as a timeout `Trap`.
357
- #[derive(Debug)]
358
- pub(crate) struct TimeoutTrap;
359
-
360
- impl std::fmt::Display for TimeoutTrap {
361
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362
- write!(f, "wall-clock deadline exceeded")
363
- }
364
- }
365
-
366
- impl std::error::Error for TimeoutTrap {}
367
-
368
- #[cfg(test)]
369
- mod tests {
370
- //! Unit tests for `MemoryLimiter` — the per-invocation memory
371
- //! delta cap. The Ruby-facing E2E suite exercises the full path
372
- //! through wasmtime; these tests pin the pure delta arithmetic so
373
- //! a regression that breaks the baseline accounting (e.g. dropping
374
- //! the `baseline` subtraction, or letting `activate` carry stale
375
- //! state across invocations) is caught without spinning up a
376
- //! Store.
377
- use super::{MemoryLimitTrap, MemoryLimiter};
378
- use wasmtime::ResourceLimiter;
379
-
380
- fn assert_growing(limiter: &mut MemoryLimiter, desired: usize) {
381
- assert!(
382
- limiter.memory_growing(0, desired, None).unwrap(),
383
- "expected memory_growing({desired}) to allow growth"
384
- );
385
- }
386
-
387
- fn assert_trapping(limiter: &mut MemoryLimiter, desired: usize) {
388
- let err = limiter
389
- .memory_growing(0, desired, None)
390
- .expect_err("expected memory_growing to trap");
391
- assert!(
392
- err.downcast_ref::<MemoryLimitTrap>().is_some(),
393
- "expected MemoryLimitTrap, got {err:?}"
394
- );
395
- }
396
-
397
- // @behavior S-113
398
- #[test]
399
- fn dormant_limiter_allows_any_growth() {
400
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
401
- // Without `activate`, the cap is dormant — the module's
402
- // declared initial allocation must pass through unconditionally.
403
- assert_growing(&mut limiter, 100 << 20);
404
- }
405
-
406
- // @behavior S-114
407
- #[test]
408
- fn delta_below_cap_passes_after_activate() {
409
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
410
- limiter.activate(2 << 20);
411
- // baseline=2 MiB, desired=2.5 MiB → delta=0.5 MiB ≤ 1 MiB cap.
412
- assert_growing(&mut limiter, (2 << 20) + (1 << 19));
413
- }
414
-
415
- // @behavior S-121
416
- #[test]
417
- fn delta_past_cap_traps_with_memory_limit_trap() {
418
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
419
- limiter.activate(2 << 20);
420
- // baseline=2 MiB, desired=4 MiB → delta=2 MiB > 1 MiB cap.
421
- assert_trapping(&mut limiter, 4 << 20);
422
- }
423
-
424
- // @behavior S-008
425
- #[test]
426
- fn activate_resets_baseline_on_each_invocation() {
427
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
428
- limiter.activate(2 << 20);
429
- assert_growing(&mut limiter, (2 << 20) + (1 << 20));
430
- // Second invocation: linear memory has grown to 3 MiB. Re-arming
431
- // must re-anchor the baseline so the next 1 MiB of growth fits
432
- // the per-invocation budget rather than being charged against
433
- // the prior invocation's residue.
434
- limiter.activate(3 << 20);
435
- assert_growing(&mut limiter, (3 << 20) + (1 << 20));
436
- }
437
-
438
- // @behavior S-009
439
- #[test]
440
- fn disabled_cap_ignores_delta_size() {
441
- let mut limiter = MemoryLimiter::new(None);
442
- limiter.activate(0);
443
- assert_growing(&mut limiter, 100 << 20);
444
- }
445
-
446
- // @behavior S-116
447
- #[test]
448
- fn peak_starts_at_zero_before_any_grow() {
449
- let limiter = MemoryLimiter::new(Some(1 << 20));
450
- assert_eq!(limiter.peak(), 0);
451
- }
452
-
453
- // @behavior S-115
454
- #[test]
455
- fn peak_tracks_high_water_of_delta_past_baseline() {
456
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
457
- limiter.activate(2 << 20);
458
- assert_growing(&mut limiter, (2 << 20) + (1 << 18)); // delta=256 KiB
459
- assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB (new peak)
460
- assert_growing(&mut limiter, (2 << 20) + (1 << 17)); // delta=128 KiB (below peak)
461
- assert_eq!(limiter.peak(), 1 << 19);
462
- }
463
-
464
- // @behavior S-062
465
- #[test]
466
- fn trap_does_not_update_peak() {
467
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
468
- limiter.activate(2 << 20);
469
- assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB
470
- assert_trapping(&mut limiter, (2 << 20) + (2 << 20)); // would be 2 MiB > 1 MiB cap
471
- // Peak reflects the last accepted grow, not the rejected desired.
472
- assert_eq!(limiter.peak(), 1 << 19);
473
- }
474
-
475
- // @behavior S-117
476
- #[test]
477
- fn activate_resets_peak_for_new_invocation() {
478
- let mut limiter = MemoryLimiter::new(Some(1 << 20));
479
- limiter.activate(2 << 20);
480
- assert_growing(&mut limiter, (2 << 20) + (1 << 19));
481
- assert_eq!(limiter.peak(), 1 << 19);
482
- limiter.activate(3 << 20);
483
- assert_eq!(limiter.peak(), 0);
484
- }
485
-
486
- // @behavior S-118
487
- #[test]
488
- fn disabled_cap_still_tracks_peak() {
489
- let mut limiter = MemoryLimiter::new(None);
490
- limiter.activate(1 << 20);
491
- assert_growing(&mut limiter, (1 << 20) + (4 << 20));
492
- assert_eq!(limiter.peak(), 4 << 20);
493
- }
494
- }
@@ -0,0 +1,89 @@
1
+ //! Per-invocation I/O between the host and the guest: it writes the
2
+ //! entry's downcall into a freshly allocated guest buffer and reads the
3
+ //! Outcome the downcall answered with.
4
+
5
+ use wasmtime::Store as WtStore;
6
+
7
+ use crate::exports::Exports;
8
+ use crate::guest_mem::{self, ReadError, WriteError};
9
+ use crate::invocation::Invocation;
10
+ use kobako_runtime::error::{InvokeError, SetupError, Trap};
11
+ use kobako_transport::envelope::{CompileReply, Outcome};
12
+
13
+ /// A missing or trapping allocator is an engine fault, a `Trap`; one that
14
+ /// runs but returns 0 leaves the runtime intact, so it is a `SetupError`.
15
+ pub(crate) fn write_request(
16
+ store: &mut WtStore<Invocation>,
17
+ exports: &Exports,
18
+ envelope: &[u8],
19
+ ) -> Result<(i32, i32), InvokeError> {
20
+ let trap = |message: String| -> InvokeError { Trap::Other(message).into() };
21
+ let ptr =
22
+ guest_mem::alloc_and_write(&mut *store, exports, envelope).map_err(|err| match err {
23
+ WriteError::OutOfMemory => {
24
+ SetupError::Intact("could not allocate input buffer (out of memory)".to_string())
25
+ .into()
26
+ }
27
+ WriteError::TooLarge => trap("payload exceeds the 16 MiB limit".to_string()),
28
+ WriteError::Incompatible(message) => trap(message.to_string()),
29
+ WriteError::Trapped(e) => crate::trap::cap_trap(&e)
30
+ .unwrap_or_else(|| Trap::Other(format!("failed to allocate input buffer: {e}")))
31
+ .into(),
32
+ WriteError::OutOfBounds => {
33
+ trap("the input buffer lies outside the Sandbox's memory".to_string())
34
+ }
35
+ })?;
36
+ // The write has already held the length to the payload cap.
37
+ Ok((ptr as i32, envelope.len() as i32))
38
+ }
39
+
40
+ /// A guest that answered without an Outcome the envelope can frame — an
41
+ /// empty answer included — has nothing left to attribute, so it is a
42
+ /// `Trap` like every other way of failing to read the result.
43
+ pub(crate) fn read_outcome(
44
+ store: &WtStore<Invocation>,
45
+ exports: &Exports,
46
+ packed: u64,
47
+ ) -> Result<Outcome, Trap> {
48
+ let bytes = read_answer(store, exports, packed)?;
49
+ Outcome::decode(&bytes).map_err(|_| {
50
+ Trap::Other(
51
+ if bytes.is_empty() {
52
+ "Sandbox exited without producing a result"
53
+ } else {
54
+ "Sandbox produced an unrecognised result"
55
+ }
56
+ .to_string(),
57
+ )
58
+ })
59
+ }
60
+
61
+ /// Held to the same 16 MiB bound as an Outcome; an answer that does not
62
+ /// frame is a `Trap` the caller treats like any other failed compile.
63
+ pub(crate) fn read_compile_reply(
64
+ store: &WtStore<Invocation>,
65
+ exports: &Exports,
66
+ packed: u64,
67
+ ) -> Result<CompileReply, Trap> {
68
+ let bytes = read_answer(store, exports, packed)?;
69
+ CompileReply::decode(&bytes)
70
+ .map_err(|_| Trap::Other("Sandbox produced an unrecognised compile answer".to_string()))
71
+ }
72
+
73
+ fn read_answer(
74
+ store: &WtStore<Invocation>,
75
+ exports: &Exports,
76
+ packed: u64,
77
+ ) -> Result<Vec<u8>, Trap> {
78
+ let mem = exports.require_memory()?;
79
+ let (ptr, len) = guest_mem::unpack_guest_buffer(packed);
80
+ guest_mem::read_buffer(store, mem, ptr, len).map_err(|err| {
81
+ Trap::Other(
82
+ match err {
83
+ ReadError::TooLarge => "result payload exceeds the 16 MiB limit",
84
+ ReadError::OutOfBounds => "the Sandbox result is out of bounds",
85
+ }
86
+ .to_string(),
87
+ )
88
+ })
89
+ }