kobako 0.1.2 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
@@ -1,56 +1,283 @@
1
1
  //! Per-Store host state shared with every wasmtime callback.
2
2
  //!
3
3
  //! Owned by [`StoreCell`] (a `RefCell` shim wrapping `wasmtime::Store`)
4
- //! and threaded through every host import — the `__kobako_rpc_call`
5
- //! dispatcher reads `registry`, while the run-path methods on
6
- //! [`crate::wasm::Instance`] mutate `wasi`, `stdout_pipe`, `stderr_pipe`
7
- //! when refreshing the WASI context before each `#run` (SPEC.md B-03 /
8
- //! B-04).
4
+ //! and threaded through every host import — the `__kobako_dispatch`
5
+ //! dispatcher reads the server handle, while the run-path methods on
6
+ //! [`crate::wasm::Instance`] install fresh WASI context + pipes before
7
+ //! every `#run` (SPEC.md B-03 / B-04).
8
+ //!
9
+ //! The state also carries the per-run wall-clock deadline (SPEC.md B-01,
10
+ //! E-19) and the linear-memory cap [`KobakoLimiter`] (SPEC.md B-01,
11
+ //! E-20). Both are read from the wasmtime `epoch_deadline_callback` /
12
+ //! `ResourceLimiter` callbacks installed in
13
+ //! [`crate::wasm::Instance::from_path`].
9
14
 
10
- use std::cell::RefCell;
15
+ use std::cell::{Ref, RefCell, RefMut};
16
+ use std::time::Instant;
11
17
 
12
18
  use magnus::{value::Opaque, Value};
13
- use wasmtime::Store as WtStore;
19
+ use wasmtime::{ResourceLimiter, Store as WtStore};
14
20
  use wasmtime_wasi::p1::WasiP1Ctx;
15
21
  use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
16
22
 
17
23
  /// Per-Store host state threaded through every host import callback.
18
24
  ///
