kobako 0.4.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 (96) 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 +0 -1
  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 +12 -16
  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} +94 -86
  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 -5
  25. data/lib/kobako/codec/factory.rb +12 -12
  26. data/lib/kobako/codec/utils.rb +56 -59
  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 +4 -6
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +31 -35
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +83 -72
  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/{rpc/wire_error.rb → transport/error.rb} +7 -6
  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/version.rb +1 -1
  49. data/lib/kobako.rb +4 -4
  50. data/release-please-config.json +24 -0
  51. data/sig/kobako/capture.rbs +0 -2
  52. data/sig/kobako/catalog/handles.rbs +19 -0
  53. data/sig/kobako/catalog/namespaces.rbs +17 -0
  54. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  55. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  56. data/sig/kobako/codec/decoder.rbs +2 -1
  57. data/sig/kobako/codec/factory.rbs +2 -2
  58. data/sig/kobako/codec/utils.rbs +5 -5
  59. data/sig/kobako/errors.rbs +7 -7
  60. data/sig/kobako/fault.rbs +19 -0
  61. data/sig/kobako/handle.rbs +2 -3
  62. data/sig/kobako/namespace.rbs +19 -0
  63. data/sig/kobako/outcome.rbs +2 -2
  64. data/sig/kobako/runtime.rbs +23 -0
  65. data/sig/kobako/sandbox.rbs +5 -8
  66. data/sig/kobako/snapshot.rbs +15 -0
  67. data/sig/kobako/transport/dispatcher.rbs +34 -0
  68. data/sig/kobako/transport/error.rbs +6 -0
  69. data/sig/kobako/transport/request.rbs +32 -0
  70. data/sig/kobako/transport/response.rbs +30 -0
  71. data/sig/kobako/transport/run.rbs +27 -0
  72. data/sig/kobako/transport/yield.rbs +34 -0
  73. data/sig/kobako/transport/yielder.rbs +21 -0
  74. data/sig/kobako/transport.rbs +4 -0
  75. metadata +48 -30
  76. data/ext/kobako/src/wasm/dispatch.rs +0 -162
  77. data/ext/kobako/src/wasm/instance.rs +0 -873
  78. data/ext/kobako/src/wasm.rs +0 -126
  79. data/lib/kobako/handle_table.rb +0 -119
  80. data/lib/kobako/invocation.rb +0 -143
  81. data/lib/kobako/rpc/dispatcher.rb +0 -171
  82. data/lib/kobako/rpc/envelope.rb +0 -118
  83. data/lib/kobako/rpc/fault.rb +0 -41
  84. data/lib/kobako/rpc/namespace.rb +0 -74
  85. data/lib/kobako/rpc/server.rb +0 -146
  86. data/lib/kobako/rpc.rb +0 -11
  87. data/lib/kobako/wasm.rb +0 -25
  88. data/sig/kobako/handle_table.rbs +0 -23
  89. data/sig/kobako/invocation.rbs +0 -25
  90. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  91. data/sig/kobako/rpc/envelope.rbs +0 -51
  92. data/sig/kobako/rpc/fault.rbs +0 -20
  93. data/sig/kobako/rpc/namespace.rbs +0 -24
  94. data/sig/kobako/rpc/server.rbs +0 -31
  95. data/sig/kobako/rpc/wire_error.rbs +0 -6
  96. data/sig/kobako/wasm.rbs +0 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb91cd11e954d6388b7d6c19be8b9fc77548fa1ea9d57b75f1afc7c0d450a36b
4
- data.tar.gz: f84463e4b30e2ae5cb1e7d09a7c55345a419afd442613a1eb6b080682263587f
3
+ metadata.gz: fd04d7b8efaf9ccb41ff873a66b0855839222c5bc034d80a854b9fbc25602570
4
+ data.tar.gz: 718665fbcfb89faafc86f018a247e37ea304c3c8559e4febc240ad95bdc15654
5
5
  SHA512:
