kobako 0.27.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 +71 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +100 -125
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +31 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +4 -3
  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 +28 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +21 -12
  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 +44 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +2 -2
  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 -15
@@ -1,89 +1,81 @@
1
- //! Host-side dispatch for the `__kobako_dispatch` import.
1
+ //! Host side of the `__kobako_upcall` import: hand the guest's Call to
2
+ //! the bound `DispatchHandler` and write its Reply back into guest memory.
3
+ //! A request under a tag the host does not serve is answered as
4
+ //! unsupported, so a guest asking for a later operation can fall back.
2
5
  //!
3
- //! When the guest invokes the wasm import declared in
4
- //! `wasm/kobako-core/src/abi.rs`, wasmtime calls back into the host
5
- //! through the closure registered by `instance_pre::build_linker`.
6
- //! That closure delegates here. The dispatcher:
6
+ //! A failure lands on whoever is answerable for it. A trap the guest
7
+ //! raises during a callback into it ends the invocation as that trap; any
8
+ //! other failure is the guest's or the wire's, so the import answers 0,
9
+ //! which the guest receives as a wire failure.
7
10
  //!
8
- //! 1. Reads the Call bytes from guest linear memory.
9
- //! 2. Invokes the bound `DispatchHandler` (the frontend's dispatch
10
- //! bridge, e.g. a Ruby Proc) and recovers Reply bytes.
11
- //! 3. Allocates a guest buffer via `__kobako_alloc(len)` invoked
12
- //! through `Caller::get_export`.
13
- //! 4. Writes the Reply 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 handler before any invocation, so reaching the
18
- //! dispatcher with no handler bound is itself a wire-layer fault; the
19
- //! guest maps a 0 return to a trap. Failures during normal dispatch
20
- //! take the Reply's fault arm 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 the driver that deliberately prints
27
- //! through `eprintln!`. The host normally surfaces faults through the
28
- //! contract's error channels; the dispatcher contract is the exception
29
- //! — it must return a packed `i64` to the guest and cannot fail, so a
30
- //! 0 return is the only signal the wasm side receives. The guest collapses every 0 into the same trap, so the
31
- //! Ruby host has no way to attribute the failure to a specific step
32
- //! (missing `memory` export vs. no dispatch handler bound vs. the
33
- //! handler raised vs. `__kobako_alloc` returned 0 vs. `memory.write`
34
- //! rejected).
35
- //!
36
- //! `handle` writes a single `[kobako-dispatch] <reason>` line to
37
- //! `stderr` on each failure path so operators have a breadcrumb to
38
- //! correlate the trap with the actual cause. The line is emitted in
39
- //! both debug and release builds on purpose: dispatcher failures are
40
- //! wire-layer faults rather than expected error paths (`Kobako::Sandbox`
41
- //! always installs the handler, the handler is contracted never to
42
- //! raise, etc.), so the "release-build noise" cost is bounded — under
43
- //! normal operation the line is never written. Operators that need to
44
- //! silence the stream can redirect the host process's stderr, but the
45
- //! kobako convention is "ext never logs" plus this single, named
46
- //! exception.
11
+ //! Each 0 also writes one `[kobako-dispatch] <reason>` line to stderr,
12
+ //! since the 0 itself carries no reason. It is the one place the driver
13
+ //! logs, and normal operation never reaches it.
47
14
 
48
15
  use wasmtime::Caller;
49
16
 
50
- use kobako_transport::envelope::Call;
17
+ use kobako_runtime::error::Trap;
18
+ use kobako_runtime::yielder::Yielder;
19
+ use kobako_transport::abi::pack_ptr_len;
20
+ use kobako_transport::envelope::{Unsupported, Upcall, YieldReply};
51
21
 
22
+ use crate::guest_mem;
52
23
  use crate::invocation::Invocation;
53
24
 