19
- /// WASI p1 state is embedded as `Option<WasiP1Ctx>` so it can be replaced
20
- /// fresh before each `#run` without rebuilding the Store. The `stdout_pipe`
21
- /// and `stderr_pipe` clones are kept alongside so the Ruby layer can drain
22
- /// captured bytes after execution without touching the WASI internals.
23
- #[derive(Default)]
24
- pub(crate) struct HostState {
25
- /// Buffer mirror of guest's OUTCOME_BUFFER. Filled by `__kobako_take_outcome`
26
- /// post-execution. Reserved for a future streaming-outcome path; not yet
27
- /// consumed on the Rust side (the Ruby layer reads outcome via read_memory).
28
- #[allow(dead_code)]
29
- pub outcome: Vec<u8>,
30
- /// WASI p1 context for the current (or most-recent) run. Replaced before
31
- /// each `#run` so stdin/stdout/stderr pipes are always fresh (SPEC.md B-03).
32
- pub wasi: Option<WasiP1Ctx>,
33
- /// Clone of the MemoryOutputPipe wired to guest fd 1 (stdout). Retained
34
- /// here so `take_stdout` can call `contents()` after execution without
35
- /// having to dig into the WASI ctx internals.
36
- pub stdout_pipe: Option<MemoryOutputPipe>,
37
- /// Clone of the MemoryOutputPipe wired to guest fd 2 (stderr).
38
- pub stderr_pipe: Option<MemoryOutputPipe>,
39
- /// Ruby-side `Kobako::Registry`. When set, the `__kobako_rpc_call`
40
- /// import calls `registry.dispatch(req_bytes)` and hands the returned
41
- /// Response bytes back to the guest. `Opaque<Value>` is `Send + Sync`;
42
- /// calling `get_inner` requires a `Ruby` handle, which we obtain on
43
- /// every Ruby thread entry via `Ruby::get()`.
44
- pub registry: Option<Opaque<Value>>,
25
+ /// All field access is mediated by methods on this type the WASI ctx is
26
+ /// rebuilt fresh before each `#run` via [`HostState::install_wasi`], the
27
+ /// Ruby Server handle is set once via [`HostState::bind_server`], and
28
+ /// captured stdout/stderr bytes are read after the run via
29
+ /// [`HostState::stdout_bytes`] / [`HostState::stderr_bytes`]. The fields
30
+ /// are private so the mutation surface stays narrow.
31
+ pub(super) struct HostState {
32
+ wasi: Option<WasiP1Ctx>,
33
+ stdout_pipe: Option<MemoryOutputPipe>,
34
+ stderr_pipe: Option<MemoryOutputPipe>,
35
+ server: Option<Opaque<Value>>,
36
+ deadline: Option<Instant>,
37
+ limiter: KobakoLimiter,
45
38
  }
46
39
 
40
+ impl HostState {
41
+ /// Build a fresh per-Store host state. `memory_limit` carries the
42
+ /// `Sandbox#memory_limit` cap in bytes (or `None` to disable the cap);
43
+ /// it is read from the wasmtime [`ResourceLimiter`] callback every
44
+ /// time the guest grows linear memory (SPEC.md B-01, E-20).
45
+ pub(super) fn new(memory_limit: Option<usize>) -> Self {
46
+ Self {
47
+ wasi: None,
48
+ stdout_pipe: None,
49
+ stderr_pipe: None,
50
+ server: None,
51
+ deadline: None,
52
+ limiter: KobakoLimiter::new(memory_limit),
53
+ }
54
+ }
55
+
56
+ /// Install a freshly-built WASI context plus the matching stdout/stderr
57
+ /// pipe clones. Called from [`crate::wasm::Instance::run`] at the top
58
+ /// of every guest invocation (SPEC.md B-03 / B-04).
59
+ pub(super) fn install_wasi(
60
+ &mut self,
61
+ wasi: WasiP1Ctx,
62
+ stdout: MemoryOutputPipe,
63
+ stderr: MemoryOutputPipe,
64
+ ) {
65
+ self.wasi = Some(wasi);
66
+ self.stdout_pipe = Some(stdout);
67
+ self.stderr_pipe = Some(stderr);
68
+ }
69
+
70
+ /// Bind the Ruby-side `Kobako::RPC::Server` handle. From this point on,
71
+ /// every `__kobako_dispatch` host import invocation routes through it.
72
+ pub(super) fn bind_server(&mut self, server: Opaque<Value>) {
73
+ self.server = Some(server);
74
+ }
75
+
76
+ /// Snapshot the bytes captured on guest fd 1 during the most recent
77
+ /// run. Empty vec before any run.
78
+ pub(super) fn stdout_bytes(&self) -> Vec<u8> {
79
+ self.stdout_pipe
80
+ .as_ref()
81
+ .map(|p| p.contents().to_vec())
82
+ .unwrap_or_default()
83
+ }
84
+
85
+ /// Snapshot the bytes captured on guest fd 2 during the most recent
86
+ /// run. Empty vec before any run.
87
+ pub(super) fn stderr_bytes(&self) -> Vec<u8> {
88
+ self.stderr_pipe
89
+ .as_ref()
90
+ .map(|p| p.contents().to_vec())
91
+ .unwrap_or_default()
92
+ }
93
+
94
+ /// Return the bound Server handle. `Opaque<Value>` is `Copy`, so the
95
+ /// handle is returned by value rather than by reference. None means no
96
+ /// Server has been bound yet via [`HostState::bind_server`].
97
+ pub(super) fn server(&self) -> Option<Opaque<Value>> {
98
+ self.server
99
+ }
100
+
101
+ /// Mutable handle to the live WASI context. Panics if no context has
102
+ /// been installed yet — every call site is downstream of
103
+ /// [`HostState::install_wasi`] running at the top of `Instance::run`,
104
+ /// so reaching this branch with `None` signals a host-side wiring bug.
105
+ pub(super) fn wasi_mut(&mut self) -> &mut WasiP1Ctx {
106
+ self.wasi
107
+ .as_mut()
108
+ .expect("WASI context not initialised — call Instance#run before any WASI use")
109
+ }
110
+
111
+ /// Replace the per-run wall-clock deadline. `Some(at)` makes the
112
+ /// epoch-deadline callback trap once `Instant::now() >= at`; `None`
113
+ /// disables the cap. Called at the top of every `#run` (SPEC.md B-01).
114
+ pub(super) fn set_deadline(&mut self, deadline: Option<Instant>) {
115
+ self.deadline = deadline;
116
+ }
117
+
118
+ /// Return the current per-run deadline. Read from the epoch-deadline
119
+ /// callback installed by [`crate::wasm::Instance::from_path`].
120
+ pub(super) fn deadline(&self) -> Option<Instant> {
121
+ self.deadline
122
+ }
123
+
124
+ /// Mutable handle to the embedded [`KobakoLimiter`]. Shared by the
125
+ /// wasmtime [`ResourceLimiter`] callback (set once at Store build
126
+ /// time) and by [`crate::wasm::Instance`] for arming / disarming the
127
+ /// memory cap around each guest run. Same shape as
128
+ /// [`HostState::wasi_mut`] — callers operate on the inner type
129
+ /// directly instead of going through a per-action passthrough.
130
+ pub(super) fn limiter_mut(&mut self) -> &mut KobakoLimiter {
131
+ &mut self.limiter
132
+ }
133
+ }
134
+
135
+ /// Resource limiter that enforces the `memory_limit` cap from SPEC.md
136
+ /// B-01 / E-20 on every guest `memory.grow`.
137
+ ///
138
+ /// `max_memory` is the byte cap (`None` disables the cap). `cap_active`
139
+ /// gates whether the cap is enforced — wasmtime's `ResourceLimiter`
140
+ /// fires for both the module's declared initial allocation and every
141
+ /// subsequent `memory.grow`, but SPEC.md E-20 scopes the trap to
142
+ /// `memory.grow` specifically. [`KobakoLimiter::activate`] /
143
+ /// [`KobakoLimiter::deactivate`] flip the flag for the lifetime of an
144
+ /// `Instance::run` call. When `cap_active` is `false`, the limiter
145
+ /// always allows growth.
146
+ ///
147
+ /// When `memory.grow` would push linear memory past the cap, the
148
+ /// limiter returns [`MemoryLimitTrap`] from `memory_growing`; wasmtime
149
+ /// turns that into the trap surfaced to the host as `__kobako_run`
150
+ /// failure.
151
+ #[derive(Debug, Clone, Copy)]
152
+ pub(super) struct KobakoLimiter {
153
+ max_memory: Option<usize>,
154
+ cap_active: bool,
155
+ }
156
+
157
+ impl KobakoLimiter {
158
+ fn new(max_memory: Option<usize>) -> Self {
159
+ Self {
160
+ max_memory,
161
+ cap_active: false,
162
+ }
163
+ }
164
+
165
+ /// Arm the cap so subsequent `memory.grow` calls are checked
166
+ /// against `memory_limit`. The cap is dormant by default — the
167
+ /// module's declared initial memory is allocated during
168
+ /// `Linker::instantiate` and SPEC.md E-20 scopes the trap to
169
+ /// `memory.grow` (not the instantiation-time initial allocation).
170
+ /// [`crate::wasm::Instance::run`] calls this right before
171
+ /// `__kobako_run`.
172
+ pub(super) fn activate(&mut self) {
173
+ self.cap_active = true;
174
+ }
175
+
176
+ /// Disarm the cap so post-run host bookkeeping (e.g. fetching the
177
+ /// OUTCOME_BUFFER, which can grow guest memory transiently) is
178
+ /// not attributed to the user script. Paired with
179
+ /// [`KobakoLimiter::activate`].
180
+ pub(super) fn deactivate(&mut self) {
181
+ self.cap_active = false;
182
+ }
183
+ }
184
+
185
+ impl ResourceLimiter for KobakoLimiter {
186
+ fn memory_growing(
187
+ &mut self,
188
+ _current: usize,
189
+ desired: usize,
190
+ _maximum: Option<usize>,
191
+ ) -> wasmtime::Result<bool> {
192
+ if !self.cap_active {
193
+ return Ok(true);
194
+ }
195
+ if let Some(limit) = self.max_memory {
196
+ if desired > limit {
197
+ return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
198
+ }
199
+ }
200
+ Ok(true)
201
+ }
202
+
203
+ fn table_growing(
204
+ &mut self,
205
+ _current: usize,
206
+ _desired: usize,
207
+ _maximum: Option<usize>,
208
+ ) -> wasmtime::Result<bool> {
209
+ Ok(true)
210
+ }
211
+ }
212
+
213
+ /// Marker error returned from [`KobakoLimiter::memory_growing`] on
214
+ /// SPEC.md E-20. Downcast from the wasmtime trap error to surface as
215
+ /// `Kobako::Wasm::MemoryLimitError` on the Ruby side. Callers use the
216
+ /// `Display` impl below — no field is read directly — so the inner
217
+ /// state stays private.
218
+ #[derive(Debug)]
219
+ pub(crate) struct MemoryLimitTrap {
220
+ desired: usize,
221
+ limit: usize,
222
+ }
223
+
224
+ impl std::fmt::Display for MemoryLimitTrap {
225
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226
+ write!(
227
+ f,
228
+ "guest memory.grow would exceed memory_limit: desired={} bytes, limit={} bytes",
229
+ self.desired, self.limit
230
+ )
231
+ }
232
+ }
233
+
234
+ impl std::error::Error for MemoryLimitTrap {}
235
+
236
+ /// Marker error returned from the epoch-deadline callback on SPEC.md
237
+ /// E-19. Downcast from the wasmtime trap error to surface as
238
+ /// `Kobako::Wasm::TimeoutError` on the Ruby side.
239
+ #[derive(Debug)]
240
+ pub(crate) struct TimeoutTrap;
241
+
242
+ impl std::fmt::Display for TimeoutTrap {
243
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244
+ write!(f, "guest exceeded the configured wall-clock timeout")
245
+ }
246
+ }
247
+
248
+ impl std::error::Error for TimeoutTrap {}
249
+
47
250
  /// Interior-mutability wrapper around `wasmtime::Store<HostState>`.