6
- metadata.gz: d622978cf22e2b30dbf8674275bbaaf39d0de68962709b40b67194d0521ca3d1e991e9a4e59853e634c111fc852d4719864c2990f2baaf35e1395efa3f67b63a
7
- data.tar.gz: 3f828b5374841d0bcb8136a7c1aa078668c05c3673c794c50f86de9b1aee0bd915d090e022061b3e9636abb2dfb85a0b3bf1a7bbacff968635d9ed01c5f21edd
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.4.0"
867
+ version = "0.5.0"
868
868
  dependencies = [
869
869
  "magnus",
870
870
  "wasmtime",
data/README.md CHANGED
@@ -191,7 +191,6 @@ end
191
191
  |----------------------------------------|--------------------|------------------------------------------------------------------------------------------|
192
192
  | `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
193
193
  | `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
194
- | `Kobako::ServiceError::Disconnected` | `ServiceError` | RPC target Handle has been invalidated |
195
194
  | `Kobako::HandleTableExhausted` | `SandboxError` | Per-invocation Handle counter reached its 2³¹ − 1 cap |
196
195
  | `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` payload failed RITE structural validation at first invocation replay |
197
196
 
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.4.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> {
@@ -119,7 +115,7 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
119
115
  }
120
116
 
121
117
  let bytes = fs::read(path).map_err(|e| {
122
- wasm_err(
118
+ setup_err(
123
119
  &ruby,
124
120
  format!(
125
121
  "failed to read Sandbox runtime at {}: {}",
@@ -129,7 +125,7 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
129
125
  )
130
126
  })?;
131
127
  let module = WtModule::new(shared_engine()?, &bytes)
132
- .map_err(|e| wasm_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
128
+ .map_err(|e| setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e)))?;
133
129
  cache
134
130
  .lock()
135
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
+ }
@@ -0,0 +1,211 @@
1
+ //! Host-side dispatch for the `__kobako_dispatch` import.
2
+ //!
3
+ //! When the guest invokes the wasm import declared in
4
+ //! `wasm/kobako-wasm/src/abi.rs`, wasmtime calls back into the host
5
+ //! through the closure built in `super::Runtime::build`.
6
+ //! That closure delegates here. The dispatcher (docs/behavior.md B-12 / B-13):
7
+ //!
8
+ //! 1. Reads the Request bytes from guest linear memory.
9
+ //! 2. Invokes the Ruby-side dispatch Proc bound via
10
+ //! `Runtime#on_dispatch=` and recovers Response bytes.
11
+ //! 3. Allocates a guest buffer via `__kobako_alloc(len)` invoked
12
+ //! through `Caller::get_export`.
13
+ //! 4. Writes the Response bytes into the guest buffer.
14
+ //! 5. Returns packed `(ptr<<32)|len` for the guest to decode.
15
+ //!
16
+ //! Returns 0 on any step failure. `Kobako::Sandbox#initialize` always
17
+ //! installs the dispatch Proc before any invocation, so reaching the
18
+ //! dispatcher with no Proc bound is itself a wire-layer fault; the
19
+ //! guest maps a 0 return to a trap. Failures during normal dispatch
20
+ //! surface as Response.err envelopes from
21
+ //! `Kobako::Transport::Dispatcher.dispatch` itself — they never reach
22
+ //! this 0-return path.
23
+ //!
24
+ //! ## Why this module writes to `stderr`
25
+ //!
26
+ //! This file is the one place in `ext/` that deliberately prints
27
+ //! through `eprintln!`. The host normally surfaces faults by
28
+ //! raising a `MagnusError` back into Ruby; the dispatcher contract
29
+ //! is the exception — it must return a packed `i64` to the guest
30
+ //! and cannot raise, so a 0 return is the only signal the wasm side
31
+ //! receives. The guest collapses every 0 into the same trap, so the
32
+ //! Ruby host has no way to attribute the failure to a specific step
33
+ //! (missing `memory` export vs. no dispatch Proc bound vs. the Proc
34
+ //! raised vs. `__kobako_alloc` returned 0 vs. `memory.write`
35
+ //! rejected).
36
+ //!
37
+ //! `handle` writes a single `[kobako-dispatch] <reason>` line to
38
+ //! `stderr` on each failure path so operators have a breadcrumb to
39
+ //! correlate the trap with the actual cause. The line is emitted in
40
+ //! both debug and release builds on purpose: dispatcher failures are
41
+ //! wire-layer faults rather than expected error paths (`Kobako::Sandbox`
42
+ //! always installs the Proc, the Proc is contracted never to raise,
43
+ //! etc.), so the "release-build noise" cost is bounded — under normal
44
+ //! operation the line is never written. Operators that need to silence
45
+ //! the stream can redirect the host process's stderr, but the kobako
46
+ //! convention is "ext never logs" plus this single, named exception.
47
+
48
+ use core::cell::Cell;
49
+ use core::ptr::NonNull;
50
+
51
+ use magnus::value::{Opaque, ReprValue};
52
+ use magnus::{Error as MagnusError, RString, Ruby, Value};
53
+ use wasmtime::Caller;
54
+
55
+ use super::invocation::Invocation;
56
+
57
+ // ============================================================
58
+ // Active-caller pointer for the per-thread Invocation slot (B-24, B-28,
59
+ // SPEC.md Single-Invocation Slot).
60
+ // ============================================================
61
+ //
62
+ // `Runtime#yield_to_active_invocation` (whose body is the
63
+ // `__kobako_yield_to_block` guest export) runs synchronously inside a
64
+ // Ruby Service callback that itself was invoked from inside this
65
+ // dispatcher — at that moment we are several stack frames deep in
66
+ // `try_handle`, with the original `&mut Caller<'_, Invocation>` parked
67
+ // unused on the Rust stack while Ruby code is running. The yield path
68
+ // needs the same Caller to call the guest export, but the Rust borrow
69
+ // type is non-`'static` so it cannot be stored on the `Invocation`
70
+ // struct directly (the `&mut Caller` outlives no struct field — its
71
+ // lifetime ends when `handle` returns to wasmtime).
72
+ //
73
+ // The pointer is therefore erased to `NonNull<()>` and parked in a
74
+ // per-thread slot — the materialised form of the SPEC.md
75
+ // "Single-Invocation Slot" invariant. The single-threaded wasm
76
+ // execution per Sandbox (B-22) plus the LIFO re-entry shape of nested
77
+ // dispatch frames (B-28) ensures no aliasing across threads or across
78
+ // frames; the recovery invariant lives at `current_caller`. The
79
+ // pointer is set on entry to `handle` and restored to the outer
80
+ // frame's value on every exit through a drop guard.
81
+
82
+ thread_local! {
83
+ static ACTIVE_CALLER: Cell<Option<NonNull<()>>> = const { Cell::new(None) };
84
+ }
85
+
86
+ /// RAII guard that saves the previous `ACTIVE_CALLER` value on
87
+ /// installation and restores it on drop. Nested `__kobako_dispatch`
88
+ /// frames stack within one Invocation (B-28) — the inner frame's `set`
89
+ /// swaps in its own pointer while remembering the outer's; drop
90
+ /// restores the outer so its continuation (e.g. iterating over another
91
+ /// guest block) still finds a live caller.
92
+ pub(crate) struct CallerGuard {
93
+ previous: Option<NonNull<()>>,
94
+ }
95
+
96
+ impl CallerGuard {
97
+ fn set(ptr: NonNull<()>) -> Self {
98
+ let previous = ACTIVE_CALLER.with(|c| c.replace(Some(ptr)));
99
+ Self { previous }
100
+ }
101
+ }
102
+
103
+ impl Drop for CallerGuard {
104
+ fn drop(&mut self) {
105
+ ACTIVE_CALLER.with(|c| c.set(self.previous));
106
+ }
107
+ }
108
+
109
+ /// Recover the active `&mut Caller<'_, Invocation>` set by the
110
+ /// enclosing `handle` frame. Returns `None` when no dispatch frame is
111
+ /// active on this thread.
112
+ ///
113
+ /// # Safety
114
+ ///
115
+ /// The returned reference aliases the original `&mut Caller` borrow
116
+ /// held on the Rust stack inside `handle`'s enclosing frame. The
117
+ /// original borrow is logically inactive while Ruby code is running
118
+ /// (it is parked on the stack between `invoke_on_dispatch` and the
119
+ /// eventual `funcall` return), and the SPEC.md Single-Invocation Slot
120
+ /// invariant (one Invocation per OS thread for the duration of any
121
+ /// invocation) guarantees no other Rust frame can observe it. Callers
122
+ /// must not retain the returned `&mut` past the synchronous Ruby
123
+ /// callback that requested it — i.e. only use it inside one short
124
+ /// magnus method body and let the borrow end before the method returns.
125
+ pub(crate) fn current_caller<'a>() -> Option<&'a mut Caller<'a, Invocation>> {
126
+ let raw: NonNull<()> = ACTIVE_CALLER.with(|c| c.get())?;
127
+ // SAFETY: see item doc.
128
+ Some(unsafe { &mut *raw.as_ptr().cast::<Caller<'a, Invocation>>() })
129
+ }
130
+
131
+ /// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
132
+ /// from the wasmtime closure built in `super::Runtime::build`.
133
+ ///
134
+ /// Returns the packed `(ptr<<32)|len` u64 on success, 0 on any
135
+ /// wire-layer fault. Failure paths log a `[kobako-dispatch]` line to
136
+ /// `stderr` so operators have a breadcrumb when the guest sees a 0
137
+ /// return and traps. The bound dispatch Proc is contracted never to
138
+ /// raise (it folds Service exceptions into Response.err envelopes),
139
+ /// so reaching the failure path is always a wiring bug or wire-layer
140
+ /// fault rather than an expected path.
141
+ pub(crate) fn handle(caller: &mut Caller<'_, Invocation>, req_ptr: i32, req_len: i32) -> i64 {
142
+ // SAFETY: lifetime erased to `NonNull<()>` per the module's
143
+ // Invocation-slot doc. The pointer is restored by `_caller_guard`
144
+ // before this function returns, and only
145
+ // `Runtime#yield_to_active_invocation` (running inside a Ruby
146
+ // callback we are about to invoke) reads it through `current_caller`.
147
+ let ptr: NonNull<()> = NonNull::from(&mut *caller).cast();
148
+ let _caller_guard = CallerGuard::set(ptr);
149
+
150
+ match try_handle(caller, req_ptr, req_len) {
151
+ Ok(packed) => packed,
152
+ Err(reason) => {
153
+ eprintln!("[kobako-dispatch] {}", reason);
154
+ 0
155
+ }
156
+ }
157
+ }
158
+
159
+ /// Result-returning core of `handle`. Pulled out so each early
160
+ /// failure path carries a diagnostic string instead of an opaque 0.
161
+ fn try_handle(
162
+ caller: &mut Caller<'_, Invocation>,
163
+ req_ptr: i32,
164
+ req_len: i32,
165
+ ) -> Result<i64, &'static str> {
166
+ let req_bytes = super::guest_mem::read(caller, req_ptr, req_len)?;
167
+
168
+ // `Kobako::Sandbox` always installs the dispatch Proc before
169
+ // invoking the runtime, so reaching this branch indicates a misuse
170
+ // rather than a normal control path.
171
+ let on_dispatch = caller
172
+ .data()
173
+ .on_dispatch()
174
+ .ok_or("a Sandbox callback fired outside an active Sandbox#run — please report this as a kobako bug")?;
175
+
176
+ let resp_bytes = invoke_on_dispatch(on_dispatch, &req_bytes).map_err(|_| {
177
+ "a Sandbox callback raised an exception instead of returning a fault — please report this as a kobako bug"
178
+ })?;
179
+
180
+ write_response(caller, &resp_bytes)
181
+ }
182
+
183
+ /// Invoke the Ruby-side dispatch +Proc+ with the request bytes and return
184
+ /// the encoded Response bytes. The Proc is contracted to fold every
185
+ /// dispatch failure into a +Response.err+ envelope (see
186
+ /// `Kobako::Transport::Dispatcher.dispatch`), so reaching the error
187
+ /// branch is itself a wire-layer fault rather than a normal control path.
188
+ fn invoke_on_dispatch(
189
+ on_dispatch: Opaque<Value>,
190
+ req_bytes: &[u8],
191
+ ) -> Result<Vec<u8>, MagnusError> {
192
+ // The wasmtime callback runs on the same Ruby thread that called the
193
+ // active Sandbox invocation (#eval or #run) — the invariant SPEC
194
+ // Implementation Standards Architecture pins for the host gem — so
195
+ // `Ruby::get()` is always available here. Panicking with `expect`
196
+ // localises the violation rather than letting a nonsense error
197
+ // propagate.
198
+ let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_dispatch");
199
+ let proc_value: Value = ruby.get_inner(on_dispatch);
200
+ let req_str = ruby.str_from_slice(req_bytes);
201
+ let resp: RString = proc_value.funcall("call", (req_str,))?;
202
+ Ok(super::rstring_to_vec(resp))
203
+ }
204
+
205
+ /// Allocate a guest-side buffer and copy the response bytes into it via
206
+ /// `super::guest_mem::alloc_and_write`, returning the packed
207
+ /// `(ptr<<32)|len` u64 the guest's `__kobako_dispatch` import expects.
208
+ fn write_response(caller: &mut Caller<'_, Invocation>, bytes: &[u8]) -> Result<i64, &'static str> {
209
+ let ptr = super::guest_mem::alloc_and_write(caller, bytes)?;
210
+ Ok(((ptr as i64) << 32) | (bytes.len() as i64))
211
+ }
@@ -0,0 +1,51 @@
1
+ //! Cached wasmtime export handles for the host-driven ABI surface.
2
+ //!
3
+ //! `Runtime::from_path` resolves the three docs/wire-codec.md ABI exports
4
+ //! the run path drives (`__kobako_eval` / `__kobako_run` /
5
+ //! `__kobako_take_outcome`) once at construction and stores their typed
6
+ //! handles here, so each `#eval` / `#run` calls a cached handle rather than
7
+ //! re-resolving the export by name. Distinct from `super::cache` (the
8
+ //! process-wide Engine / Module cache): this caches *which guest function
9
+ //! to call*, per `Runtime`.
10
+ //!
11
+ //! `__kobako_alloc` is deliberately absent — only `super::dispatch` calls
12
+ //! it, and it does so through `Caller::get_export` on the wasmtime side.
13
+
14
+ use wasmtime::{AsContextMut, Instance as WtInstance, TypedFunc};
15
+
16
+ use super::invocation::StoreCell;
17
+
18
+ /// The cached host-driven export handles. Each is `Option` because test
19
+ /// fixtures (a minimal "ping" module) need not provide them; real
20
+ /// `kobako.wasm` always does, and the run-path methods raise a Ruby
21
+ /// `Kobako::TrapError` (via `require_export`) when a handle is `None`.
22
+ pub(crate) struct Exports {
23
+ pub(crate) eval: Option<TypedFunc<(), ()>>,
24
+ pub(crate) run: Option<TypedFunc<(i32, i32), ()>>,
25
+ pub(crate) take_outcome: Option<TypedFunc<(), u64>>,
26
+ }
27
+
28
+ impl Exports {
29
+ /// Best-effort lookup of the three host-driven exports against a
30
+ /// freshly instantiated module. Missing exports are not an error here
31
+ /// (the test fixture is a bare module); the host enforces presence at
32
+ /// invocation time. Only the SPEC ABI shapes are accepted —
33
+ /// `__kobako_eval` is `() -> ()`, `__kobako_run` is
34
+ /// `(env_ptr, env_len) -> ()`, `__kobako_take_outcome` is `() -> u64`
35
+ /// (docs/wire-codec.md § ABI Signatures).
36
+ pub(crate) fn resolve(instance: &WtInstance, store: &StoreCell) -> Self {
37
+ let mut store_ref = store.borrow_mut();
38
+ let mut ctx = store_ref.as_context_mut();
39
+ Self {
40
+ eval: instance
41
+ .get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
42
+ .ok(),
43
+ run: instance
44
+ .get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
45
+ .ok(),
46
+ take_outcome: instance
47
+ .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
48
+ .ok(),
49
+ }
50
+ }
51
+ }