54
- /// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
55
- /// from the wasmtime closure registered by `instance_pre::build_linker`.
56
- ///
57
- /// Returns the packed `(ptr<<32)|len` u64 on success, 0 on any
58
- /// wire-layer fault. Failure paths log a `[kobako-dispatch]` line to
59
- /// `stderr` so operators have a breadcrumb when the guest sees a 0
60
- /// return and traps. The bound dispatch handler is contracted never to
61
- /// raise (it folds Service exceptions onto the Reply's fault arm),
62
- /// so reaching the failure path is always a wiring bug or wire-layer
63
- /// fault rather than an expected path.
64
- pub(crate) fn handle(caller: &mut Caller<'_, Invocation>, req_ptr: i32, req_len: i32) -> i64 {
65
- match try_handle(caller, req_ptr, req_len) {
66
- Ok(packed) => packed,
67
- Err(reason) => {
68
- eprintln!("[kobako-dispatch] {reason}");
69
- 0
70
- }
25
+ /// The wasmtime-backed `Yielder`: built per `__kobako_upcall` frame over
26
+ /// that frame's `Caller`, so nested dispatch frames each carry their own and
27
+ /// stack on the Rust call stack with no shared slot.
28
+ struct CallerYielder<'a, 'c> {
29
+ caller: &'a mut Caller<'c, Invocation>,
30
+ }
31
+
32
+ impl Yielder for CallerYielder<'_, '_> {
33
+ fn yield_to_block(&mut self, args: &[u8]) -> Result<YieldReply, Trap> {
34
+ let bytes = guest_mem::drive_yield(self.caller, args)
35
+ .map_err(|msg| Trap::Other(msg.to_string()))?;
36
+ YieldReply::decode(&bytes).map_err(|err| Trap::Other(err.to_string()))
71
37
  }
72
38
  }
73
39
 
74
- /// Result-returning core of `handle`. Pulled out so each early
75
- /// failure path carries a diagnostic string instead of an opaque 0.
40
+ /// Answer one `__kobako_upcall` call: the packed `(ptr<<32)|len` of the
41
+ /// Reply, 0 for a failure the guest or the wire is answerable for, or the
42
+ /// trap a callback into the guest raised.
43
+ pub(crate) fn handle(
44
+ caller: &mut Caller<'_, Invocation>,
45
+ req_ptr: i32,
46
+ req_len: i32,
47
+ ) -> wasmtime::Result<i64> {
48
+ let answer = try_handle(caller, req_ptr, req_len);
49
+ if let Some(trap) = caller.data_mut().take_reentry_trap() {
50
+ return Err(trap);
51
+ }
52
+ Ok(answer.unwrap_or_else(|reason| {
53
+ eprintln!("[kobako-dispatch] {reason}");
54
+ 0
55
+ }))
56
+ }
57
+
76
58
  fn try_handle(
77
59
  caller: &mut Caller<'_, Invocation>,
78
60
  req_ptr: i32,
79
61
  req_len: i32,
80
62
  ) -> Result<i64, &'static str> {
81
- let req_bytes = crate::guest_mem::read(caller, req_ptr, req_len)?;
82
- // The driver decodes the core envelope so the frontend never sees a
83
- // frame; the payload inside it stays bytes the whole way through.
84
- let call = Call::decode(&req_bytes).map_err(|_| {
85
- "the guest sent a malformed Call envelope — please report this as a kobako bug"
86
- })?;
63
+ let req_bytes = guest_mem::read_request(caller, req_ptr, req_len)?;
64
+ // The driver decodes the core envelope, here and in `CallerYielder`, so
65
+ // the frontend never sees a frame; the payload inside it stays bytes
66
+ // the whole way through.
67
+ let call = match Upcall::decode(&req_bytes) {
68
+ Ok(Upcall::Call(call)) => call,
69
+ Ok(_) => {
70
+ let refusal = Unsupported::new("the host does not serve this request");
71
+ return write_reply(caller, &refusal.encode());
72
+ }
73
+ Err(_) => {
74
+ return Err(
75
+ "the guest sent a malformed Call envelope — please report this as a kobako bug",
76
+ )
77
+ }
78
+ };
87
79
 
88
80
  // `Kobako::Sandbox` always installs the dispatch handler before
89
81
  // invoking the runtime, so reaching this branch indicates a misuse
@@ -91,27 +83,25 @@ fn try_handle(
91
83
  let handler = caller
92
84
  .data()
93
85
  .on_dispatch()
94
- .ok_or("a Sandbox callback fired outside an active Sandbox#run — please report this as a kobako bug")?;
86
+ .ok_or("a Sandbox callback fired outside an active invocation — please report this as a kobako bug")?;
95
87
 
96
88
  // Build a frame-scoped yielder over this Caller and hand it to the
97
89
  // handler. The borrow ends with the block, freeing the Caller for
98
- // `write_response`; nested dispatch frames each build their own, so
90
+ // `write_reply`; nested dispatch frames each build their own, so
99
91
  // the LIFO re-entry lives on the Rust stack — no shared slot.
100
92
  let reply = {
101
- let mut yielder = crate::guest_mem::CallerYielder::new(caller);
93
+ let mut yielder = CallerYielder { caller };
102
94
  handler.dispatch(call, &mut yielder)
103
95
  }
104
96
  .ok_or(
105
97
  "a Sandbox callback raised an exception instead of returning a fault — please report this as a kobako bug",
106
98
  )?;
107
99
 
108
- write_response(caller, &reply.encode())
100
+ write_reply(caller, &reply.encode())
109
101
  }
110
102
 
111
- /// Allocate a guest-side buffer and copy the response bytes into it via
112
- /// `crate::guest_mem::alloc_and_write`, returning the packed
113
- /// `(ptr<<32)|len` u64 the guest's `__kobako_dispatch` import expects.
114
- fn write_response(caller: &mut Caller<'_, Invocation>, bytes: &[u8]) -> Result<i64, &'static str> {
115
- let ptr = crate::guest_mem::alloc_and_write(caller, bytes)?;
116
- Ok(((ptr as i64) << 32) | (bytes.len() as i64))
103
+ fn write_reply(caller: &mut Caller<'_, Invocation>, bytes: &[u8]) -> Result<i64, &'static str> {
104
+ let ptr = guest_mem::write_from_callback(caller, bytes)?;
105
+ // The write has already held the length to the payload cap.
106
+ Ok(pack_ptr_len(ptr, bytes.len() as u32) as i64)
117
107
  }
@@ -17,14 +17,15 @@ use wasmtime::{
17
17
 
18
18
  use crate::cache::shared_engine;
19
19
  use crate::config::Config;
20
- use crate::exports::Exports;
20
+ use crate::exports::{self, Exports};
21
21
  use crate::invocation::Invocation;
22
- use crate::{capture, frames, instance_pre, trap};
22
+ use crate::{capture, guest_mem, instance_pre, io, trap};
23
23
  use kobako_runtime::dispatch::DispatchHandler;
24
24
  use kobako_runtime::error::{InvokeError, SetupError, Trap};
25
25
  use kobako_runtime::profile::Profile;
26
- use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
26
+ use kobako_runtime::runtime::{Entry, Runtime as ContractRuntime};
27
27
  use kobako_runtime::snapshot::{Capture, Completion, Snapshot, Usage};
28
+ use kobako_transport::envelope::{CompileReply, Downcall, Preamble, Run, Snippet};
28
29
 
29
30
  /// The wasmtime execution unit behind one sandbox runtime.
30
31
  pub struct Driver {
@@ -39,11 +40,9 @@ pub struct Driver {
39
40
  }
40
41
 
41
42
  impl Driver {
42
- /// Construct a Driver from a wasm file path, using the process-wide
43
- /// shared Engine and per-path Module / InstancePre caches. The
44
- /// artifact's ABI version is verified with the cached template
45
- /// (`crate::abi`). Every failure is a `SetupError` for the frontend
46
- /// to attribute — Engine and Module never leave the driver.
43
+ /// Load the Guest Binary at `path` and verify its ABI version. The
44
+ /// Engine is shared by the process and the Module cached per path;
45
+ /// neither leaves the driver.
47
46
  pub fn new(path: &Path, config: Config) -> Result<Self, SetupError> {
48
47
  Ok(Self {
49
48
  instance_pre: instance_pre::cached_instance_pre(path)?,
@@ -51,8 +50,6 @@ impl Driver {
51
50
  })
52
51
  }
53
52
 
54
- /// Build the per-invocation Store: a fresh `Invocation` wired with
55
- /// the memory limiter and the epoch-deadline callback.
56
53
  fn new_store(&self) -> Result<WtStore<Invocation>, SetupError> {
57
54
  let mut store = WtStore::new(shared_engine()?, Invocation::new(self.config.memory_limit));
58
55
  store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
@@ -60,28 +57,21 @@ impl Driver {
60
57
  Ok(store)
61
58
  }
62
59
 
63
- /// Instantiate the per-invocation instance from the pre-linked
64
- /// template and resolve its host-driven export handles. An
65
- /// instantiation failure at invocation time is an engine fault —
66
- /// a `Trap` — unlike the construction-time probe, whose failure is
67
- /// `SetupError`.
60
+ /// Failing here is an engine fault — a `Trap` — unlike the
61
+ /// construction-time probe, whose failure is a `SetupError`.
68
62
  fn instantiate(&self, store: &mut WtStore<Invocation>) -> Result<Exports, Trap> {
69
63
  let instance = self
70
64
  .instance_pre
71
65
  .instantiate(store.as_context_mut())
72
66
  .map_err(|e| Trap::Other(format!("failed to instantiate the Sandbox runtime: {e}")))?;
73
- Ok(Exports::resolve(&instance, store.as_context_mut()))
67
+ let exports = Exports::resolve(&instance, store.as_context_mut());
68
+ store.data_mut().set_exports(exports.clone());
69
+ Ok(exports)
74
70
  }
75
71
 
76
- /// Run one guest export call inside the per-invocation cap window:
77
- /// `Driver::prime_caps` before, `disarm_caps` after — the shared
78
- /// bracket for both run-path exports (`__kobako_eval` /
79
- /// `__kobako_run`). Disarm runs whether the call returns or traps, so
80
- /// the `wall_time` bracket and the memory
81
- /// cap always close — that close-on-trap guarantee is the reason this
82
- /// bracket lives in one place rather than inline at each call site.
83
- /// The wasmtime trap is returned unmapped; the caller classifies it
84
- /// through `trap::trap_from`.
72
+ /// Disarm runs whether the call returns or traps, so the `wall_time`
73
+ /// bracket and the memory cap always close; that guarantee is why the
74
+ /// bracket lives in one place rather than at each call site.
85
75
  fn call_with_caps<Params, Results>(
86
76
  &self,
87
77
  store: &mut WtStore<Invocation>,
@@ -99,20 +89,8 @@ impl Driver {
99
89
  result
100
90
  }
101
91
 
102
- /// Stamp the per-invocation wall-clock deadline into `Invocation`
103
- /// and prime the wasmtime epoch deadline so the next ticker tick
104
- /// wakes the epoch-deadline callback. When `timeout` is disabled,
105
- /// the deadline is set far enough in the future that the callback
106
- /// effectively never fires.
107
- ///
108
- /// Also captures the current linear-memory size as the baseline
109
- /// for the per-invocation memory delta cap —
110
- /// the pre-initialized image's allocation is folded into the
111
- /// baseline rather than the budget — and stamps the wall-clock
112
- /// entry instant for the `wall_time`
113
- /// measurement. The bracket closes in `disarm_caps` so it matches
114
- /// the `timeout` deadline window and excludes `OUTCOME_BUFFER`
115
- /// decoding and stdout / stderr capture readout.
92
+ /// The pre-initialized image's allocation is folded into the memory
93
+ /// baseline rather than the budget.
116
94
  fn prime_caps(&self, store: &mut WtStore<Invocation>, exports: &Exports) {
117
95
  match self.config.timeout {
118
96
  Some(timeout) => {
@@ -133,9 +111,8 @@ impl Driver {
133
111
  store.data_mut().start_wall_clock();
134
112
  }
135
113
 
136
- /// Bundle one invocation's observables into a fresh `Snapshot`,
137
- /// uniformly for every `completion` — the clipped captures and the
138
- /// cap-bracket usage must survive a trap just as they do an outcome.
114
+ /// Built the same for every `completion`: captures and usage must
115
+ /// survive a trap just as they do an outcome.
139
116
  fn build_snapshot(&self, store: &WtStore<Invocation>, completion: Completion) -> Snapshot {
140
117
  let data = store.data();
141
118
  let usage = Usage {
@@ -162,48 +139,39 @@ impl Driver {
162
139
  }
163
140
 
164
141
  impl ContractRuntime for Driver {
165
- /// Drive one guest invocation on a fresh instance and return its
166
- /// `Snapshot`, `Ok` iff the guest export ran. Builds a fresh Store,
167
- /// binds the borrowed dispatch handler, installs the stdin frames
168
- /// (three for `Eval` — preamble / source / snippets; two for `Run` —
169
- /// preamble / snippets, with the envelope copied into guest memory),
170
- /// and primes the per-invocation caps around the export call. A fault
171
- /// before the export call is the `Err` channel; once the call starts,
172
- /// every fault folds into the Snapshot's `Completion` — the
173
- /// configured-cap paths as `Trap::Timeout` / `Trap::MemoryLimit`,
174
- /// everything else as `Trap::Other` — so captures and usage survive
175
- /// it. The body touches no frontend value — the handler is only
176
- /// borrowed (see the trait's safety contract).
142
+ /// A fault before the export call is the `Err`; once the call
143
+ /// starts, every fault folds into the Snapshot's `Completion` so
144
+ /// captures and usage survive it. The handler is only borrowed (see the
145
+ /// trait's safety contract).
177
146
  fn invoke(
178
147
  &self,
179
148
  entry: Entry<'_>,
180
- frames: Frames<'_>,
149
+ preamble: &Preamble,
181
150
  handler: Option<Arc<dyn DispatchHandler>>,
182
151
  ) -> Result<Snapshot, InvokeError> {
183
152
  let mut store = self.new_store()?;
184
153
  if let Some(handler) = handler {
185
154
  store.data_mut().bind_on_dispatch(handler);
186
155
  }
187
- let frame_list: Vec<&[u8]> = match &entry {
188
- Entry::Eval { source } => vec![frames.preamble, source, frames.snippets],
189
- Entry::Run { .. } => vec![frames.preamble, frames.snippets],
190
- };
191
- frames::install_wasi_frames(&mut store, &self.config, &frame_list)?;
156
+ io::install_wasi(&mut store, &self.config, &preamble.encode())?;
192
157
  let exports = self.instantiate(&mut store)?;
193
- let called = match entry {
194
- Entry::Eval { .. } => {
195
- let eval = frames::require_export(exports.eval.as_ref())?;
196
- self.call_with_caps(&mut store, &exports, eval, ())
197
- }
198
- Entry::Run { envelope } => {
199
- let run = frames::require_export(exports.run.as_ref())?;
200
- let (env_ptr, env_len) = frames::write_envelope(&mut store, &exports, envelope)?;
201
- self.call_with_caps(&mut store, &exports, run, (env_ptr, env_len))
202
- }
203
- };
158
+ let downcall = exports::require(exports.downcall.as_ref())?;
159
+ let request = match entry {
160
+ Entry::Eval { source } => Downcall::Eval(source.to_vec()),
161
+ Entry::Run {
162
+ entrypoint,
163
+ payload,
164
+ } => Downcall::Run(Run {
165
+ entrypoint: entrypoint.to_owned(),
166
+ payload,
167
+ }),
168
+ }
169
+ .encode();
170
+ let (ptr, len) = io::write_request(&mut store, &exports, &request)?;
171
+ let called = self.call_with_caps(&mut store, &exports, downcall, (ptr, len));
204
172
  let completion = match called {
205
- Ok(()) => match frames::fetch_outcome_bytes(&mut store, &exports) {
206
- Ok(bytes) => Completion::Outcome(bytes),
173
+ Ok(packed) => match io::read_outcome(&store, &exports, packed) {
174
+ Ok(outcome) => Completion::Outcome(outcome),
207
175
  Err(t) => Completion::Trap(t),
208
176
  },
209
177
  Err(e) => Completion::Trap(trap::trap_from(e)),
@@ -211,25 +179,104 @@ impl ContractRuntime for Driver {
211
179
  Ok(self.build_snapshot(&store, completion))
212
180
  }
213
181
 
214
- /// The posture this driver built — exactly the rung requested via
215
- /// `Config`. Every per-invocation WASI context is built to it
216
- /// (`frames::install_wasi_frames`): `Hermetic` freezes ambient time
217
- /// and entropy (`crate::ambient`), `Permissive` leaves the live WASI
218
- /// sources. Both rungs wire no filesystem, environment, or network,
219
- /// and the linker adds only the wire ABI's `__kobako_dispatch`
220
- /// beyond that confined WASI surface (`crate::instance_pre`).
182
+ /// Exactly the rung `Config` requested. `Hermetic` freezes ambient time
183
+ /// and entropy; both rungs wire no filesystem, environment, or network,
184
+ /// and the linker adds only `__kobako_upcall` beyond that WASI surface.
221
185
  fn profile(&self) -> Profile {
222
186
  self.config.profile
223
187
  }
188
+
189
+ /// Bounded by the same caps as an invocation, on an instance of its
190
+ /// own that no script ever runs on, so what it answers carries
191
+ /// nothing a script could have written.
192
+ fn compile(&self, preamble: &Preamble) -> Option<Vec<Vec<u8>>> {
193
+ let mut store = self.new_store().ok()?;
194
+ io::install_wasi(&mut store, &self.config, &preamble.encode()).ok()?;
195
+ let exports = self.instantiate(&mut store).ok()?;
196
+ let downcall = exports::require(exports.downcall.as_ref()).ok()?;
197
+ let (ptr, len) =
198
+ io::write_request(&mut store, &exports, &Downcall::Compile.encode()).ok()?;
199
+ let packed = self
200
+ .call_with_caps(&mut store, &exports, downcall, (ptr, len))
201
+ .ok()?;
202
+ match io::read_compile_reply(&store, &exports, packed).ok()? {
203
+ CompileReply::Ok(bytecodes) => one_per_source(preamble, bytecodes)
204
+ .filter(|bytecodes| replay_fits(preamble, bytecodes)),
205
+ CompileReply::Error(_) | CompileReply::Unsupported(_) => None,
206
+ }
207
+ }
208
+ }
209
+
210
+ /// Each invocation sends the Preamble with every bytecode in place of its
211
+ /// source, so an answer that would carry it past the payload cap would trap
212
+ /// every invocation the source alone fits.
213
+ fn replay_fits(preamble: &Preamble, bytecodes: &[Vec<u8>]) -> bool {
214
+ let mut replay = preamble.clone();
215
+ let sources = replay
216
+ .snippets
217
+ .entries
218
+ .iter_mut()
219
+ .filter(|entry| matches!(entry, Snippet::Source { .. }));
220
+ for (entry, body) in sources.zip(bytecodes) {
221
+ *entry = Snippet::Bytecode { body: body.clone() };
222
+ }
223
+ guest_mem::checked_payload_len(replay.encode().len()).is_ok()
224
+ }
225
+
226
+ /// A frontend replays each bytecode in place of one source snippet, in
227
+ /// order, so an answer of any other length leaves nothing to replay.
228
+ fn one_per_source(preamble: &Preamble, bytecodes: Vec<Vec<u8>>) -> Option<Vec<Vec<u8>>> {
229
+ let sources = preamble
230
+ .snippets
231
+ .entries
232
+ .iter()
233
+ .filter(|entry| matches!(entry, Snippet::Source { .. }))
234
+ .count();
235
+ (bytecodes.len() == sources).then_some(bytecodes)
224
236
  }
225
237
 
226
- /// Drop the memory cap as soon as the guest call returns so that
227
- /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
228
- /// which can grow guest memory transiently) is not attributed to
229
- /// the user script. Also closes the
230
- /// `wall_time` bracket opened by `Driver::prime_caps`. Paired
231
- /// with `Driver::prime_caps`.
232
238
  fn disarm_caps(store: &mut WtStore<Invocation>) {
233
239
  store.data_mut().stop_wall_clock();
234
240
  store.data_mut().disarm_memory_cap();
235
241
  }
242
+
243
+ #[cfg(test)]
244
+ mod tests {
245
+ use kobako_transport::envelope::Snippets;
246
+
247
+ use super::*;
248
+
249
+ /// One source between two bytecode snippets: a compile answers one
250
+ /// bytecode for it.
251
+ fn one_source() -> Preamble {
252
+ let mut preamble = Preamble::default();
253
+ preamble.snippets = Snippets {
254
+ entries: vec![
255
+ Snippet::Bytecode { body: vec![0xaa] },
256
+ Snippet::Source {
257
+ name: "Only".into(),
258
+ body: "1".into(),
259
+ },
260
+ Snippet::Bytecode { body: vec![0xbb] },
261
+ ],
262
+ };
263
+ preamble
264
+ }
265
+
266
+ // @behavior WE-089
267
+ #[test]
268
+ fn a_compiled_answer_of_another_length_leaves_nothing_compiled() {
269
+ let preamble = one_source();
270
+
271
+ assert_eq!(
272
+ [
273
+ one_per_source(&preamble, Vec::new()),
274
+ one_per_source(&preamble, vec![vec![0x01], vec![0x02]]),
275
+ one_per_source(&preamble, vec![vec![0x01]]),
276
+ ],
277
+ [None, None, Some(vec![vec![0x01]])],
278
+ "a compiled answer through Driver#compile must be kept only when it carries one \
279
+ bytecode per source snippet"
280
+ );
281
+ }
282
+ }
@@ -1,56 +1,47 @@
1
1
  //! Per-invocation wasmtime export handles for the host-driven ABI
2
2
  //! surface.
3
3
  //!
4
- //! `Driver::instantiate` resolves the ABI exports the run path drives
5
- //! (`__kobako_eval` / `__kobako_run` / `__kobako_take_outcome` /
6
- //! `__kobako_alloc`) plus the `memory` export against each fresh
7
- //! per-invocation instance and bundles their
8
- //! typed handles here, so the invocation body passes one struct around
9
- //! rather than re-resolving exports by name at every step. Distinct
10
- //! from `crate::cache` (the process-wide Engine / Module cache): this
11
- //! carries *which guest function to call*, per invocation.
12
- //!
13
- //! `crate::dispatch` does not reach this struct — a host import runs
14
- //! against a `Caller`, so the dispatch path resolves `__kobako_alloc`
15
- //! and `memory` through `Caller::get_export` instead.
4
+ //! Each ABI export is named and typed once, here, against each fresh
5
+ //! per-invocation instance. The invocation body and the host imports
6
+ //! running inside it both drive the guest through these handles, never
7
+ //! by looking an export up by name. Distinct from `crate::cache` (the
8
+ //! process-wide Engine / Module cache): this carries *which guest
9
+ //! function to call*, per invocation.
16
10
 
17
11
  use wasmtime::{AsContextMut, Instance as WtInstance, Memory, TypedFunc};
18
12
 
13
+ use crate::guest_mem::SANDBOX_RUNTIME_NOT_KOBAKO;
14
+ use kobako_runtime::error::Trap;
15
+
16
+ /// For a required guest export that is absent or mistyped, wherever it is
17
+ /// looked up. Names neither the export nor a frontend's artifact path;
18
+ /// what a caller can do is rebuild the runtime.
19
+ pub(crate) const RUNTIME_INCOMPATIBLE: &str =
20
+ "the Sandbox runtime is incompatible; rebuild it against the kobako version in use";
21
+
19
22
  /// The resolved host-driven export handles. Each is `Option` because test
20
23
  /// fixtures (a minimal "ping" module) need not provide them; real
21
- /// `kobako.wasm` always does, and the run-path methods surface a `Trap`
22
- /// (via `require_export` / `require_memory`) when a handle is `None`.
24
+ /// `kobako.wasm` always does, and the run path surfaces a `Trap` (via
25
+ /// `require` / `Exports::require_memory`) when a handle is `None`.
23
26
  ///
24
27
  /// The handles are indices into the owning Store, not borrows of the
25
28
  /// `Instance` — they stay valid for the Store's lifetime, which is why
26
- /// no `Instance` field is kept.
29
+ /// no `Instance` field is kept, and why a clone can ride in the Store's
30
+ /// own data for the host imports to reach.
31
+ #[derive(Clone)]
27
32
  pub(crate) struct Exports {
28
- pub(crate) eval: Option<TypedFunc<(), ()>>,
29
- pub(crate) run: Option<TypedFunc<(i32, i32), ()>>,
30
- pub(crate) take_outcome: Option<TypedFunc<(), u64>>,
33
+ pub(crate) downcall: Option<TypedFunc<(i32, i32), u64>>,
31
34
  pub(crate) alloc: Option<TypedFunc<u32, u32>>,
32
35
  pub(crate) memory: Option<Memory>,
33
36
  }
34
37
 
35
38
  impl Exports {
36
- /// Best-effort lookup of the host-driven exports against a freshly
37
- /// instantiated module. Missing exports are not an error here
38
- /// (the test fixture is a bare module); the host enforces presence at
39
- /// invocation time. Only the SPEC ABI shapes are accepted —
40
- /// `__kobako_eval` is `() -> ()`, `__kobako_run` is
41
- /// `(env_ptr, env_len) -> ()`, `__kobako_take_outcome` is `() -> u64`,
42
- /// `__kobako_alloc` is `(len) -> ptr`
43
- /// (docs/wire-codec.md § ABI Signatures).
39
+ /// A missing export is not an error here, because the test fixture is a
40
+ /// bare module; presence is enforced at invocation time.
44
41
  pub(crate) fn resolve(instance: &WtInstance, mut ctx: impl AsContextMut) -> Self {
45
42
  Self {
46
- eval: instance
47
- .get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
48
- .ok(),
49
- run: instance
50
- .get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
51
- .ok(),
52
- take_outcome: instance
53
- .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
43
+ downcall: instance
44
+ .get_typed_func::<(i32, i32), u64>(&mut ctx, "__kobako_downcall")
54
45
  .ok(),
55
46
  alloc: instance
56
47
  .get_typed_func::<u32, u32>(&mut ctx, "__kobako_alloc")
@@ -58,4 +49,67 @@ impl Exports {
58
49
  memory: instance.get_memory(&mut ctx, "memory"),
59
50
  }
60
51
  }
52
+
53
+ pub(crate) fn require_memory(&self) -> Result<Memory, Trap> {
54
+ self.memory
55
+ .ok_or_else(|| Trap::Other(SANDBOX_RUNTIME_NOT_KOBAKO.to_string()))
56
+ }
57
+ }
58
+
59
+ pub(crate) fn require<Params, Results>(
60
+ export: Option<&TypedFunc<Params, Results>>,
61
+ ) -> Result<&TypedFunc<Params, Results>, Trap>
62
+ where
63
+ Params: wasmtime::WasmParams,
64
+ Results: wasmtime::WasmResults,
65
+ {
66
+ export.ok_or_else(|| Trap::Other(RUNTIME_INCOMPATIBLE.to_string()))
67
+ }
68
+
69
+ #[cfg(test)]
70
+ mod tests {
71
+ //! The ABI is a closed set of guest exports, so the bundled Guest
72
+ //! Binary is read for what it exports rather than only for whether
73
+ //! `resolve` finds what it looks up — an extra entry point would pass
74
+ //! every lookup. A missing binary is a hard failure under
75
+ //! CI (which always builds it) and a silent skip locally.
76
+ use std::path::Path;
77
+
78
+ use wasmtime::ExternType;
79
+
80
+ use crate::cache::cached_module;
81
+
82
+ const WASM: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../data/kobako.wasm");
83
+
84
+ const ABI_EXPORTS: [&str; 3] = [
85
+ "__kobako_abi_version",
86
+ "__kobako_alloc",
87
+ "__kobako_downcall",
88
+ ];
89
+
90
+ // @behavior RT-068
91
+ #[test]
92
+ fn the_guest_binary_exports_the_abi_functions_and_nothing_else() {
93
+ let path = Path::new(WASM);
94
+ if !path.exists() {
95
+ assert!(
96
+ std::env::var_os("CI").is_none(),
97
+ "data/kobako.wasm missing under CI — run `bundle exec rake wasm:build`"
98
+ );
99
+ return;
100
+ }
101
+ let module = cached_module(path).expect("the Guest Binary must load");
102
+
103
+ let mut functions: Vec<&str> = module
104
+ .exports()
105
+ .filter(|export| matches!(export.ty(), ExternType::Func(_)))
106
+ .map(|export| export.name())
107
+ .collect();
108
+ functions.sort_unstable();
109
+
110
+ assert_eq!(
111
+ functions, ABI_EXPORTS,
112
+ "data/kobako.wasm read through its export section must export exactly the ABI's functions"
113
+ );
114
+ }
61
115
  }