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
@@ -1,36 +1,31 @@
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,
6
+ //! the `__kobako_upcall` dispatcher reads the bound dispatch handler,
8
7
  //! while `Driver::invoke` installs the invocation's WASI
9
- //! context + pipes (via `frames::install_wasi_frames`) before the guest
8
+ //! context + pipes (via `io::install_wasi`) before the guest
10
9
  //! export call.
11
10
  //!
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
11
+ //! The slot also carries the per-invocation wall-clock deadline and the
12
+ //! memory cap `MemoryLimiter`, both read from the wasmtime
13
+ //! `epoch_deadline_callback` / `ResourceLimiter` callbacks installed in
14
+ //! `Driver::new_store`.
22
15
 
23
16
  use std::sync::Arc;
24
17
  use std::time::{Duration, Instant};
25
18
 
26
- use wasmtime::ResourceLimiter;
27
19
  use wasmtime_wasi::p1::WasiP1Ctx;
28
20
  use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
29
21
 
30
22
  use kobako_runtime::dispatch::DispatchHandler;
31
23
 
32
- /// Per-invocation host state — the data half of the Single-Invocation
33
- /// Slot. Threaded through every host import callback.
24
+ use crate::exports::Exports;
25
+ use crate::limiter::MemoryLimiter;
26
+
27
+ /// Per-invocation host state, owned by the invocation's own Store.
28
+ /// Threaded through every host import callback.
34
29
  ///
35
30
  /// All field access is mediated by methods on this type — the WASI ctx
36
31
  /// is rebuilt fresh before each invocation via
@@ -48,13 +43,11 @@ pub(crate) struct Invocation {
48
43
  limiter: MemoryLimiter,
49
44
  wall_entry: Option<Instant>,
50
45
  wall_time: Duration,
46
+ reentry_trap: Option<wasmtime::Error>,
47
+ exports: Option<Exports>,
51
48
  }
52
49
 
53
50
  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
51
  pub(crate) fn new(memory_limit: Option<usize>) -> Self {
59
52
  Self {
60
53
  wasi: None,
@@ -65,13 +58,37 @@ impl Invocation {
65
58
  limiter: MemoryLimiter::new(memory_limit),
66
59
  wall_entry: None,
67
60
  wall_time: Duration::ZERO,
61
+ reentry_trap: None,
62
+ exports: None,
68
63
  }
69
64
  }
70
65
 
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.
66
+ pub(crate) fn set_exports(&mut self, exports: Exports) {
67
+ self.exports = Some(exports);
68
+ }
69
+
70
+ /// `None` until the instance exists, so a host import reached before
71
+ /// then finds no guest to call back into.
72
+ pub(crate) fn exports(&self) -> Option<Exports> {
73
+ self.exports.clone()
74
+ }
75
+
76
+ /// The first trap wins: the upcall import ends the invocation with it,
77
+ /// so the trap keeps its own kind.
78
+ pub(crate) fn record_reentry_trap(&mut self, trap: wasmtime::Error) {
79
+ self.reentry_trap.get_or_insert(trap);
80
+ }
81
+
82
+ /// Once a callback into the guest has trapped, the guest is past
83
+ /// resuming.
84
+ pub(crate) fn reentry_trapped(&self) -> bool {
85
+ self.reentry_trap.is_some()
86
+ }
87
+
88
+ pub(crate) fn take_reentry_trap(&mut self) -> Option<wasmtime::Error> {
89
+ self.reentry_trap.take()
90
+ }
91
+
75
92
  pub(crate) fn install_wasi(
76
93
  &mut self,
77
94
  wasi: WasiP1Ctx,
@@ -83,15 +100,10 @@ impl Invocation {
83
100
  self.stderr_pipe = Some(stderr);
84
101
  }
85
102
 
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
103
  pub(crate) fn bind_on_dispatch(&mut self, handler: Arc<dyn DispatchHandler>) {
90
104
  self.on_dispatch = Some(handler);
91
105
  }
92
106
 
93
- /// Snapshot the bytes captured on guest fd 1 during the most recent
94
- /// run. Empty vec before any run.
95
107
  pub(crate) fn stdout_bytes(&self) -> Vec<u8> {
96
108
  self.stdout_pipe
97
109
  .as_ref()
@@ -99,8 +111,6 @@ impl Invocation {
99
111
  .unwrap_or_default()
100
112
  }
101
113
 
102
- /// Snapshot the bytes captured on guest fd 2 during the most recent
103
- /// run. Empty vec before any run.
104
114
  pub(crate) fn stderr_bytes(&self) -> Vec<u8> {
105
115
  self.stderr_pipe
106
116
  .as_ref()
@@ -108,387 +118,60 @@ impl Invocation {
108
118
  .unwrap_or_default()
109
119
  }
110
120
 
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`.
121
+ /// A clone, so the borrow on the `Caller` is released and the
122
+ /// dispatcher can re-borrow it to write the Reply.
116
123
  pub(crate) fn on_dispatch(&self) -> Option<Arc<dyn DispatchHandler>> {
117
124
  self.on_dispatch.clone()
118
125
  }
119
126
 
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
127
  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
- )
128
+ self.wasi
129
+ .as_mut()
130
+ .expect("WASI context not initialised — the driver must install it before any WASI use")
129
131
  }
130
132
 
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
133
  pub(crate) fn set_deadline(&mut self, deadline: Option<Instant>) {
136
134
  self.deadline = deadline;
137
135
  }
138
136
 
139
- /// Return the current per-run deadline. Read from the epoch-deadline
140
- /// callback installed by `Driver::new_store`.
141
137
  pub(crate) fn deadline(&self) -> Option<Instant> {
142
138
  self.deadline
143
139
  }
144
140
 
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
141
  pub(crate) fn limiter_mut(&mut self) -> &mut MemoryLimiter {
153
142
  &mut self.limiter
154
143
  }
155
144
 
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.
145
+ /// Only the `memory.grow` delta past `baseline` is charged, so the
146
+ /// image's initial allocation and the high-water mark of earlier
147
+ /// invocations do not consume the budget.
165
148
  pub(crate) fn arm_memory_cap(&mut self, baseline: usize) {
166
149
  self.limiter.activate(baseline);
167
150
  }
168
151
 
169
- /// Disarm the memory cap. See
170
- /// `Invocation::arm_memory_cap`.
152
+ /// Disarmed once the export returns, so post-run host bookkeeping such
153
+ /// as reading the answer back is not charged to the script.
171
154
  pub(crate) fn disarm_memory_cap(&mut self) {
172
155
  self.limiter.deactivate();
173
156
  }
174
157
 
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.
158
+ /// Stamped right before the guest export call, so the bracket matches
159
+ /// the `timeout` accounting and excludes post-run host bookkeeping.
181
160
  pub(crate) fn start_wall_clock(&mut self) {
182
161
  self.wall_entry = Some(Instant::now());
183
162
  }
184
163
 
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
164
  pub(crate) fn stop_wall_clock(&mut self) {
191
165
  if let Some(entry) = self.wall_entry.take() {
192
166
  self.wall_time = entry.elapsed();
193
167
  }
194
168
  }
195
169
 
196
- /// Return the wall-clock duration the most recent invocation
197
- /// spent inside the guest export call.
198
- /// Zero before the first invocation.
199
170
  pub(crate) fn wall_time(&self) -> Duration {
200
171
  self.wall_time
201
172
  }
202
173
 
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
174
  pub(crate) fn memory_peak(&self) -> usize {
208
175
  self.limiter.peak()
209
176
  }
210
177
  }
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
- }