kobako 0.3.0 → 0.5.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +85 -6
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +1 -1
  8. data/ext/kobako/src/lib.rs +4 -2
  9. data/ext/kobako/src/{wasm → runtime}/cache.rs +22 -18
  10. data/ext/kobako/src/runtime/capture.rs +91 -0
  11. data/ext/kobako/src/runtime/config.rs +26 -0
  12. data/ext/kobako/src/runtime/dispatch.rs +211 -0
  13. data/ext/kobako/src/runtime/exports.rs +51 -0
  14. data/ext/kobako/src/runtime/guest_mem.rs +228 -0
  15. data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +195 -81
  16. data/ext/kobako/src/runtime/trap.rs +134 -0
  17. data/ext/kobako/src/runtime.rs +782 -0
  18. data/ext/kobako/src/snapshot.rs +110 -0
  19. data/lib/kobako/capture.rb +11 -16
  20. data/lib/kobako/catalog/handles.rb +107 -0
  21. data/lib/kobako/catalog/namespaces.rb +99 -0
  22. data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
  23. data/lib/kobako/catalog.rb +18 -0
  24. data/lib/kobako/codec/decoder.rb +13 -7
  25. data/lib/kobako/codec/factory.rb +21 -18
  26. data/lib/kobako/codec/utils.rb +118 -29
  27. data/lib/kobako/codec.rb +6 -3
  28. data/lib/kobako/errors.rb +45 -28
  29. data/lib/kobako/fault.rb +40 -0
  30. data/lib/kobako/handle.rb +60 -0
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +55 -29
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +131 -67
  35. data/lib/kobako/sandbox_options.rb +6 -9
  36. data/lib/kobako/snapshot.rb +40 -0
  37. data/lib/kobako/snippet/binary.rb +6 -7
  38. data/lib/kobako/snippet/source.rb +8 -8
  39. data/lib/kobako/snippet.rb +7 -9
  40. data/lib/kobako/transport/dispatcher.rb +195 -0
  41. data/lib/kobako/transport/error.rb +24 -0
  42. data/lib/kobako/transport/request.rb +78 -0
  43. data/lib/kobako/transport/response.rb +69 -0
  44. data/lib/kobako/transport/run.rb +141 -0
  45. data/lib/kobako/transport/yield.rb +91 -0
  46. data/lib/kobako/transport/yielder.rb +89 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/usage.rb +41 -0
  49. data/lib/kobako/version.rb +1 -1
  50. data/lib/kobako.rb +4 -3
  51. data/release-please-config.json +24 -0
  52. data/sig/kobako/capture.rbs +0 -2
  53. data/sig/kobako/{rpc/handle_table.rbs → catalog/handles.rbs} +3 -9
  54. data/sig/kobako/catalog/namespaces.rbs +17 -0
  55. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  56. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  57. data/sig/kobako/codec/decoder.rbs +2 -1
  58. data/sig/kobako/codec/factory.rbs +3 -3
  59. data/sig/kobako/codec/utils.rbs +11 -1
  60. data/sig/kobako/errors.rbs +7 -7
  61. data/sig/kobako/fault.rbs +19 -0
  62. data/sig/kobako/handle.rbs +18 -0
  63. data/sig/kobako/namespace.rbs +19 -0
  64. data/sig/kobako/outcome.rbs +2 -2
  65. data/sig/kobako/runtime.rbs +23 -0
  66. data/sig/kobako/sandbox.rbs +10 -7
  67. data/sig/kobako/snapshot.rbs +15 -0
  68. data/sig/kobako/transport/dispatcher.rbs +34 -0
  69. data/sig/kobako/transport/error.rbs +6 -0
  70. data/sig/kobako/transport/request.rbs +32 -0
  71. data/sig/kobako/transport/response.rbs +30 -0
  72. data/sig/kobako/transport/run.rbs +27 -0
  73. data/sig/kobako/transport/yield.rbs +34 -0
  74. data/sig/kobako/transport/yielder.rbs +21 -0
  75. data/sig/kobako/transport.rbs +4 -0
  76. data/sig/kobako/usage.rbs +11 -0
  77. metadata +52 -30
  78. data/ext/kobako/src/wasm/dispatch.rs +0 -161
  79. data/ext/kobako/src/wasm/instance.rs +0 -771
  80. data/ext/kobako/src/wasm.rs +0 -125
  81. data/lib/kobako/invocation.rb +0 -112
  82. data/lib/kobako/rpc/dispatcher.rb +0 -169
  83. data/lib/kobako/rpc/envelope.rb +0 -118
  84. data/lib/kobako/rpc/fault.rb +0 -41
  85. data/lib/kobako/rpc/handle.rb +0 -39
  86. data/lib/kobako/rpc/handle_table.rb +0 -107
  87. data/lib/kobako/rpc/namespace.rb +0 -74
  88. data/lib/kobako/rpc/server.rb +0 -158
  89. data/lib/kobako/rpc.rb +0 -11
  90. data/lib/kobako/wasm.rb +0 -25
  91. data/sig/kobako/invocation.rbs +0 -23
  92. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  93. data/sig/kobako/rpc/envelope.rbs +0 -51
  94. data/sig/kobako/rpc/fault.rbs +0 -20
  95. data/sig/kobako/rpc/handle.rbs +0 -19
  96. data/sig/kobako/rpc/namespace.rbs +0 -24
  97. data/sig/kobako/rpc/server.rbs +0 -37
  98. data/sig/kobako/wasm.rbs +0 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddc9be38e5ce7f23c176bcd7be8b8683cf58d308dfedb17e91fc5841308a5c8c
