kobako 0.12.1 → 0.13.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +22 -0
  4. data/Cargo.lock +138 -136
  5. data/Cargo.toml +6 -2
  6. data/README.md +3 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +16 -0
  8. data/crates/kobako-runtime/Cargo.toml +23 -0
  9. data/crates/kobako-runtime/README.md +34 -0
  10. data/crates/kobako-runtime/src/dispatch.rs +22 -0
  11. data/crates/kobako-runtime/src/error.rs +64 -0
  12. data/crates/kobako-runtime/src/lib.rs +18 -0
  13. data/crates/kobako-runtime/src/profile.rs +35 -0
  14. data/crates/kobako-runtime/src/runtime.rs +57 -0
  15. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  16. data/crates/kobako-runtime/src/yielder.rs +22 -0
  17. data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
  18. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  19. data/crates/kobako-wasmtime/README.md +32 -0
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +8 -5
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  22. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  23. data/crates/kobako-wasmtime/src/config.rs +33 -0
  24. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  25. data/crates/kobako-wasmtime/src/driver.rs +297 -0
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  27. data/crates/kobako-wasmtime/src/frames.rs +276 -0
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  30. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  31. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  32. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  33. data/data/kobako.wasm +0 -0
  34. data/ext/kobako/Cargo.toml +9 -32
  35. data/ext/kobako/src/lib.rs +0 -2
  36. data/ext/kobako/src/runtime/bridge.rs +150 -0
  37. data/ext/kobako/src/runtime/errors.rs +45 -13
  38. data/ext/kobako/src/runtime.rs +246 -420
  39. data/lib/kobako/catalog/handles.rb +3 -3
  40. data/lib/kobako/catalog/namespaces.rb +4 -0
  41. data/lib/kobako/catalog/snippets.rb +4 -0
  42. data/lib/kobako/codec/encoder.rb +5 -1
  43. data/lib/kobako/codec/factory.rb +41 -13
  44. data/lib/kobako/codec/handle_walk.rb +4 -0
  45. data/lib/kobako/codec.rb +1 -1
  46. data/lib/kobako/errors.rb +18 -16
  47. data/lib/kobako/sandbox.rb +71 -39
  48. data/lib/kobako/sandbox_options.rb +71 -13
  49. data/lib/kobako/transport/dispatcher.rb +2 -2
  50. data/lib/kobako/transport/response.rb +14 -14
  51. data/lib/kobako/transport/run.rb +2 -6
  52. data/lib/kobako/transport/yield.rb +1 -1
  53. data/lib/kobako/transport/yielder.rb +2 -2
  54. data/lib/kobako/version.rb +1 -1
  55. data/lib/kobako.rb +0 -1
  56. data/release-please-config.json +78 -3
  57. data/sig/kobako/codec/factory.rbs +3 -0
  58. data/sig/kobako/errors.rbs +7 -14
  59. data/sig/kobako/runtime.rbs +16 -6
  60. data/sig/kobako/sandbox.rbs +11 -7
  61. data/sig/kobako/sandbox_options.rbs +15 -4
  62. data/sig/kobako/transport/dispatcher.rbs +1 -1
  63. data/sig/kobako/transport/run.rbs +2 -2
  64. data/sig/kobako/transport/yielder.rbs +2 -2
  65. data/sig/kobako/transport.rbs +8 -0
  66. metadata +28 -15
  67. data/ext/kobako/src/runtime/config.rs +0 -25
  68. data/ext/kobako/src/runtime/dispatch.rs +0 -211
  69. data/ext/kobako/src/runtime/frames.rs +0 -188
  70. data/ext/kobako/src/snapshot.rs +0 -110
  71. data/lib/kobako/snapshot.rb +0 -38
  72. data/sig/kobako/snapshot.rbs +0 -15
@@ -1,13 +1,12 @@
1
1
  //! Process-wide caches for the wasmtime `Engine` and compiled
2
2
  //! `Module`, plus the on-disk compiled-artifact cache.
3
3
  //!