48
251
  ///
49
252
  /// Magnus requires `Send + Sync` for wrapped types. `wasmtime::Store` is not
50
253
  /// `Sync`, so we wrap it in a `RefCell`. `RefCell` alone is sufficient
51
254
  /// because magnus enforces single-threaded GVL access from Ruby; `Send` and
52
255
  /// `Sync` are asserted via the unsafe impls below.
53
- pub(crate) struct StoreCell(pub(crate) RefCell<WtStore<HostState>>);
256
+ pub(super) struct StoreCell {
257
+ inner: RefCell<WtStore<HostState>>,
258
+ }
259
+
260
+ impl StoreCell {
261
+ /// Wrap a freshly-built `wasmtime::Store<HostState>` so it can be owned
262
+ /// by the magnus-wrapped `Instance`.
263
+ pub(super) fn new(store: WtStore<HostState>) -> Self {
264
+ Self {
265
+ inner: RefCell::new(store),
266
+ }
267
+ }
268
+
269
+ /// Immutable borrow of the wrapped Store. Panics if a `borrow_mut()`
270
+ /// is currently live — matches `RefCell::borrow` semantics.
271
+ pub(super) fn borrow(&self) -> Ref<'_, WtStore<HostState>> {
272
+ self.inner.borrow()
273
+ }
274
+
275
+ /// Mutable borrow of the wrapped Store. Panics if any other borrow is
276
+ /// currently live — matches `RefCell::borrow_mut` semantics.
277
+ pub(super) fn borrow_mut(&self) -> RefMut<'_, WtStore<HostState>> {
278
+ self.inner.borrow_mut()
279
+ }
280
+ }
54
281
 
55
282
  // SAFETY: Ruby's GVL serialises access to magnus-wrapped objects on a single
56
283
  // OS thread at a time. `wasmtime::Store` is `Send` (verified upstream); the