4
- data.tar.gz: 4a0de9e36b529010b50148ff9d904cafbe253b0c394a9a2118e6ef4d7c9e4029
3
+ metadata.gz: fd04d7b8efaf9ccb41ff873a66b0855839222c5bc034d80a854b9fbc25602570
4
+ data.tar.gz: 718665fbcfb89faafc86f018a247e37ea304c3c8559e4febc240ad95bdc15654
5
5
  SHA512:
6
- metadata.gz: ae0e599389ce723a1c257923a5b5b760ddf94a71698e5fa990f82587494ae8ca874d11c8c1fd5eedfac0d6e953f53cfb8603fb4da34989f8f92e39425bb99fa5
7
- data.tar.gz: 6cbdec819843c52c1be3d04a6694af51ae41121e159a4de12d1ebef0074d1626247c8c06db0e9f15da2deb19b4b1730c124aa027bc676ffc2b1fedf6cd68d3c7
6
+ metadata.gz: e9cd6493f200abbb9a9014e9222633fec67e8813a854b93e71a29d94a3738ff64dfe33f5b136efe472d87831fb78062a09ac481d1da6970782e965a7a7aeafc8
7
+ data.tar.gz: 7f11f9fb3e48efc808eebd0598bc19569c6c1fd90322cee2f65f9030d8ca39a3da6cbe9fa68edb33480e926aabcb3766c2dc8c37995945ceaebd7b9952d9ec0f
@@ -0,0 +1 @@
1
+ {".":"0.5.0"}
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [0.5.0](https://github.com/elct9620/kobako/compare/v0.4.0...v0.5.0) (2026-05-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **abi:** add `__kobako_yield_to_block` skeleton + host re-entry channel ([555eb4b](https://github.com/elct9620/kobako/commit/555eb4bf578c3c4397ba2c0d105c0d3ca687e23c))
9
+ * **abi:** classify RBreak via ci_break_index for B-25 / E-21 ([32668a0](https://github.com/elct9620/kobako/commit/32668a033e2f959700acadadfbc41388ed72a2dd))
10
+ * **abi:** wire `__kobako_yield_to_block` to real `mrb_yield_argv` ([35aeac8](https://github.com/elct9620/kobako/commit/35aeac8700254d1500f5be837a72c56984a7ebfa))
11
+ * **bench:** add noise-aware release gate, report mean alongside median ([0cfaebc](https://github.com/elct9620/kobako/commit/0cfaebc2afadfae81e3d00441273da70e396d7a5))
12
+ * **bench:** add yield round-trip suite as gated benchmark [#6](https://github.com/elct9620/kobako/issues/6) ([315f923](https://github.com/elct9620/kobako/commit/315f923caa89bcd8752a611525da68ae53ae092f))
13
+ * **catalog:** introduce empty Kobako::Catalog namespace ([8af8c54](https://github.com/elct9620/kobako/commit/8af8c54c72e5e5193555bcc2e86072d4a4d8176d))
14
+ * **ext:** enforce the 16 MiB single-dispatch payload cap on host boundaries ([c80e281](https://github.com/elct9620/kobako/commit/c80e281e0810640c60d93174beddd49a31c34182))
15
+ * **guest:** capture guest blocks via `n*&` argspec + LIFO BLOCK_STACK ([aa55556](https://github.com/elct9620/kobako/commit/aa55556aab23c159078d0ba0ea47ed878b26e89d))
16
+ * **rpc:** build block proxy for guest-supplied yield blocks ([b6d6cf7](https://github.com/elct9620/kobako/commit/b6d6cf7f5ca857f55aafea62631b243f688c61a6))
17
+ * **rpc:** catch/throw + frame invalidator close B-25 / B-28 / E-23 ([3b21f25](https://github.com/elct9620/kobako/commit/3b21f252fafdd2070f3953460509e24a0e643d88))
18
+ * **transport:** introduce empty Kobako::Transport namespace ([85cda26](https://github.com/elct9620/kobako/commit/85cda268000490f521424339bec1664d0b33478b))
19
+ * **wire:** add `block_given` field to Request envelope ([30e004f](https://github.com/elct9620/kobako/commit/30e004fa8f00739e68883889c5225c98cf9521fe))
20
+ * **wire:** add YieldResponse envelope codec on both sides ([4592567](https://github.com/elct9620/kobako/commit/459256784af616d70738ffd0f56c3b15244b3e7c))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **bench:** restore renamed class references so rake bench runs ([76140cc](https://github.com/elct9620/kobako/commit/76140cc99922973fc305aab6ba727a832ddbe7ba))
26
+ * **ext:** GC-root the dispatch Proc via a pinning mark on Kobako::Runtime ([f31bd07](https://github.com/elct9620/kobako/commit/f31bd071201b5fed7376bd13b876f103d6c6a5d6))
27
+ * **ext:** raise SandboxError, not TrapError, when #run envelope alloc fails ([a1981fe](https://github.com/elct9620/kobako/commit/a1981fea7438090a76758147e7e84543e9d96968))
28
+ * **transport:** fill E-xx placeholder and drop BLOCK_RESEARCH citations ([816ff80](https://github.com/elct9620/kobako/commit/816ff804535196036bec01fcd980e25036211b80))
29
+ * **wasm:** reject unrepresentable guest return values instead of stringifying ([c3fd069](https://github.com/elct9620/kobako/commit/c3fd0698cb168b55502fb86065406caf9a7744e1))
data/Cargo.lock CHANGED
@@ -864,7 +864,7 @@ dependencies = [
864
864
 
865
865
  [[package]]
866
866
  name = "kobako"
867
- version = "0.3.0"
867
+ version = "0.5.0"
868
868
  dependencies = [
869
869
  "magnus",
870
870
  "wasmtime",
data/README.md CHANGED
@@ -27,10 +27,11 @@ The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby an
27
27
  | Per-invocation caps | Every invocation enforces a wall-clock `timeout` (default 60 s) and a per-invocation linear-memory `memory_limit` (default 1 MiB); exhaustion raises `Kobako::TimeoutError` / `Kobako::MemoryLimitError`. |
28
28
  | Capability injection via Services | Guest scripts can only call Ruby objects you explicitly `bind` under a two-level `Namespace::Member` path. |
29
29
  | Preloaded snippets | `Sandbox#preload` registers source or RITE bytecode for setup-once dispatch via `Sandbox#run(:Entrypoint, *args, **kwargs)`. |
30
- | Capability Handles | Services may return stateful host objects; the guest receives an opaque `Kobako::RPC::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it. |
30
+ | Capability Handles | Services may return stateful host objects; the guest receives an opaque `Kobako::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it. `Sandbox#run` also accepts non-wire-representable Ruby objects as args and auto-wraps them into Handles, so the guest can use any host object the script needs. |
31
31
  | Three-class error taxonomy | Every failure is exactly one of `TrapError`, `SandboxError`, or `ServiceError`, so you can route errors without inspecting messages. |
32
32
  | Per-invocation state reset | Handles issued during one invocation are invalidated before the next; Service bindings and preloaded snippets remain. |
33
33
  | Separated stdout / stderr capture | Guest writes to `$stdout` / `$stderr` are buffered per-channel (1 MiB default cap, configurable); overflow is clipped and reported by `#stdout_truncated?` / `#stderr_truncated?`. |
34
+ | Per-invocation usage readout | `Sandbox#usage` returns the most recent invocation's `wall_time` (Float seconds spent inside the wasm guest) and `memory_peak` (high-water `memory.grow` delta in bytes), populated on every outcome including `TrapError`, for budget diagnostics. |
34
35
  | Curated mruby stdlib | Core extensions plus `mruby-onig-regexp` for full Onigmo `Regexp` support; no mrbgem with I/O, network, or syscall access is bundled. |
35
36
 
36
37
  ## Requirements
@@ -122,6 +123,19 @@ The timeout deadline is absolute wall-clock from invocation entry and is checked
122
123
 
123
124
  The 1 MiB default targets lightweight dynamic RPC workloads — short scripts that orchestrate Service calls, return small structured values, or replace a tool-calling layer in an AI Agent's Code Mode dispatch. Bump `memory_limit` when scripts compose multi-hundred-KiB strings, hold large composite return values, or run computations that allocate substantial intermediate state. Because the cap resets every invocation, multi-call patterns on one Sandbox do not need a budget that covers their cumulative footprint — only the largest single invocation's working set.
124
125
 
126
+ To see how much of the cap an invocation actually consumed, read `Sandbox#usage` after the call. It returns a `Kobako::Usage` value object with `wall_time` (Float seconds the guest export call spent inside wasmtime, aligned with the `timeout` accounting) and `memory_peak` (Integer high-water `memory.grow` delta in bytes, aligned with the `memory_limit` accounting). The fields are populated on every outcome, including the `TrapError` branches, so you can read them after rescuing a trap to diagnose which budget the failing invocation chewed through.
127
+
128
+ ```ruby
129
+ sandbox = Kobako::Sandbox.new(timeout: 1.0, memory_limit: 4 * 1024 * 1024)
130
+
131
+ begin
132
+ sandbox.eval("'x' * 5_000_000")
133
+ rescue Kobako::MemoryLimitError
134
+ sandbox.usage.memory_peak # => the largest delta accepted before the trap
135
+ sandbox.usage.wall_time # => seconds spent before the cap fired
136
+ end
137
+ ```
138
+
125
139
  ## Capturing stdout and stderr
126
140
 
127
141
  Guest output is captured into per-invocation buffers and exposed independently from the return value. The buffers cover the full Ruby IO surface — `puts`, `print`, `printf`, `p`, `<<`, and writes through `$stdout` / `$stderr` — all routed through the host-captured WASI pipe.
@@ -177,13 +191,12 @@ end
177
191
  |----------------------------------------|--------------------|------------------------------------------------------------------------------------------|
178
192
  | `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
179
193
  | `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
180
- | `Kobako::ServiceError::Disconnected` | `ServiceError` | RPC target Handle has been invalidated |
181
194
  | `Kobako::HandleTableExhausted` | `SandboxError` | Per-invocation Handle counter reached its 2³¹ − 1 cap |
182
195
  | `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` payload failed RITE structural validation at first invocation replay |
183
196
 
184
197
  ## Capability Handles
185
198
 
186
- When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::RPC::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
199
+ When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::Handle` proxy it can use as the target of further RPC calls — but cannot dereference, forge from an integer, or smuggle across runs.
187
200
 
188
201
  ```ruby
189
202
  class Greeter
@@ -194,18 +207,69 @@ end
194
207
  sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
195
208
 
196
209
  sandbox.eval(<<~RUBY)
197
- g = Factory::Make.call("Bob") # g is a Kobako::RPC::Handle proxy
210
+ g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
198
211
  g.greet # second RPC, routed to the Greeter
199
212
  RUBY
200
213
  # => "hi, Bob"
201
214
  ```
202
215
 
216
+ `Sandbox#run` accepts non-wire-representable host objects as args / kwargs values too: the host walks the argument tree, wraps every non-wire leaf through the same Handle path, and the guest sees a `Kobako::Handle` proxy in its place. This lets you pass framework objects (a Rack `env` Hash containing an `IO`-like body, an active record, an enumerator) into the entrypoint without first marshalling them into primitives.
217
+
218
+ ```ruby
219
+ require "stringio"
220
+
221
+ sandbox = Kobako::Sandbox.new
222
+ sandbox.preload(code: "Echo = ->(body) { body.read.upcase }", name: :Echo)
223
+
224
+ sandbox.run(:Echo, StringIO.new("hello world"))
225
+ # => "HELLO WORLD"
226
+ ```
227
+
203
228
  Handles are scoped to a single invocation — a Handle obtained in invocation N is invalid in invocation N+1, even on the same Sandbox.
204
229
 
205
230
  ## Setup-once, run-many
206
231
 
207
232
  A single Sandbox can serve many invocations. Service bindings and preloaded snippets persist; capability state (Handles, stdout, stderr) resets between invocations.
208
233
 
234
+ ```
235
+ ───────────── setup phase (mutable) ─────────────
236
+
237
+ sandbox = Kobako::Sandbox.new
238
+ sandbox.define(:KV).bind(:Lookup, ...)
239
+ sandbox.preload(code: ..., name: :Adder)
240
+ sandbox.preload(code: ..., name: :Greeter)
241
+
242
+
243
+
244
+
245
+ ═════════════════ seal point ═════════════════
246
+ First #eval or #run freezes the Service registry
247
+ and snippet table. Further define / preload now
248
+ raise ArgumentError.
249
+
250
+
251
+
252
+
253
+ ──────────────── invocation N ───────────────────
254
+
255
+ 1. allocate fresh mrb_state
256
+
257
+ 2. replay snippets (in insertion order):
258
+ :Adder → defines Adder
259
+ :Greeter → defines Greeter
260
+
261
+ 3. dispatch: eval(source) or run(:Target, *args)
262
+
263
+ 4. return value to host
264
+
265
+ 5. discard mrb_state; reset per-invocation state:
266
+ · Handles invalidated
267
+ · stdout / stderr buffers cleared
268
+ · memory delta zeroed
269
+
270
+ Services + snippets persist; invocation N+1 repeats.
271
+ ```
272
+
209
273
  ```ruby
210
274
  sandbox = Kobako::Sandbox.new
211
275
  sandbox.define(:Data).bind(:Fetch, ->(id) { records[id] })
@@ -240,6 +304,21 @@ The source form trial-compiles each snippet against a fresh `mrb_state` at prelo
240
304
 
241
305
  Snippets replay in insertion order, so later snippets can reference constants defined by earlier ones. The snippet table is sealed by the first invocation alongside Service registration; additional `#preload` calls after the first `#eval` or `#run` raise `ArgumentError`.
242
306
 
307
+ ```
308
+ per-invocation replay (every #eval / #run, snippets in insertion order):
309
+
310
+ fresh mrb_state
311
+
312
+ ├──▶ replay :Adder (defines Adder)
313
+
314
+ ├──▶ replay :Greeter (defines Greeter)
315
+
316
+ └──▶ eval(source) -or- run(:Target, *args, **kwargs)
317
+
318
+
319
+ return value, then mrb_state discarded
320
+ ```
321
+
243
322
  `#run` resolves `target` (Symbol or String, normalized to Symbol) only as a top-level `Object` constant — `::`-segmented names and lowercase forms fail at host pre-flight with `ArgumentError`. A `Kobako::SandboxError` surfaces when the constant is missing or does not respond to `#call`.
244
323
 
245
324
  ### Choosing between source and bytecode
@@ -273,9 +352,9 @@ The ~600 ms cold start dominates the first Sandbox in a process — wasmtime JIT
273
352
 
274
353
  | Allocation | Cost |
275
354
  |---------------------------------------------|----------------------------------------------------------------------------|
276
- | Process RSS after first `Sandbox.new` | ~150-180 MB (one-time engine + module + first instance) |
355
+ | Process RSS after first `Sandbox.new` | ~165-195 MB (one-time engine + module + first instance) |
277
356
  | Per additional Sandbox | ~580 KB (Wasm instance + linear memory + WASI capture pipes) |
278
- | 1 000 isolated tenants in one process | ~750 MB total |
357
+ | 1 000 isolated tenants in one process | ~765 MB total |
279
358
 
280
359
  Use these as upper-bound budgets for capacity planning, not lower bounds — actual RSS shifts ~30% with host process load and macOS allocator state.
281
360
 
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -1,10 +1,12 @@
1
1
  use magnus::{Error, Ruby};
2
2
 
3
- mod wasm;
3
+ mod runtime;
4
+ mod snapshot;
4
5
 
5
6
  #[magnus::init]
6
7
  fn init(ruby: &Ruby) -> Result<(), Error> {
7
8
  let module = ruby.define_module("Kobako")?;
8
- wasm::init(ruby, module)?;
9
+ runtime::init(ruby, module)?;
10
+ snapshot::init(ruby, module)?;
9
11
  Ok(())
10
12
  }
@@ -1,22 +1,18 @@
1
- //! Process-wide caches for the wasmtime [`Engine`] and compiled
2
- //! [`Module`].
1
+ //! Process-wide caches for the wasmtime `Engine` and compiled
2
+ //! `Module`.
3
3
  //!
4
4
  //! SPEC.md "Code Organization" pins `ext/` as private and forbids
5
5
  //! exposing wasm engine types to the Host App or downstream gems. To
6
6
  //! amortise Engine creation and Module JIT compilation across multiple
7
7
  //! `Kobako::Sandbox` constructions, the ext keeps a process-scope
8
8
  //! shared Engine and a per-path Module cache. Both are transparent to
9
- //! Ruby callers, who construct an `Instance` via
10
- //! `Kobako::Wasm::Instance.from_path(...)` and never see Engine or
11
- //! Module.
9
+ //! Ruby callers, who construct a `Runtime` via
10
+ //! `Kobako::Runtime.from_path(...)` and never see Engine or Module.
12
11
  //!
13
12
  //! Concurrency: under Ruby's GVL only one thread can execute Rust code
14
13
  //! at a time, so the Mutex is held briefly during HashMap insert/lookup
15
14
  //! and serves to satisfy `Sync` bounds rather than to arbitrate real
16
15
  //! contention.
17
- //!
18
- //! [`Engine`]: wasmtime::Engine
19
- //! [`Module`]: wasmtime::Module
20
16
 
21
17
  use std::collections::HashMap;
22
18
  use std::fs;
@@ -28,7 +24,7 @@ use std::time::Duration;
28
24
  use magnus::{Error as MagnusError, Ruby};
29
25
  use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
30
26
 
31
- use super::{wasm_err, MODULE_NOT_BUILT_ERROR};
27
+ use super::{setup_err, MODULE_NOT_BUILT_ERROR};
32
28
 
33
29
  static SHARED_ENGINE: OnceLock<WtEngine> = OnceLock::new();
34
30
  static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new();
@@ -55,7 +51,7 @@ const EPOCH_TICK: Duration = Duration::from_millis(10);
55
51
  /// Also enables `epoch_interruption(true)` so every Store can install an
56
52
  /// `epoch_deadline_callback` for the per-run wall-clock cap
57
53
  /// (docs/behavior.md B-01, E-19). The first call spawns the process-singleton ticker
58
- /// thread that drives `engine.increment_epoch()` at [`EPOCH_TICK`]
54
+ /// thread that drives `engine.increment_epoch()` at `EPOCH_TICK`
59
55
  /// cadence; subsequent calls reuse the same engine and ticker.
60
56
  pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
61
57
  if let Some(engine) = SHARED_ENGINE.get() {
@@ -66,7 +62,7 @@ pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
66
62
  config.epoch_interruption(true);
67
63
  let engine = WtEngine::new(&config).map_err(|e| {
68
64
  let ruby = Ruby::get().expect("Ruby thread");
69
- wasm_err(&ruby, format!("engine init: {}", e))
65
+ setup_err(&ruby, format!("engine init: {}", e))
70
66
  })?;
71
67
  let engine = SHARED_ENGINE.get_or_init(|| engine);
72
68
  spawn_epoch_ticker(engine.clone());
@@ -75,8 +71,8 @@ pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
75
71
 
76
72
  /// Spawn the process-singleton epoch ticker. The thread holds a clone of
77
73
  /// the shared Engine (`wasmtime::Engine` is reference-counted internally)
78
- /// and ticks the epoch counter at [`EPOCH_TICK`] cadence. Idempotent
79
- /// across reentrant calls to [`shared_engine`] because [`OnceLock`]
74
+ /// and ticks the epoch counter at `EPOCH_TICK` cadence. Idempotent
75
+ /// across reentrant calls to `shared_engine` because `OnceLock`
80
76
  /// gates the spawn.
81
77
  fn spawn_epoch_ticker(engine: WtEngine) {
82
78
  static TICKER_SPAWNED: OnceLock<()> = OnceLock::new();
@@ -92,7 +88,7 @@ fn spawn_epoch_ticker(engine: WtEngine) {
92
88
  }
93
89
 
94
90
  /// Look up `path` in the per-path Module cache, compiling and inserting
95
- /// the artifact on a miss. Raises `Kobako::Wasm::ModuleNotBuiltError`
91
+ /// the artifact on a miss. Raises `Kobako::ModuleNotBuiltError`
96
92
  /// when the file is missing — the headline error for the common
97
93
  /// pre-build state on a fresh clone before `rake compile`.
98
94
  pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
@@ -112,16 +108,24 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
112
108
  return Err(MagnusError::new(
113
109
  ruby.get_inner(&MODULE_NOT_BUILT_ERROR),
114
110
  format!(
115
- "wasm module not found at {}; run `bundle exec rake wasm:build` to build it",
111
+ "Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
116
112
  path.display()
117
113
  ),
118
114
  ));
119
115
  }
120
116
 
121
- let bytes =
122
- fs::read(path).map_err(|e| wasm_err(&ruby, format!("read {}: {}", path.display(), e)))?;
117
+ let bytes = fs::read(path).map_err(|e| {
118
+ setup_err(
119
+ &ruby,
120
+ format!(
121
+ "failed to read Sandbox runtime at {}: {}",
122
+ path.display(),
123
+ e
124
+ ),
125
+ )
126
+ })?;
123
127
  let module = WtModule::new(shared_engine()?, &bytes)
124
- .map_err(|e| wasm_err(&ruby, format!("compile module: {}", e)))?;
128
+ .map_err(|e| setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
125
129
  cache
126
130
  .lock()
127
131
  .expect("module cache mutex poisoned")
@@ -0,0 +1,91 @@
1
+ //! Per-channel stdout / stderr capture sizing and clipping.
2
+ //!
3
+ //! Two pure helpers shared by the run path (docs/behavior.md B-04): one
4
+ //! sizes the per-run `MemoryOutputPipe`, the other clips a captured
5
+ //! snapshot back to the configured cap and reports whether the cap was
6
+ //! exceeded. Kept channel-agnostic (a function of `cap`, not of which
7
+ //! channel) so a regression that only breaks one channel cannot sneak
8
+ //! through the test that pins them.
9
+
10
+ /// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
11
+ /// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
12
+ /// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
13
+ /// `usize::MAX` when the channel is uncapped.
14
+ pub(super) fn pipe_capacity(cap: Option<usize>) -> usize {
15
+ match cap {
16
+ Some(c) => c.saturating_add(1),
17
+ None => usize::MAX,
18
+ }
19
+ }
20
+
21
+ /// Pure slicing core shared by the snapshot readback: given the unclipped
22
+ /// pipe snapshot and the configured cap, return the bytes Ruby should
23
+ /// observe (clipped to `cap`) plus the truncation flag. `truncated` is
24
+ /// `true` only when the snapshot strictly exceeded the cap — this is the
25
+ /// "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case; "wrote
26
+ /// exactly `cap` bytes" stays `false`.
27
+ pub(super) fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
28
+ match cap {
29
+ Some(c) if raw.len() > c => (&raw[..c], true),
30
+ _ => (raw, false),
31
+ }
32
+ }
33
+
34
+ #[cfg(test)]
35
+ mod tests {
36
+ use super::{clip_capture, pipe_capacity};
37
+
38
+ #[test]
39
+ fn pipe_capacity_adds_one_when_cap_is_set() {
40
+ assert_eq!(pipe_capacity(Some(5)), 6);
41
+ assert_eq!(pipe_capacity(Some(0)), 1);
42
+ }
43
+
44
+ #[test]
45
+ fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
46
+ assert_eq!(pipe_capacity(None), usize::MAX);
47
+ }
48
+
49
+ #[test]
50
+ fn pipe_capacity_saturates_at_usize_max() {
51
+ assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
52
+ }
53
+
54
+ #[test]
55
+ fn clip_capture_returns_full_bytes_when_under_cap() {
56
+ let (bytes, truncated) = clip_capture(b"abc", Some(5));
57
+ assert_eq!(bytes, b"abc");
58
+ assert!(!truncated);
59
+ }
60
+
61
+ #[test]
62
+ fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
63
+ let (bytes, truncated) = clip_capture(b"abcde", Some(5));
64
+ assert_eq!(bytes, b"abcde");
65
+ assert!(!truncated);
66
+ }
67
+
68
+ #[test]
69
+ fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
70
+ // The pipe is sized `cap + 1`, so the snapshot can be at most
71
+ // 6 bytes when `cap == 5`; that surface is what triggers the
72
+ // truncation flag.
73
+ let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
74
+ assert_eq!(bytes, b"abcde");
75
+ assert!(truncated);
76
+ }
77
+
78
+ #[test]
79
+ fn clip_capture_treats_none_as_uncapped() {
80
+ let (bytes, truncated) = clip_capture(b"abcdef", None);
81
+ assert_eq!(bytes, b"abcdef");
82
+ assert!(!truncated);
83
+ }
84
+
85
+ #[test]
86
+ fn clip_capture_handles_empty_input() {
87
+ let (bytes, truncated) = clip_capture(b"", Some(5));
88
+ assert_eq!(bytes, b"");
89
+ assert!(!truncated);
90
+ }
91
+ }
@@ -0,0 +1,26 @@
1
+ //! Per-`Runtime` execution configuration.
2
+ //!
3
+ //! The wall-clock and per-channel capture caps a `Kobako::Sandbox`
4
+ //! forwards into `Runtime::from_path`. A plain value carrier owned by the
5
+ //! `Runtime` — distinct from the process-wide engine/module `super::cache`
6
+ //! (which is shared across every Sandbox) and from the per-invocation
7
+ //! `super::invocation::Invocation` (which the wasm engine mutates from
8
+ //! inside a run). These caps are read only by `Runtime` methods between
9
+ //! runs, so they live here.
10
+
11
+ use std::time::Duration;
12
+
13
+ /// Wall-clock and output caps for one `Runtime`. `None` on any field
14
+ /// disables that cap.
15
+ pub(crate) struct Config {
16
+ /// Wall-clock cap for one guest `#eval` / `#run` (docs/behavior.md
17
+ /// B-01, E-19). Stamped into a per-run `Instant` deadline by
18
+ /// `Runtime::prime_caps`.
19
+ pub(crate) timeout: Option<Duration>,
20
+ /// Byte cap for guest stdout capture (docs/behavior.md B-01 / B-04).
21
+ /// Sizes the per-run `MemoryOutputPipe` and computes the truncation
22
+ /// flag in `Runtime::build_snapshot`.
23
+ pub(crate) stdout_limit_bytes: Option<usize>,
24
+ /// Byte cap for guest stderr capture. Mirror of `stdout_limit_bytes`.
25
+ pub(crate) stderr_limit_bytes: Option<usize>,
26
+ }