4
- //! SPEC.md "Code Organization" pins `ext/` as private and forbids
5
- //! exposing wasm engine types to the Host App or downstream gems. To
6
- //! amortise Engine creation and Module JIT compilation across multiple
7
- //! `Kobako::Sandbox` constructions, the ext keeps a process-scope
8
- //! shared Engine and a per-path Module cache. Both are transparent to
9
- //! Ruby callers, who construct a `Runtime` via
10
- //! `Kobako::Runtime.from_path(...)` and never see Engine or Module.
4
+ //! SPEC.md "Code Organization" forbids exposing wasm engine types to
5
+ //! the Host App or downstream gems. To amortise Engine creation and
6
+ //! Module JIT compilation across multiple sandbox constructions, the
7
+ //! driver keeps a process-scope shared Engine and a per-path Module
8
+ //! cache. Both are transparent to frontends, which construct a
9
+ //! `Driver` via `Driver::new` and never see Engine or Module.
11
10
  //!
12
11
  //! Across processes, the Cranelift compile cost is amortised by a
13
12
  //! best-effort `.cwasm` disk cache keyed by the SHA-256 of the Guest
@@ -27,11 +26,10 @@ use std::sync::{Mutex, OnceLock};
27
26
  use std::thread;
28
27
  use std::time::{Duration, SystemTime};
29
28
 
30
- use magnus::{Error as MagnusError, Ruby};
31
29
  use sha2::{Digest, Sha256};
32
30
  use wasmtime::{Config as WtConfig, Engine as WtEngine, Module as WtModule};
33
31
 
34
- use super::errors::{setup_err, MODULE_NOT_BUILT_ERROR};
32
+ use kobako_runtime::error::SetupError;
35
33
 
36
34
  static SHARED_ENGINE: OnceLock<WtEngine> = OnceLock::new();
37
35
  static MODULE_CACHE: OnceLock<Mutex<HashMap<PathBuf, WtModule>>> = OnceLock::new();
@@ -60,17 +58,15 @@ const EPOCH_TICK: Duration = Duration::from_millis(10);
60
58
  /// cap. The first call spawns the process-singleton ticker
61
59
  /// thread that drives `engine.increment_epoch()` at `EPOCH_TICK`
62
60
  /// cadence; subsequent calls reuse the same engine and ticker.
63
- pub(crate) fn shared_engine() -> Result<&'static WtEngine, MagnusError> {
61
+ pub(crate) fn shared_engine() -> Result<&'static WtEngine, SetupError> {
64
62
  if let Some(engine) = SHARED_ENGINE.get() {
65
63
  return Ok(engine);
66
64
  }
67
65
  let mut config = WtConfig::new();
68
66
  config.wasm_exceptions(true);
69
67
  config.epoch_interruption(true);
70
- let engine = WtEngine::new(&config).map_err(|e| {
71
- let ruby = Ruby::get().expect("Ruby thread");
72
- setup_err(&ruby, format!("engine init: {}", e))
73
- })?;
68
+ let engine =
69
+ WtEngine::new(&config).map_err(|e| SetupError::Dead(format!("engine init: {e}")))?;
74
70
  let engine = SHARED_ENGINE.get_or_init(|| engine);
75
71
  spawn_epoch_ticker(engine.clone());
76
72
  Ok(engine)
@@ -95,11 +91,11 @@ fn spawn_epoch_ticker(engine: WtEngine) {
95
91
  }
96
92
 
97
93
  /// Look up `path` in the per-path Module cache, compiling and inserting
98
- /// the artifact on a miss. Raises `Kobako::ModuleNotBuiltError`
99
- /// when the file is missing — the headline error for the common
100
- /// pre-build state on a fresh clone before `rake compile`.
101
- pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
102
- let ruby = Ruby::get().expect("Ruby thread");
94
+ /// the artifact on a miss. Returns `SetupError::ModuleNotBuilt`
95
+ /// (boundary → `Kobako::ModuleNotBuiltError`) when the file is missing —
96
+ /// the headline error for the common pre-build state on a fresh clone
97
+ /// before `rake compile`.
98
+ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, SetupError> {
103
99
  let cache = MODULE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
104
100
 
105
101
  if let Some(module) = cache
@@ -112,33 +108,25 @@ pub(crate) fn cached_module(path: &Path) -> Result<WtModule, MagnusError> {
112
108
  }
113
109
 
114
110
  if !path.exists() {
115
- return Err(MagnusError::new(
116
- ruby.get_inner(&MODULE_NOT_BUILT_ERROR),
117
- format!(
118
- "Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
119
- path.display()
120
- ),
121
- ));
111
+ return Err(SetupError::ModuleNotBuilt(format!(
112
+ "Sandbox runtime not found at {}; run `bundle exec rake wasm:build` to build it",
113
+ path.display()
114
+ )));
122
115
  }
123
116
 
124
117
  let bytes = fs::read(path).map_err(|e| {
125
- setup_err(
126
- &ruby,
127
- format!(
128
- "failed to read Sandbox runtime at {}: {}",
129
- path.display(),
130
- e
131
- ),
132
- )
118
+ SetupError::Dead(format!(
119
+ "failed to read Sandbox runtime at {}: {e}",
120
+ path.display()
121
+ ))
133
122
  })?;
134
123
  let engine = shared_engine()?;
135
124
  let artifact = artifact_path(&bytes);
136
125
  let module = match artifact.as_deref().and_then(|p| load_artifact(engine, p)) {
137
126
  Some(module) => module,
138
127
  None => {
139
- let module = WtModule::new(engine, &bytes).map_err(|e| {
140
- setup_err(&ruby, format!("failed to compile Sandbox runtime: {}", e))
141
- })?;
128
+ let module = WtModule::new(engine, &bytes)
129
+ .map_err(|e| SetupError::Dead(format!("failed to compile Sandbox runtime: {e}")))?;
142
130
  if let Some(p) = artifact.as_deref() {
143
131
  store_artifact(&module, p);
144
132
  }
@@ -159,11 +147,12 @@ const ARTIFACT_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
159
147
 
160
148
  /// Compute the disk-cache location for a Guest Binary's compiled
161
149
  /// artifact: `$XDG_CACHE_HOME/kobako` (falling back to
162
- /// `~/.cache/kobako`) `/<sha256 of the wasm bytes>-<gem version>.cwasm`.
150
+ /// `~/.cache/kobako`) `/<sha256 of the wasm bytes>-<crate version>.cwasm`.
163
151
  /// Content addressing makes a rebuilt Guest Binary a new cache entry
164
- /// rather than an invalidation problem; the gem-version segment keeps
165
- /// two installed kobako versions (each pinning its own wasmtime) from
166
- /// sharing a key and recompile-thrashing each other's entry. wasmtime
152
+ /// rather than an invalidation problem; the crate-version segment keeps
153
+ /// two installed kobako-wasmtime versions (each pinning its own
154
+ /// wasmtime) from sharing a key and recompile-thrashing each other's
155
+ /// entry. wasmtime
167
156
  /// itself rejects an artifact produced by an incompatible wasmtime
168
157
  /// version or Config at deserialize time. Returns `None` when no home
169
158
  /// directory is available — the caller then just compiles in-process.
@@ -11,7 +11,7 @@
11
11
  /// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
12
12
  /// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
13
13
  /// `usize::MAX` when the channel is uncapped.
14
- pub(super) fn pipe_capacity(cap: Option<usize>) -> usize {
14
+ pub(crate) fn pipe_capacity(cap: Option<usize>) -> usize {
15
15
  match cap {
16
16
  Some(c) => c.saturating_add(1),
17
17
  None => usize::MAX,
@@ -24,7 +24,7 @@ pub(super) fn pipe_capacity(cap: Option<usize>) -> usize {
24
24
  /// `true` only when the snapshot strictly exceeded the cap — this is the
25
25
  /// "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case; "wrote
26
26
  /// exactly `cap` bytes" stays `false`.
27
- pub(super) fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
27
+ pub(crate) fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
28
28
  match cap {
29
29
  Some(c) if raw.len() > c => (&raw[..c], true),
30
30
  _ => (raw, false),
@@ -0,0 +1,33 @@
1
+ //! Per-`Driver` execution configuration.
2
+ //!
3
+ //! The wall-clock and per-channel capture caps a frontend forwards into
4
+ //! `Driver::new`. A plain value carrier owned by the `Driver` — distinct
5
+ //! from the process-wide engine/module `crate::cache` (which is shared
6
+ //! across every sandbox) and from the per-invocation
7
+ //! `crate::invocation::Invocation` (which the wasm engine mutates from
8
+ //! inside a run). These caps are read only by `Driver` methods between
9
+ //! runs, so they live here.
10
+
11
+ use std::time::Duration;
12
+
13
+ use kobako_runtime::profile::Profile;
14
+
15
+ /// Wall-clock and output caps plus the requested isolation profile for
16
+ /// one `Driver`. `None` on any cap field disables that cap.
17
+ pub struct Config {
18
+ /// Wall-clock cap for one guest `#eval` / `#run`. Stamped into a
19
+ /// per-run `Instant` deadline by `Driver::prime_caps`.
20
+ pub timeout: Option<Duration>,
21
+ /// Byte cap for guest stdout capture.
22
+ /// Sizes the per-run `MemoryOutputPipe` and computes the truncation
23
+ /// flag in `Driver::build_snapshot`.
24
+ pub stdout_limit_bytes: Option<usize>,
25
+ /// Byte cap for guest stderr capture. Mirror of `stdout_limit_bytes`.
26
+ pub stderr_limit_bytes: Option<usize>,
27
+ /// Isolation posture the frontend requested. The per-invocation
28
+ /// WASI context is built to this rung — `Hermetic` freezes ambient
29
+ /// time and entropy (`crate::ambient`), `Permissive` leaves the
30
+ /// live WASI sources — and `Driver::profile` declares it back as
31
+ /// the built posture.
32
+ pub profile: Profile,
33
+ }
@@ -0,0 +1,110 @@
1
+ //! Host-side dispatch for the `__kobako_dispatch` import.
2
+ //!
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:
7
+ //!
8
+ //! 1. Reads the Request bytes from guest linear memory.
9
+ //! 2. Invokes the bound `DispatchHandler` (the frontend's dispatch
10
+ //! bridge, e.g. a Ruby Proc) 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 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
+ //! 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 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.
47
+
48
+ use wasmtime::Caller;
49
+
50
+ use crate::invocation::Invocation;
51
+
52
+ /// Drive a single `__kobako_dispatch` invocation end-to-end. Entry point
53
+ /// from the wasmtime closure registered by `instance_pre::build_linker`.
54
+ ///
55
+ /// Returns the packed `(ptr<<32)|len` u64 on success, 0 on any
56
+ /// wire-layer fault. Failure paths log a `[kobako-dispatch]` line to
57
+ /// `stderr` so operators have a breadcrumb when the guest sees a 0
58
+ /// return and traps. The bound dispatch handler is contracted never to
59
+ /// raise (it folds Service exceptions into Response.err envelopes),
60
+ /// so reaching the failure path is always a wiring bug or wire-layer
61
+ /// fault rather than an expected path.
62
+ pub(crate) fn handle(caller: &mut Caller<'_, Invocation>, req_ptr: i32, req_len: i32) -> i64 {
63
+ match try_handle(caller, req_ptr, req_len) {
64
+ Ok(packed) => packed,
65
+ Err(reason) => {
66
+ eprintln!("[kobako-dispatch] {reason}");
67
+ 0
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Result-returning core of `handle`. Pulled out so each early
73
+ /// failure path carries a diagnostic string instead of an opaque 0.
74
+ fn try_handle(
75
+ caller: &mut Caller<'_, Invocation>,
76
+ req_ptr: i32,
77
+ req_len: i32,
78
+ ) -> Result<i64, &'static str> {
79
+ let req_bytes = crate::guest_mem::read(caller, req_ptr, req_len)?;
80
+
81
+ // `Kobako::Sandbox` always installs the dispatch handler before
82
+ // invoking the runtime, so reaching this branch indicates a misuse
83
+ // rather than a normal control path.
84
+ let handler = caller
85
+ .data()
86
+ .on_dispatch()
87
+ .ok_or("a Sandbox callback fired outside an active Sandbox#run — please report this as a kobako bug")?;
88
+
89
+ // Build a frame-scoped yielder over this Caller and hand it to the
90
+ // handler. The borrow ends with the block, freeing the Caller for
91
+ // `write_response`; nested dispatch frames each build their own, so
92
+ // the LIFO re-entry lives on the Rust stack — no shared slot.
93
+ let resp_bytes = {
94
+ let mut yielder = crate::guest_mem::CallerYielder::new(caller);
95
+ handler.dispatch(&req_bytes, &mut yielder)
96
+ }
97
+ .ok_or(
98
+ "a Sandbox callback raised an exception instead of returning a fault — please report this as a kobako bug",
99
+ )?;
100
+
101
+ write_response(caller, &resp_bytes)
102
+ }
103
+
104
+ /// Allocate a guest-side buffer and copy the response bytes into it via
105
+ /// `crate::guest_mem::alloc_and_write`, returning the packed
106
+ /// `(ptr<<32)|len` u64 the guest's `__kobako_dispatch` import expects.
107
+ fn write_response(caller: &mut Caller<'_, Invocation>, bytes: &[u8]) -> Result<i64, &'static str> {
108
+ let ptr = crate::guest_mem::alloc_and_write(caller, bytes)?;
109
+ Ok(((ptr as i64) << 32) | (bytes.len() as i64))
110
+ }
@@ -0,0 +1,297 @@
1
+ //! The wasmtime driver: everything needed to run one guest invocation,
2
+ //! expressed purely in contract and wasmtime types.
3
+ //!
4
+ //! A `Driver` is the engine half of a kobako host — the pre-linked
5
+ //! `InstancePre` plus the per-Driver caps — and implements the contract
6
+ //! `Runtime` trait over it. It is free of any frontend type; a frontend
7
+ //! shell (the Ruby ext's `Kobako::Runtime`) only shuttles its
8
+ //! host-language values across the contract boundary.
9
+
10
+ use std::path::Path;
11
+ use std::sync::Arc;
12
+ use std::time::Instant;
13
+
14
+ use wasmtime::{
15
+ AsContextMut, InstancePre as WtInstancePre, ResourceLimiter, Store as WtStore, TypedFunc,
16
+ };
17
+
18
+ use crate::cache::shared_engine;
19
+ use crate::config::Config;
20
+ use crate::exports::Exports;
21
+ use crate::invocation::Invocation;
22
+ use crate::{capture, frames, instance_pre, trap};
23
+ use kobako_runtime::dispatch::DispatchHandler;
24
+ use kobako_runtime::error::{Error, SetupError, Trap};
25
+ use kobako_runtime::profile::Profile;
26
+ use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
27
+ use kobako_runtime::snapshot::{Capture, Completion, Snapshot, Usage};
28
+
29
+ /// The wire ABI version this host implements (docs/wire-codec.md § ABI
30
+ /// Version). A Guest Binary is accepted only when its
31
+ /// `__kobako_abi_version` export reports the same value; a mismatch
32
+ /// is a deterministic artifact fault. The guest-side mirror is
33
+ /// `kobako_core::abi::ABI_VERSION`. Version 2
34
+ /// carries the per-invocation instance discipline: the host
35
+ /// drives every invocation on a fresh instance, so the guest may leave
36
+ /// its VM state dirty at exit.
37
+ const ABI_VERSION: u32 = 2;
38
+
39
+ /// The wasmtime execution unit behind one sandbox runtime.
40
+ pub struct Driver {
41
+ // Pre-linked instantiation template (import wiring + type checks
42
+ // done once in `instance_pre::cached_instance_pre`). Every
43
+ // invocation instantiates a fresh instance from it and discards the
44
+ // whole Store afterwards — the per-invocation instance discipline.
45
+ instance_pre: WtInstancePre<Invocation>,
46
+ // Per-invocation linear-memory cap,
47
+ // threaded into each fresh `Invocation`; lives apart from `Config`
48
+ // because the wasmtime `ResourceLimiter` callback consumes it from
49
+ // inside the wasm engine.
50
+ memory_limit: Option<usize>,
51
+ // Wall-clock + per-channel capture caps forwarded from the Sandbox;
52
+ // see `Config`.
53
+ config: Config,
54
+ }
55
+
56
+ impl Driver {
57
+ /// Construct a Driver from a wasm file path, using the process-wide
58
+ /// shared Engine and per-path Module / InstancePre caches, and verify
59
+ /// the artifact's ABI version. Every failure is a `SetupError` for
60
+ /// the frontend to attribute — Engine and Module never leave the
61
+ /// driver.
62
+ pub fn new(
63
+ path: &Path,
64
+ memory_limit: Option<usize>,
65
+ config: Config,
66
+ ) -> Result<Self, SetupError> {
67
+ let instance_pre = instance_pre::cached_instance_pre(path)?;
68
+ let driver = Self {
69
+ instance_pre,
70
+ memory_limit,
71
+ config,
72
+ };
73
+ driver.probe_abi_version()?;
74
+ Ok(driver)
75
+ }
76
+
77
+ /// Instantiate a throwaway probe instance at construction and require
78
+ /// the guest's `__kobako_abi_version` export to equal `ABI_VERSION`.
79
+ /// An absent export or a non-equal value is a deterministic artifact
80
+ /// fault. The probe Store drops here; invocation instances are
81
+ /// created per invoke. The frameless WASI context keeps a third-party
82
+ /// guest whose start section touches WASI on the `SetupError` path
83
+ /// instead of panicking in `Invocation::wasi_mut`.
84
+ fn probe_abi_version(&self) -> Result<(), SetupError> {
85
+ let mut store = self.new_store()?;
86
+ frames::install_wasi_frames(&mut store, &self.config, &[])
87
+ .map_err(|t| SetupError::Dead(t.to_string()))?;
88
+ let instance = self
89
+ .instance_pre
90
+ .instantiate(store.as_context_mut())
91
+ .map_err(trap::instantiate_err)?;
92
+ let probe = instance
93
+ .get_typed_func::<(), u32>(store.as_context_mut(), "__kobako_abi_version")
94
+ .map_err(|_| {
95
+ SetupError::Dead(format!(
96
+ "the Guest Binary does not export __kobako_abi_version; \
97
+ rebuild it against ABI version {ABI_VERSION}"
98
+ ))
99
+ })?;
100
+ let reported = probe.call(store.as_context_mut(), ()).map_err(|e| {
101
+ SetupError::Dead(format!(
102
+ "failed to read the Guest Binary's ABI version: {e}"
103
+ ))
104
+ })?;
105
+ if reported != ABI_VERSION {
106
+ return Err(SetupError::Dead(format!(
107
+ "the Guest Binary reports ABI version {reported}, but this host \
108
+ implements ABI version {ABI_VERSION}; rebuild the Guest Binary \
109
+ against the host's version"
110
+ )));
111
+ }
112
+ Ok(())
113
+ }
114
+
115
+ /// Build the per-invocation Store: a fresh `Invocation` wired with
116
+ /// the memory limiter and the epoch-deadline callback.
117
+ fn new_store(&self) -> Result<WtStore<Invocation>, SetupError> {
118
+ let mut store = WtStore::new(shared_engine()?, Invocation::new(self.memory_limit));
119
+ store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
120
+ store.epoch_deadline_callback(trap::epoch_deadline_callback);
121
+ Ok(store)
122
+ }
123
+
124
+ /// Instantiate the per-invocation instance from the pre-linked
125
+ /// template and resolve its host-driven export handles. An
126
+ /// instantiation failure at invocation time is an engine fault —
127
+ /// a `Trap` — unlike the construction-time probe, whose failure is
128
+ /// `SetupError`.
129
+ fn instantiate(&self, store: &mut WtStore<Invocation>) -> Result<Exports, Trap> {
130
+ let instance = self
131
+ .instance_pre
132
+ .instantiate(store.as_context_mut())
133
+ .map_err(|e| Trap::Other(format!("failed to instantiate the Sandbox runtime: {e}")))?;
134
+ Ok(Exports::resolve(&instance, store.as_context_mut()))
135
+ }
136
+
137
+ /// Run one guest export call inside the per-invocation cap window:
138
+ /// `Driver::prime_caps` before, `disarm_caps` after — the shared
139
+ /// bracket for both run-path exports (`__kobako_eval` /
140
+ /// `__kobako_run`). Disarm runs whether the call returns or traps, so
141
+ /// the `wall_time` bracket and the memory
142
+ /// cap always close — that close-on-trap guarantee is the reason this
143
+ /// bracket lives in one place rather than inline at each call site.
144
+ /// The wasmtime trap is returned unmapped; the caller classifies it
145
+ /// through `trap::trap_from`.
146
+ fn call_with_caps<Params, Results>(
147
+ &self,
148
+ store: &mut WtStore<Invocation>,
149
+ exports: &Exports,
150
+ export: &TypedFunc<Params, Results>,
151
+ params: Params,
152
+ ) -> Result<Results, wasmtime::Error>
153
+ where
154
+ Params: wasmtime::WasmParams,
155
+ Results: wasmtime::WasmResults,
156
+ {
157
+ self.prime_caps(store, exports);
158
+ let result = export.call(store.as_context_mut(), params);
159
+ disarm_caps(store);
160
+ result
161
+ }
162
+
163
+ /// Stamp the per-invocation wall-clock deadline into `Invocation`
164
+ /// and prime the wasmtime epoch deadline so the next ticker tick
165
+ /// wakes the epoch-deadline callback. When `timeout` is disabled,
166
+ /// the deadline is set far enough in the future that the callback
167
+ /// effectively never fires.
168
+ ///
169
+ /// Also captures the current linear-memory size as the baseline
170
+ /// for the per-invocation memory delta cap —
171
+ /// the pre-initialized image's allocation is folded into the
172
+ /// baseline rather than the budget — and stamps the wall-clock
173
+ /// entry instant for the `wall_time`
174
+ /// measurement. The bracket closes in `disarm_caps` so it matches
175
+ /// the `timeout` deadline window and excludes `OUTCOME_BUFFER`
176
+ /// decoding and stdout / stderr capture readout.
177
+ fn prime_caps(&self, store: &mut WtStore<Invocation>, exports: &Exports) {
178
+ match self.config.timeout {
179
+ Some(timeout) => {
180
+ let deadline = Instant::now() + timeout;
181
+ store.data_mut().set_deadline(Some(deadline));
182
+ store.set_epoch_deadline(1);
183
+ }
184
+ None => {
185
+ store.data_mut().set_deadline(None);
186
+ store.set_epoch_deadline(u64::MAX);
187
+ }
188
+ }
189
+ let baseline = match exports.memory {
190
+ Some(m) => m.data_size(store.as_context_mut()),
191
+ None => 0,
192
+ };
193
+ store.data_mut().arm_memory_cap(baseline);
194
+ store.data_mut().start_wall_clock();
195
+ }
196
+
197
+ /// Bundle one invocation's observables into a fresh `Snapshot`,
198
+ /// uniformly for every `completion` — the clipped captures and the
199
+ /// cap-bracket usage must survive a trap just as they do an outcome.
200
+ fn build_snapshot(&self, store: &WtStore<Invocation>, completion: Completion) -> Snapshot {
201
+ let data = store.data();
202
+ let usage = Usage {
203
+ wall_time: data.wall_time().as_secs_f64(),
204
+ memory_peak: data.memory_peak(),
205
+ };
206
+ let (stdout_raw, stderr_raw) = (data.stdout_bytes(), data.stderr_bytes());
207
+ let (stdout_visible, stdout_truncated) =
208
+ capture::clip_capture(&stdout_raw, self.config.stdout_limit_bytes);
209
+ let (stderr_visible, stderr_truncated) =
210
+ capture::clip_capture(&stderr_raw, self.config.stderr_limit_bytes);
211
+ Snapshot {
212
+ completion,
213
+ stdout: Capture {
214
+ bytes: stdout_visible.to_vec(),
215
+ truncated: stdout_truncated,
216
+ },
217
+ stderr: Capture {
218
+ bytes: stderr_visible.to_vec(),
219
+ truncated: stderr_truncated,
220
+ },
221
+ usage,
222
+ }
223
+ }
224
+ }
225
+
226
+ impl ContractRuntime for Driver {
227
+ /// Drive one guest invocation on a fresh instance and return its
228
+ /// `Snapshot`, `Ok` iff the guest export ran. Builds a fresh Store,
229
+ /// binds the borrowed dispatch handler, installs the stdin frames
230
+ /// (three for `Eval` — preamble / source / snippets; two for `Run` —
231
+ /// preamble / snippets, with the envelope copied into guest memory),
232
+ /// and primes the per-invocation caps around the export call. A fault
233
+ /// before the export call is the `Err` channel; once the call starts,
234
+ /// every fault folds into the Snapshot's `Completion` — the
235
+ /// configured-cap paths as `Trap::Timeout` / `Trap::MemoryLimit`,
236
+ /// everything else as `Trap::Other` — so captures and usage survive
237
+ /// it. The body touches no frontend value — the handler is only
238
+ /// borrowed (see the trait's safety contract).
239
+ fn invoke(
240
+ &self,
241
+ entry: Entry<'_>,
242
+ frames: Frames<'_>,
243
+ handler: Option<Arc<dyn DispatchHandler>>,
244
+ ) -> Result<Snapshot, Error> {
245
+ let mut store = self.new_store()?;
246
+ if let Some(handler) = handler {
247
+ store.data_mut().bind_on_dispatch(handler);
248
+ }
249
+ let frame_list: Vec<&[u8]> = match &entry {
250
+ Entry::Eval { source } => vec![frames.preamble, source, frames.snippets],
251
+ Entry::Run { .. } => vec![frames.preamble, frames.snippets],
252
+ };
253
+ frames::install_wasi_frames(&mut store, &self.config, &frame_list)?;
254
+ let exports = self.instantiate(&mut store)?;
255
+ let called = match entry {
256
+ Entry::Eval { .. } => {
257
+ let eval = frames::require_export(exports.eval.as_ref())?;
258
+ self.call_with_caps(&mut store, &exports, eval, ())
259
+ }
260
+ Entry::Run { envelope } => {
261
+ let run = frames::require_export(exports.run.as_ref())?;
262
+ let (env_ptr, env_len) = frames::write_envelope(&mut store, &exports, envelope)?;
263
+ self.call_with_caps(&mut store, &exports, run, (env_ptr, env_len))
264
+ }
265
+ };
266
+ let completion = match called {
267
+ Ok(()) => match frames::fetch_outcome_bytes(&mut store, &exports) {
268
+ Ok(bytes) => Completion::Outcome(bytes),
269
+ Err(t) => Completion::Trap(t),
270
+ },
271
+ Err(e) => Completion::Trap(trap::trap_from(e)),
272
+ };
273
+ Ok(self.build_snapshot(&store, completion))
274
+ }
275
+
276
+ /// The posture this driver built — exactly the rung requested via
277
+ /// `Config`. Every per-invocation WASI context is built to it
278
+ /// (`frames::install_wasi_frames`): `Hermetic` freezes ambient time
279
+ /// and entropy (`crate::ambient`), `Permissive` leaves the live WASI
280
+ /// sources. Both rungs wire no filesystem, environment, or network,
281
+ /// and the linker adds only the wire ABI's `__kobako_dispatch`
282
+ /// beyond that confined WASI surface (`crate::instance_pre`).
283
+ fn profile(&self) -> Profile {
284
+ self.config.profile
285
+ }
286
+ }
287
+
288
+ /// Drop the memory cap as soon as the guest call returns so that
289
+ /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
290
+ /// which can grow guest memory transiently) is not attributed to
291
+ /// the user script. Also closes the
292
+ /// `wall_time` bracket opened by `Driver::prime_caps`. Paired
293
+ /// with `Driver::prime_caps`.
294
+ fn disarm_caps(store: &mut WtStore<Invocation>) {
295
+ store.data_mut().stop_wall_clock();
296
+ store.data_mut().disarm_memory_cap();
297
+ }
@@ -1,16 +1,16 @@
1
1
  //! Per-invocation wasmtime export handles for the host-driven ABI
2
2
  //! surface.
3
3
  //!
4
- //! `Runtime::instantiate` resolves the ABI exports the run path drives
4
+ //! `Driver::instantiate` resolves the ABI exports the run path drives
5
5
  //! (`__kobako_eval` / `__kobako_run` / `__kobako_take_outcome` /
6
6
  //! `__kobako_alloc`) plus the `memory` export against each fresh
7
7
  //! per-invocation instance and bundles their
8
8
  //! typed handles here, so the invocation body passes one struct around
9
9
  //! rather than re-resolving exports by name at every step. Distinct
10
- //! from `super::cache` (the process-wide Engine / Module cache): this
10
+ //! from `crate::cache` (the process-wide Engine / Module cache): this
11
11
  //! carries *which guest function to call*, per invocation.
12
12
  //!
13
- //! `super::dispatch` does not reach this struct — a host import runs
13
+ //! `crate::dispatch` does not reach this struct — a host import runs
14
14
  //! against a `Caller`, so the dispatch path resolves `__kobako_alloc`
15
15
  //! and `memory` through `Caller::get_export` instead.
16
16
 
@@ -18,9 +18,8 @@ use wasmtime::{AsContextMut, Instance as WtInstance, Memory, TypedFunc};
18
18
 
19
19
  /// The resolved host-driven export handles. Each is `Option` because test
20
20
  /// fixtures (a minimal "ping" module) need not provide them; real
21
- /// `kobako.wasm` always does, and the run-path methods raise a Ruby
22
- /// `Kobako::TrapError` (via `require_export` / `require_memory`) when a
23
- /// handle is `None`.
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
23
  ///
25
24
  /// The handles are indices into the owning Store, not borrows of the
26
25
  /// `Instance` — they stay valid for the Store's lifetime, which is why