kobako 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.lock +1 -1
- data/README.md +85 -6
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/lib.rs +4 -2
- data/ext/kobako/src/{wasm → runtime}/cache.rs +22 -18
- data/ext/kobako/src/runtime/capture.rs +91 -0
- data/ext/kobako/src/runtime/config.rs +26 -0
- data/ext/kobako/src/runtime/dispatch.rs +211 -0
- data/ext/kobako/src/runtime/exports.rs +51 -0
- data/ext/kobako/src/runtime/guest_mem.rs +228 -0
- data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +195 -81
- data/ext/kobako/src/runtime/trap.rs +134 -0
- data/ext/kobako/src/runtime.rs +782 -0
- data/ext/kobako/src/snapshot.rs +110 -0
- data/lib/kobako/capture.rb +11 -16
- data/lib/kobako/catalog/handles.rb +107 -0
- data/lib/kobako/catalog/namespaces.rb +99 -0
- data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
- data/lib/kobako/catalog.rb +18 -0
- data/lib/kobako/codec/decoder.rb +13 -7
- data/lib/kobako/codec/factory.rb +21 -18
- data/lib/kobako/codec/utils.rb +118 -29
- data/lib/kobako/codec.rb +6 -3
- data/lib/kobako/errors.rb +45 -28
- data/lib/kobako/fault.rb +40 -0
- data/lib/kobako/handle.rb +60 -0
- data/lib/kobako/namespace.rb +67 -0
- data/lib/kobako/outcome.rb +55 -29
- data/lib/kobako/runtime.rb +30 -0
- data/lib/kobako/sandbox.rb +131 -67
- data/lib/kobako/sandbox_options.rb +6 -9
- data/lib/kobako/snapshot.rb +40 -0
- data/lib/kobako/snippet/binary.rb +6 -7
- data/lib/kobako/snippet/source.rb +8 -8
- data/lib/kobako/snippet.rb +7 -9
- data/lib/kobako/transport/dispatcher.rb +195 -0
- data/lib/kobako/transport/error.rb +24 -0
- data/lib/kobako/transport/request.rb +78 -0
- data/lib/kobako/transport/response.rb +69 -0
- data/lib/kobako/transport/run.rb +141 -0
- data/lib/kobako/transport/yield.rb +91 -0
- data/lib/kobako/transport/yielder.rb +89 -0
- data/lib/kobako/transport.rb +24 -0
- data/lib/kobako/usage.rb +41 -0
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +4 -3
- data/release-please-config.json +24 -0
- data/sig/kobako/capture.rbs +0 -2
- data/sig/kobako/{rpc/handle_table.rbs → catalog/handles.rbs} +3 -9
- data/sig/kobako/catalog/namespaces.rbs +17 -0
- data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
- data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
- data/sig/kobako/codec/decoder.rbs +2 -1
- data/sig/kobako/codec/factory.rbs +3 -3
- data/sig/kobako/codec/utils.rbs +11 -1
- data/sig/kobako/errors.rbs +7 -7
- data/sig/kobako/fault.rbs +19 -0
- data/sig/kobako/handle.rbs +18 -0
- data/sig/kobako/namespace.rbs +19 -0
- data/sig/kobako/outcome.rbs +2 -2
- data/sig/kobako/runtime.rbs +23 -0
- data/sig/kobako/sandbox.rbs +10 -7
- data/sig/kobako/snapshot.rbs +15 -0
- data/sig/kobako/transport/dispatcher.rbs +34 -0
- data/sig/kobako/transport/error.rbs +6 -0
- data/sig/kobako/transport/request.rbs +32 -0
- data/sig/kobako/transport/response.rbs +30 -0
- data/sig/kobako/transport/run.rbs +27 -0
- data/sig/kobako/transport/yield.rbs +34 -0
- data/sig/kobako/transport/yielder.rbs +21 -0
- data/sig/kobako/transport.rbs +4 -0
- data/sig/kobako/usage.rbs +11 -0
- metadata +52 -30
- data/ext/kobako/src/wasm/dispatch.rs +0 -161
- data/ext/kobako/src/wasm/instance.rs +0 -771
- data/ext/kobako/src/wasm.rs +0 -125
- data/lib/kobako/invocation.rb +0 -112
- data/lib/kobako/rpc/dispatcher.rb +0 -169
- data/lib/kobako/rpc/envelope.rb +0 -118
- data/lib/kobako/rpc/fault.rb +0 -41
- data/lib/kobako/rpc/handle.rb +0 -39
- data/lib/kobako/rpc/handle_table.rb +0 -107
- data/lib/kobako/rpc/namespace.rb +0 -74
- data/lib/kobako/rpc/server.rb +0 -158
- data/lib/kobako/rpc.rb +0 -11
- data/lib/kobako/wasm.rb +0 -25
- data/sig/kobako/invocation.rbs +0 -23
- data/sig/kobako/rpc/dispatcher.rbs +0 -33
- data/sig/kobako/rpc/envelope.rbs +0 -51
- data/sig/kobako/rpc/fault.rbs +0 -20
- data/sig/kobako/rpc/handle.rbs +0 -19
- data/sig/kobako/rpc/namespace.rbs +0 -24
- data/sig/kobako/rpc/server.rbs +0 -37
- data/sig/kobako/wasm.rbs +0 -39
|
@@ -1,771 +0,0 @@
|
|
|
1
|
-
//! `Kobako::Wasm::Instance` — the only Ruby-visible wasmtime wrapper.
|
|
2
|
-
//!
|
|
3
|
-
//! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
|
|
4
|
-
//! compiled [`Module`] are owned by the [`super::cache`] singletons and
|
|
5
|
-
//! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
|
|
6
|
-
//! mutability around `wasmtime::Store<HostState>`) plus three cached
|
|
7
|
-
//! [`TypedFunc`] handles for the docs/wire-codec.md ABI exports used by
|
|
8
|
-
//! the host-driven run path.
|
|
9
|
-
//!
|
|
10
|
-
//! The Ruby surface intentionally exposes intent, not the underlying ABI
|
|
11
|
-
//! (SPEC.md "Code Organization"). The length-prefixed stdin frame
|
|
12
|
-
//! protocol (three frames for `#eval`: preamble + source + snippets;
|
|
13
|
-
//! two for `#run`: preamble + snippets), packed-u64 outcome encoding,
|
|
14
|
-
//! and the `__kobako_eval` / `__kobako_run` / `__kobako_alloc` /
|
|
15
|
-
//! `__kobako_take_outcome` exports are all wrapped inside
|
|
16
|
-
//! [`Instance::eval`], [`Instance::run`], and [`Instance::outcome`];
|
|
17
|
-
//! Ruby callers see only `#eval(preamble, source, snippets)`,
|
|
18
|
-
//! `#run(preamble, snippets, envelope)`, `#stdout`, `#stderr`,
|
|
19
|
-
//! `#outcome!`, and `#server=`.
|
|
20
|
-
//!
|
|
21
|
-
//! WASI stdout/stderr capture (docs/behavior.md B-04): wasmtime-wasi p1
|
|
22
|
-
//! bindings route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`]
|
|
23
|
-
//! instances rebuilt at the start of every [`Instance::eval`] /
|
|
24
|
-
//! [`Instance::run`]. The per-channel cap is enforced directly on the
|
|
25
|
-
//! pipe — the pipe is sized at `cap + 1` so a guest that writes exactly
|
|
26
|
-
//! `cap` bytes is distinguishable from one that exceeded the cap, and
|
|
27
|
-
//! `#stdout` / `#stderr` slice the captured bytes back to `cap` before
|
|
28
|
-
//! returning them paired with a truncation flag. Uncapped channels
|
|
29
|
-
//! (`None`) build the pipe at `usize::MAX`; `memory_limit` provides
|
|
30
|
-
//! the real upper bound in that case.
|
|
31
|
-
//!
|
|
32
|
-
//! Per-run cap enforcement (docs/behavior.md B-01, E-19, E-20): every
|
|
33
|
-
//! Store installs an epoch-deadline callback for wall-clock timeout and
|
|
34
|
-
//! a [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
|
|
35
|
-
//! limiter / callback errors into traps; the run-path methods downcast
|
|
36
|
-
//! the trap source to surface as `Kobako::Wasm::TimeoutError` or
|
|
37
|
-
//! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
|
|
38
|
-
//! to the named `Kobako::TrapError` subclasses.
|
|
39
|
-
//!
|
|
40
|
-
//! [`Engine`]: wasmtime::Engine
|
|
41
|
-
//! [`Module`]: wasmtime::Module
|
|
42
|
-
//! [`TypedFunc`]: wasmtime::TypedFunc
|
|
43
|
-
//! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
|
|
44
|
-
//! [`ResourceLimiter`]: wasmtime::ResourceLimiter
|
|
45
|
-
|
|
46
|
-
use std::path::Path;
|
|
47
|
-
use std::time::{Duration, Instant};
|
|
48
|
-
|
|
49
|
-
use magnus::{value::Opaque, Error as MagnusError, RArray, RString, Ruby, Value};
|
|
50
|
-
use wasmtime::{
|
|
51
|
-
AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
|
|
52
|
-
ResourceLimiter, Store as WtStore, StoreContextMut, TypedFunc, UpdateDeadline,
|
|
53
|
-
};
|
|
54
|
-
use wasmtime_wasi::p1;
|
|
55
|
-
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
|
|
56
|
-
use wasmtime_wasi::WasiCtxBuilder;
|
|
57
|
-
|
|
58
|
-
use super::cache::{cached_module, shared_engine};
|
|
59
|
-
use super::dispatch;
|
|
60
|
-
use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
|
|
61
|
-
use super::{memory_limit_err, rstring_to_vec, timeout_err, wasm_err};
|
|
62
|
-
|
|
63
|
-
#[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
|
|
64
|
-
pub(crate) struct Instance {
|
|
65
|
-
inner: WtInstance,
|
|
66
|
-
store: StoreCell,
|
|
67
|
-
// Cached TypedFunc handles for the two host-driven ABI exports.
|
|
68
|
-
// Optional because test fixtures (a minimal "ping" module) need not
|
|
69
|
-
// provide them; real kobako.wasm always does, and the run-path methods
|
|
70
|
-
// raise a Ruby `Kobako::Wasm::Error` when an export is missing.
|
|
71
|
-
//
|
|
72
|
-
// `__kobako_alloc` is NOT cached here — only `dispatch.rs` calls it,
|
|
73
|
-
// and it does so through `Caller::get_export` on the wasmtime side.
|
|
74
|
-
eval: Option<TypedFunc<(), ()>>,
|
|
75
|
-
run: Option<TypedFunc<(i32, i32), ()>>,
|
|
76
|
-
take_outcome: Option<TypedFunc<(), u64>>,
|
|
77
|
-
// Wall-clock cap for one guest `#run` (docs/behavior.md B-01); `None` disables
|
|
78
|
-
// the cap. Translated into an `Instant`-based deadline stamped into
|
|
79
|
-
// [`HostState`] at the top of every `Instance::eval`.
|
|
80
|
-
timeout: Option<Duration>,
|
|
81
|
-
// Per-channel byte caps for guest stdout / stderr capture
|
|
82
|
-
// (docs/behavior.md B-01 / B-04). `None` disables the cap on that
|
|
83
|
-
// channel. Read by
|
|
84
|
-
// [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
|
|
85
|
-
// [`Instance::stdout`] / [`Instance::stderr`] to compute the
|
|
86
|
-
// truncation flag. See the module-level note above for the `cap + 1`
|
|
87
|
-
// sizing rationale. Unlike `memory_limit` (which lives on
|
|
88
|
-
// [`HostState`] because the wasmtime [`ResourceLimiter`] callback
|
|
89
|
-
// consumes it from within the wasm engine), these caps are read only
|
|
90
|
-
// by Instance methods, so they live on Instance itself.
|
|
91
|
-
stdout_limit_bytes: Option<usize>,
|
|
92
|
-
stderr_limit_bytes: Option<usize>,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
impl Instance {
|
|
96
|
-
/// Construct an Instance from a wasm file path, using the process-wide
|
|
97
|
-
/// shared Engine and per-path Module cache. The single Ruby-facing
|
|
98
|
-
/// constructor for `Kobako::Wasm::Instance` — Engine and Module are
|
|
99
|
-
/// never visible to Ruby.
|
|
100
|
-
///
|
|
101
|
-
/// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
|
|
102
|
-
/// (`None` disables); `memory_limit` is the linear-memory cap in
|
|
103
|
-
/// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
|
|
104
|
-
/// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
|
|
105
|
-
/// disables). All four are validated by the caller
|
|
106
|
-
/// (`Kobako::Sandbox`); this method only refuses non-finite or
|
|
107
|
-
/// non-positive timeouts as a defence in depth.
|
|
108
|
-
pub(crate) fn from_path(
|
|
109
|
-
path: String,
|
|
110
|
-
timeout_seconds: Option<f64>,
|
|
111
|
-
memory_limit: Option<usize>,
|
|
112
|
-
stdout_limit_bytes: Option<usize>,
|
|
113
|
-
stderr_limit_bytes: Option<usize>,
|
|
114
|
-
) -> Result<Self, MagnusError> {
|
|
115
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
116
|
-
let timeout = match timeout_seconds {
|
|
117
|
-
None => None,
|
|
118
|
-
Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
|
|
119
|
-
Some(secs) => {
|
|
120
|
-
return Err(wasm_err(
|
|
121
|
-
&ruby,
|
|
122
|
-
format!("timeout_seconds must be > 0 and finite, got {secs}"),
|
|
123
|
-
));
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
let engine = shared_engine()?;
|
|
128
|
-
let module = cached_module(Path::new(&path))?;
|
|
129
|
-
|
|
130
|
-
let mut store = WtStore::new(engine, HostState::new(memory_limit));
|
|
131
|
-
store.limiter(|state: &mut HostState| -> &mut dyn ResourceLimiter { state.limiter_mut() });
|
|
132
|
-
store.epoch_deadline_callback(epoch_deadline_callback);
|
|
133
|
-
|
|
134
|
-
let store_cell = StoreCell::new(store);
|
|
135
|
-
Self::build(
|
|
136
|
-
engine,
|
|
137
|
-
&module,
|
|
138
|
-
store_cell,
|
|
139
|
-
timeout,
|
|
140
|
-
stdout_limit_bytes,
|
|
141
|
-
stderr_limit_bytes,
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/// Build an `Instance` from an engine, module, and store cell. The
|
|
146
|
-
/// store cell is moved in and ends up owned by the returned Instance.
|
|
147
|
-
/// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
|
|
148
|
-
fn build(
|
|
149
|
-
engine: &wasmtime::Engine,
|
|
150
|
-
module: &WtModule,
|
|
151
|
-
store_cell: StoreCell,
|
|
152
|
-
timeout: Option<Duration>,
|
|
153
|
-
stdout_limit_bytes: Option<usize>,
|
|
154
|
-
stderr_limit_bytes: Option<usize>,
|
|
155
|
-
) -> Result<Self, MagnusError> {
|
|
156
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
157
|
-
let mut linker: Linker<HostState> = Linker::new(engine);
|
|
158
|
-
|
|
159
|
-
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
|
|
160
|
-
// to the MemoryOutputPipes set up before each run via
|
|
161
|
-
// `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
|
|
162
|
-
// HostState; the panic semantics live inside `HostState::wasi_mut`
|
|
163
|
-
// so the wiring stays honest about its precondition.
|
|
164
|
-
p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
|
|
165
|
-
.map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
|
|
166
|
-
|
|
167
|
-
// `__kobako_dispatch` host import. Signature per SPEC Wire ABI:
|
|
168
|
-
// (req_ptr: i32, req_len: i32) -> i64
|
|
169
|
-
// Decodes the Request bytes, dispatches via the Ruby-side
|
|
170
|
-
// `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
|
|
171
|
-
// guest buffer through `__kobako_alloc`, writes the Response bytes
|
|
172
|
-
// there, and returns the packed `(ptr<<32)|len`. The dispatcher
|
|
173
|
-
// returns 0 on any wire-layer fault (including a missing
|
|
174
|
-
// Server); see `dispatch::handle`.
|
|
175
|
-
linker
|
|
176
|
-
.func_wrap(
|
|
177
|
-
"env",
|
|
178
|
-
"__kobako_dispatch",
|
|
179
|
-
|mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
|
|
180
|
-
dispatch::handle(&mut caller, req_ptr, req_len)
|
|
181
|
-
},
|
|
182
|
-
)
|
|
183
|
-
.map_err(|e| wasm_err(&ruby, format!("define __kobako_dispatch: {}", e)))?;
|
|
184
|
-
|
|
185
|
-
let instance = {
|
|
186
|
-
let mut store_ref = store_cell.borrow_mut();
|
|
187
|
-
linker
|
|
188
|
-
.instantiate(store_ref.as_context_mut(), module)
|
|
189
|
-
.map_err(|e| instantiate_err(&ruby, e))?
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// Best-effort export lookup. Missing exports are not an error here
|
|
193
|
-
// (test fixture is a bare module); the host enforces presence at
|
|
194
|
-
// invocation time by raising a Ruby `Kobako::Wasm::Error` when the
|
|
195
|
-
// cached Option is None. Only the SPEC ABI `() -> ()` shape is
|
|
196
|
-
// accepted for `__kobako_eval`; `__kobako_run` takes
|
|
197
|
-
// `(env_ptr, env_len) -> ()` per docs/wire-codec.md § ABI
|
|
198
|
-
// Signatures.
|
|
199
|
-
let (eval, run, take_outcome) = {
|
|
200
|
-
let mut store_ref = store_cell.borrow_mut();
|
|
201
|
-
let mut ctx = store_ref.as_context_mut();
|
|
202
|
-
let eval = instance
|
|
203
|
-
.get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
|
|
204
|
-
.ok();
|
|
205
|
-
let run = instance
|
|
206
|
-
.get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
|
|
207
|
-
.ok();
|
|
208
|
-
let take_outcome = instance
|
|
209
|
-
.get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
|
|
210
|
-
.ok();
|
|
211
|
-
(eval, run, take_outcome)
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
Ok(Self {
|
|
215
|
-
inner: instance,
|
|
216
|
-
store: store_cell,
|
|
217
|
-
eval,
|
|
218
|
-
run,
|
|
219
|
-
take_outcome,
|
|
220
|
-
timeout,
|
|
221
|
-
stdout_limit_bytes,
|
|
222
|
-
stderr_limit_bytes,
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/// Install the Ruby-side `Kobako::RPC::Server` into HostState. Bound to
|
|
227
|
-
/// Ruby as `Instance#server=`. From this point on, every
|
|
228
|
-
/// `__kobako_dispatch` import invocation routes through
|
|
229
|
-
/// `server.dispatch(req_bytes)`.
|
|
230
|
-
pub(crate) fn set_server(&self, server: Value) -> Result<(), MagnusError> {
|
|
231
|
-
let mut store_ref = self.store.borrow_mut();
|
|
232
|
-
store_ref.data_mut().bind_server(Opaque::from(server));
|
|
233
|
-
Ok(())
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// -----------------------------------------------------------------
|
|
237
|
-
// Run-path methods. Each method is best-effort — it raises a Ruby
|
|
238
|
-
// `Kobako::Wasm::Error` when the corresponding export is missing or
|
|
239
|
-
// fails so the Sandbox layer can map errors to the three-class
|
|
240
|
-
// taxonomy.
|
|
241
|
-
// -----------------------------------------------------------------
|
|
242
|
-
|
|
243
|
-
/// Execute one guest invocation (`__kobako_eval` — one-shot source).
|
|
244
|
-
///
|
|
245
|
-
/// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
|
|
246
|
-
/// (the three-frame stdin protocol carries +preamble+, +source+, then
|
|
247
|
-
/// +snippets+ — docs/wire-codec.md § Invocation channels), then
|
|
248
|
-
/// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
|
|
249
|
-
/// B-01) are primed here: the wall-clock deadline is stamped into
|
|
250
|
-
/// [`HostState`] and the epoch deadline is set to fire at the next
|
|
251
|
-
/// ticker tick; the memory-cap limiter is already wired.
|
|
252
|
-
pub(crate) fn eval(
|
|
253
|
-
&self,
|
|
254
|
-
preamble: RString,
|
|
255
|
-
source: RString,
|
|
256
|
-
snippets: RString,
|
|
257
|
-
) -> Result<(), MagnusError> {
|
|
258
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
259
|
-
let eval = require_export(&ruby, self.eval.as_ref(), "__kobako_eval")?;
|
|
260
|
-
self.refresh_wasi(&[
|
|
261
|
-
rstring_to_vec(preamble),
|
|
262
|
-
rstring_to_vec(source),
|
|
263
|
-
rstring_to_vec(snippets),
|
|
264
|
-
]);
|
|
265
|
-
self.prime_caps();
|
|
266
|
-
let result = {
|
|
267
|
-
let mut store_ref = self.store.borrow_mut();
|
|
268
|
-
eval.call(store_ref.as_context_mut(), ())
|
|
269
|
-
};
|
|
270
|
-
self.disarm_caps();
|
|
271
|
-
result.map_err(|e| call_err(&ruby, "__kobako_eval", e))
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/// Execute one entrypoint dispatch (`__kobako_run`).
|
|
275
|
-
///
|
|
276
|
-
/// Rebuilds the WASI context with the two-frame stdin protocol
|
|
277
|
-
/// (preamble + snippets; no user source frame — docs/wire-codec.md
|
|
278
|
-
/// § Invocation channels), copies +envelope+ bytes into guest linear
|
|
279
|
-
/// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
|
|
280
|
-
/// env_len)`. Per-invocation cap semantics match [`Instance::eval`].
|
|
281
|
-
/// Returns +Kobako::Wasm::Error+ ("alloc returned 0") when guest
|
|
282
|
-
/// allocation fails (docs/behavior.md E-31).
|
|
283
|
-
pub(crate) fn run(
|
|
284
|
-
&self,
|
|
285
|
-
preamble: RString,
|
|
286
|
-
snippets: RString,
|
|
287
|
-
envelope: RString,
|
|
288
|
-
) -> Result<(), MagnusError> {
|
|
289
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
290
|
-
let run = require_export(&ruby, self.run.as_ref(), "__kobako_run")?;
|
|
291
|
-
self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)]);
|
|
292
|
-
let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
|
|
293
|
-
self.prime_caps();
|
|
294
|
-
let result = {
|
|
295
|
-
let mut store_ref = self.store.borrow_mut();
|
|
296
|
-
run.call(store_ref.as_context_mut(), (env_ptr, env_len))
|
|
297
|
-
};
|
|
298
|
-
self.disarm_caps();
|
|
299
|
-
result.map_err(|e| call_err(&ruby, "__kobako_run", e))
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/// Return the stdout capture from the most recent run as a Ruby
|
|
303
|
-
/// `[bytes, truncated]` Array — `bytes` is a binary String containing
|
|
304
|
-
/// the captured prefix (clipped to `stdout_limit_bytes` when set),
|
|
305
|
-
/// and `truncated` is a boolean that is `true` only when the guest
|
|
306
|
-
/// wrote strictly more than the cap. The pair is recomputed from the
|
|
307
|
-
/// underlying pipe contents on every call; the pipe itself is not
|
|
308
|
-
/// drained until the next `#run` rebuilds it.
|
|
309
|
-
pub(crate) fn stdout(&self) -> Result<RArray, MagnusError> {
|
|
310
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
311
|
-
let raw = self.store.borrow().data().stdout_bytes();
|
|
312
|
-
capture_pair(&ruby, &raw, self.stdout_limit_bytes)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/// Return the stderr capture from the most recent run. Same shape
|
|
316
|
-
/// and semantics as [`Instance::stdout`].
|
|
317
|
-
pub(crate) fn stderr(&self) -> Result<RArray, MagnusError> {
|
|
318
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
319
|
-
let raw = self.store.borrow().data().stderr_bytes();
|
|
320
|
-
capture_pair(&ruby, &raw, self.stderr_limit_bytes)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/// Read OUTCOME_BUFFER bytes captured during the most recent run.
|
|
324
|
-
/// Bound to Ruby as `Instance#outcome!`. The bang signals that the
|
|
325
|
-
/// underlying `__kobako_take_outcome` export is guest-side destructive
|
|
326
|
-
/// — the buffer pointer is invalidated after this call, so a second
|
|
327
|
-
/// invocation within the same run is undefined — and that any failure
|
|
328
|
-
/// (missing export, length overflow, OOB read) raises
|
|
329
|
-
/// `Kobako::Wasm::Error`.
|
|
330
|
-
pub(crate) fn outcome(&self) -> Result<RString, MagnusError> {
|
|
331
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
332
|
-
let bytes = self.fetch_outcome_bytes(&ruby)?;
|
|
333
|
-
Ok(ruby.str_from_slice(&bytes))
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// -----------------------------------------------------------------
|
|
337
|
-
// Private helpers.
|
|
338
|
-
// -----------------------------------------------------------------
|
|
339
|
-
|
|
340
|
-
/// Stamp the per-invocation wall-clock deadline into [`HostState`]
|
|
341
|
-
/// and prime the wasmtime epoch deadline so the next ticker tick
|
|
342
|
-
/// wakes the epoch-deadline callback. When `timeout` is disabled,
|
|
343
|
-
/// the deadline is set far enough in the future that the callback
|
|
344
|
-
/// effectively never fires.
|
|
345
|
-
///
|
|
346
|
-
/// Also captures the current linear-memory size as the baseline
|
|
347
|
-
/// for the docs/behavior.md E-20 per-invocation memory delta cap.
|
|
348
|
-
/// The mruby image's declared initial allocation and the high-water
|
|
349
|
-
/// mark left by prior invocations on the same Sandbox are folded
|
|
350
|
-
/// into the baseline rather than the budget — only `memory.grow`
|
|
351
|
-
/// past +baseline+ counts against `memory_limit`.
|
|
352
|
-
fn prime_caps(&self) {
|
|
353
|
-
let mut store_ref = self.store.borrow_mut();
|
|
354
|
-
match self.timeout {
|
|
355
|
-
Some(timeout) => {
|
|
356
|
-
let deadline = Instant::now() + timeout;
|
|
357
|
-
store_ref.data_mut().set_deadline(Some(deadline));
|
|
358
|
-
store_ref.set_epoch_deadline(1);
|
|
359
|
-
}
|
|
360
|
-
None => {
|
|
361
|
-
store_ref.data_mut().set_deadline(None);
|
|
362
|
-
store_ref.set_epoch_deadline(u64::MAX);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
366
|
-
Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
|
|
367
|
-
_ => 0,
|
|
368
|
-
};
|
|
369
|
-
store_ref.data_mut().arm_memory_cap(baseline);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/// Drop the memory cap as soon as the guest call returns so that
|
|
373
|
-
/// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
|
|
374
|
-
/// which can grow guest memory transiently) is not attributed to
|
|
375
|
-
/// the user script. Paired with [`Instance::prime_caps`].
|
|
376
|
-
fn disarm_caps(&self) {
|
|
377
|
-
self.store.borrow_mut().data_mut().disarm_memory_cap();
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/// Allocate a +len+-byte buffer in guest linear memory via
|
|
381
|
-
/// `__kobako_alloc`, copy +envelope+ into it, and return +(ptr, len)+
|
|
382
|
-
/// as +i32+ values matching the `__kobako_run(env_ptr, env_len)` ABI.
|
|
383
|
-
/// Raises +Kobako::Wasm::Error+ when the guest export is missing or
|
|
384
|
-
/// allocation fails (docs/behavior.md E-31).
|
|
385
|
-
fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
|
|
386
|
-
let bytes = rstring_to_vec(envelope);
|
|
387
|
-
let len_i32 = envelope_len_to_i32(bytes.len()).map_err(|msg| wasm_err(ruby, msg))?;
|
|
388
|
-
|
|
389
|
-
let mut store_ref = self.store.borrow_mut();
|
|
390
|
-
let alloc: TypedFunc<u32, u32> = self
|
|
391
|
-
.inner
|
|
392
|
-
.get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
|
|
393
|
-
.map_err(|_| wasm_err(ruby, "guest does not export __kobako_alloc"))?;
|
|
394
|
-
let ptr = alloc
|
|
395
|
-
.call(store_ref.as_context_mut(), bytes.len() as u32)
|
|
396
|
-
.map_err(|e| wasm_err(ruby, format!("__kobako_alloc(): {}", e)))?;
|
|
397
|
-
if ptr == 0 {
|
|
398
|
-
return Err(wasm_err(ruby, "__kobako_alloc returned 0 (out of memory)"));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
402
|
-
Some(Extern::Memory(m)) => m,
|
|
403
|
-
_ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
|
|
404
|
-
};
|
|
405
|
-
let data = memory.data_mut(store_ref.as_context_mut());
|
|
406
|
-
let range = guest_buffer_range(ptr as usize, bytes.len(), data.len())
|
|
407
|
-
.map_err(|msg| wasm_err(ruby, msg))?;
|
|
408
|
-
data[range].copy_from_slice(&bytes);
|
|
409
|
-
|
|
410
|
-
Ok((ptr as i32, len_i32))
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/// Rebuild the WASI context with fresh stdin (carrying every frame in
|
|
414
|
-
/// +frames+, each prefixed by its 4-byte big-endian u32 length —
|
|
415
|
-
/// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
|
|
416
|
-
/// pipes. Called at the top of every guest invocation: +#eval+ passes
|
|
417
|
-
/// three frames (preamble, source, snippets), +#run+ passes two
|
|
418
|
-
/// (preamble, snippets — the invocation envelope arrives via linear
|
|
419
|
-
/// memory instead). Each output pipe is sized at `cap + 1` so
|
|
420
|
-
/// [`Instance::stdout`] / [`Instance::stderr`] can distinguish "wrote
|
|
421
|
-
/// exactly cap bytes" from "exceeded cap"; uncapped channels fall back
|
|
422
|
-
/// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
|
|
423
|
-
/// for the real ceiling.
|
|
424
|
-
fn refresh_wasi(&self, frames: &[Vec<u8>]) {
|
|
425
|
-
let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
|
|
426
|
-
let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
|
|
427
|
-
for frame in frames {
|
|
428
|
-
stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
|
|
429
|
-
stdin_content.extend_from_slice(frame);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
let stdin_pipe = MemoryInputPipe::new(stdin_content);
|
|
433
|
-
let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
|
|
434
|
-
let stderr_pipe = MemoryOutputPipe::new(pipe_capacity(self.stderr_limit_bytes));
|
|
435
|
-
|
|
436
|
-
let mut builder = WasiCtxBuilder::new();
|
|
437
|
-
builder.stdin(stdin_pipe);
|
|
438
|
-
builder.stdout(stdout_pipe.clone());
|
|
439
|
-
builder.stderr(stderr_pipe.clone());
|
|
440
|
-
let wasi = builder.build_p1();
|
|
441
|
-
|
|
442
|
-
self.store
|
|
443
|
-
.borrow_mut()
|
|
444
|
-
.data_mut()
|
|
445
|
-
.install_wasi(wasi, stdout_pipe, stderr_pipe);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
|
|
449
|
-
/// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
|
|
450
|
-
/// `Kobako::Wasm::Error` when the export is missing, the +ptr+/+len+
|
|
451
|
-
/// arithmetic overflows, the slice falls outside live memory, or the
|
|
452
|
-
/// `memory` export itself is absent.
|
|
453
|
-
fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
|
|
454
|
-
let take = require_export(ruby, self.take_outcome.as_ref(), "__kobako_take_outcome")?;
|
|
455
|
-
|
|
456
|
-
let mut store_ref = self.store.borrow_mut();
|
|
457
|
-
let packed = take
|
|
458
|
-
.call(store_ref.as_context_mut(), ())
|
|
459
|
-
.map_err(|e| wasm_err(ruby, format!("__kobako_take_outcome(): {}", e)))?;
|
|
460
|
-
let (ptr, len) = unpack_outcome_packed(packed);
|
|
461
|
-
|
|
462
|
-
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
463
|
-
Some(Extern::Memory(m)) => m,
|
|
464
|
-
_ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
|
|
465
|
-
};
|
|
466
|
-
let data = mem.data(store_ref.as_context_mut());
|
|
467
|
-
let range = guest_buffer_range(ptr, len, data.len())
|
|
468
|
-
.map_err(|msg| wasm_err(ruby, format!("outcome: {}", msg)))?;
|
|
469
|
-
Ok(data[range].to_vec())
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/// Return the cached +TypedFunc+ for an ABI export, or raise
|
|
474
|
-
/// +Kobako::Wasm::Error+ when the option is +None+. The run-path
|
|
475
|
-
/// methods (+#eval+, +#run+, +#outcome!+) all share the same
|
|
476
|
-
/// "missing export → Ruby error" boilerplate; this helper collapses
|
|
477
|
-
/// the three sites onto one safe entry.
|
|
478
|
-
fn require_export<'a, Params, Results>(
|
|
479
|
-
ruby: &Ruby,
|
|
480
|
-
export: Option<&'a TypedFunc<Params, Results>>,
|
|
481
|
-
name: &str,
|
|
482
|
-
) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
|
|
483
|
-
where
|
|
484
|
-
Params: wasmtime::WasmParams,
|
|
485
|
-
Results: wasmtime::WasmResults,
|
|
486
|
-
{
|
|
487
|
-
export.ok_or_else(|| wasm_err(ruby, format!("guest does not export {}", name)))
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/// Validate the invocation envelope length and return it as +i32+ — the
|
|
491
|
-
/// signed wasm wire-ABI parameter type for `__kobako_run`. Rejects sizes
|
|
492
|
-
/// above +i32::MAX+ so the downstream cast cannot silently wrap.
|
|
493
|
-
fn envelope_len_to_i32(len: usize) -> Result<i32, &'static str> {
|
|
494
|
-
i32::try_from(len).map_err(|_| "invocation envelope exceeds i32::MAX bytes")
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/// Compute the half-open range `[ptr, ptr + len)` for a guest linear-memory
|
|
498
|
-
/// copy, validating that the arithmetic does not overflow and the range
|
|
499
|
-
/// fits inside `mem_size`. Shared by [`Instance::write_envelope`] (write
|
|
500
|
-
/// side) and [`Instance::fetch_outcome_bytes`] (read side).
|
|
501
|
-
fn guest_buffer_range(
|
|
502
|
-
ptr: usize,
|
|
503
|
-
len: usize,
|
|
504
|
-
mem_size: usize,
|
|
505
|
-
) -> Result<core::ops::Range<usize>, &'static str> {
|
|
506
|
-
let end = ptr.checked_add(len).ok_or("ptr + len overflow")?;
|
|
507
|
-
if end > mem_size {
|
|
508
|
-
return Err("range exceeds guest memory size");
|
|
509
|
-
}
|
|
510
|
-
Ok(ptr..end)
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/// Unpack the `(ptr, len)` u64 returned by `__kobako_take_outcome`:
|
|
514
|
-
/// high 32 bits = ptr, low 32 bits = len. Mirrors the guest-side
|
|
515
|
-
/// `crate::abi::unpack_u64` in `wasm/kobako-wasm/src/abi.rs`.
|
|
516
|
-
fn unpack_outcome_packed(packed: u64) -> (usize, usize) {
|
|
517
|
-
let ptr = (packed >> 32) as u32 as usize;
|
|
518
|
-
let len = packed as u32 as usize;
|
|
519
|
-
(ptr, len)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
|
|
523
|
-
/// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
|
|
524
|
-
/// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
|
|
525
|
-
/// `usize::MAX` when the channel is uncapped.
|
|
526
|
-
fn pipe_capacity(cap: Option<usize>) -> usize {
|
|
527
|
-
match cap {
|
|
528
|
-
Some(c) => c.saturating_add(1),
|
|
529
|
-
None => usize::MAX,
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/// Pure slicing core shared by [`Instance::stdout`] / [`Instance::stderr`]:
|
|
534
|
-
/// given the unclipped pipe snapshot and the configured cap, return the
|
|
535
|
-
/// bytes Ruby should observe (clipped to `cap`) plus the truncation flag.
|
|
536
|
-
/// `truncated` is `true` only when the snapshot strictly exceeded the cap
|
|
537
|
-
/// — this is the "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case;
|
|
538
|
-
/// "wrote exactly `cap` bytes" stays `false`.
|
|
539
|
-
fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
|
|
540
|
-
match cap {
|
|
541
|
-
Some(c) if raw.len() > c => (&raw[..c], true),
|
|
542
|
-
_ => (raw, false),
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/// Build the `[bytes, truncated]` Ruby Array surfaced by
|
|
547
|
-
/// [`Instance::stdout`] / [`Instance::stderr`]. Delegates the slicing
|
|
548
|
-
/// to [`clip_capture`] so the channel-agnostic logic stays unit-
|
|
549
|
-
/// testable from `cargo test`.
|
|
550
|
-
fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, MagnusError> {
|
|
551
|
-
let (visible, truncated) = clip_capture(raw, cap);
|
|
552
|
-
let arr = ruby.ary_new_capa(2);
|
|
553
|
-
arr.push(ruby.str_from_slice(visible))?;
|
|
554
|
-
arr.push(truncated)?;
|
|
555
|
-
Ok(arr)
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/// Epoch-deadline callback installed on every Store. Read the per-run
|
|
559
|
-
/// wall-clock deadline from [`HostState`] (docs/behavior.md B-01) and trap with
|
|
560
|
-
/// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
|
|
561
|
-
/// next check by one tick of the process-wide epoch ticker. When the
|
|
562
|
-
/// deadline is `None` the callback should not fire under normal
|
|
563
|
-
/// `Instance::eval` / `Instance::run` flow because
|
|
564
|
-
/// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
|
|
565
|
-
/// keeps the callback inert as a defence in depth.
|
|
566
|
-
fn epoch_deadline_callback(
|
|
567
|
-
ctx: StoreContextMut<'_, HostState>,
|
|
568
|
-
) -> wasmtime::Result<UpdateDeadline> {
|
|
569
|
-
match ctx.data().deadline() {
|
|
570
|
-
Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
|
|
571
|
-
Some(_) => Ok(UpdateDeadline::Continue(1)),
|
|
572
|
-
None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/// Configured-cap path classification for a wasmtime error. The
|
|
577
|
-
/// downcast logic stays in a pure helper so the
|
|
578
|
-
/// `Kobako::Wasm::TimeoutError` / `MemoryLimitError` /
|
|
579
|
-
/// `Kobako::Wasm::Error` mapping can be exercised from `cargo test`
|
|
580
|
-
/// without the magnus surface.
|
|
581
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
582
|
-
enum TrapClass {
|
|
583
|
-
/// docs/behavior.md E-19 wall-clock cap path.
|
|
584
|
-
Timeout,
|
|
585
|
-
/// docs/behavior.md E-20 linear-memory cap path.
|
|
586
|
-
MemoryLimit,
|
|
587
|
-
/// Any other wasmtime error — surfaces as the base
|
|
588
|
-
/// `Kobako::Wasm::Error`.
|
|
589
|
-
Other,
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/// Inspect a wasmtime error to decide which `Kobako::Wasm::*` class it
|
|
593
|
-
/// should map to. Pure function — operates on the error's downcast
|
|
594
|
-
/// chain only, no magnus / Ruby state required.
|
|
595
|
-
fn classify_trap(err: &wasmtime::Error) -> TrapClass {
|
|
596
|
-
if err.downcast_ref::<TimeoutTrap>().is_some() {
|
|
597
|
-
TrapClass::Timeout
|
|
598
|
-
} else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
|
|
599
|
-
TrapClass::MemoryLimit
|
|
600
|
-
} else {
|
|
601
|
-
TrapClass::Other
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
|
|
606
|
-
/// exception class. `__kobako_eval` / `__kobako_run` traps are routed
|
|
607
|
-
/// through [`classify_trap`]; +export+ is the failing export name and
|
|
608
|
-
/// appears in the trap message so the Sandbox layer can attribute the
|
|
609
|
-
/// fault to the right verb.
|
|
610
|
-
fn call_err(ruby: &Ruby, export: &str, err: wasmtime::Error) -> MagnusError {
|
|
611
|
-
let msg = format!("{}(): {}", export, err);
|
|
612
|
-
match classify_trap(&err) {
|
|
613
|
-
TrapClass::Timeout => timeout_err(ruby, msg),
|
|
614
|
-
TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
|
|
615
|
-
TrapClass::Other => wasm_err(ruby, msg),
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
|
|
620
|
-
/// exception. The memory cap is dormant during instantiation by design
|
|
621
|
-
/// (see [`HostState::arm_memory_cap`] / [`HostState::disarm_memory_cap`]),
|
|
622
|
-
/// but [`MemoryLimitTrap`] is still possible if a future Sandbox
|
|
623
|
-
/// configuration enables it during instantiation — keep the mapping
|
|
624
|
-
/// symmetric with [`call_err`]. [`TrapClass::Timeout`] is unreachable on
|
|
625
|
-
/// the instantiation path (the epoch deadline is not armed yet) but
|
|
626
|
-
/// folding it into the same `match` keeps the two paths visually paired.
|
|
627
|
-
fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
628
|
-
let msg = format!("instantiate: {}", err);
|
|
629
|
-
match classify_trap(&err) {
|
|
630
|
-
TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
|
|
631
|
-
TrapClass::Timeout | TrapClass::Other => wasm_err(ruby, msg),
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
#[cfg(test)]
|
|
636
|
-
mod tests {
|
|
637
|
-
//! Host-side unit tests for the pure capture helpers. The Ruby-
|
|
638
|
-
//! facing E2E suite exercises stdout only (the kobako mrbgem
|
|
639
|
-
//! allowlist excludes guest fd 2 writes); these tests pin the
|
|
640
|
-
//! channel-agnostic slicing so a regression that only breaks one
|
|
641
|
-
//! channel cannot sneak through.
|
|
642
|
-
use super::{
|
|
643
|
-
classify_trap, clip_capture, envelope_len_to_i32, guest_buffer_range, pipe_capacity,
|
|
644
|
-
unpack_outcome_packed, TrapClass,
|
|
645
|
-
};
|
|
646
|
-
use super::{MemoryLimitTrap, TimeoutTrap};
|
|
647
|
-
|
|
648
|
-
#[test]
|
|
649
|
-
fn pipe_capacity_adds_one_when_cap_is_set() {
|
|
650
|
-
assert_eq!(pipe_capacity(Some(5)), 6);
|
|
651
|
-
assert_eq!(pipe_capacity(Some(0)), 1);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
#[test]
|
|
655
|
-
fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
|
|
656
|
-
assert_eq!(pipe_capacity(None), usize::MAX);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
#[test]
|
|
660
|
-
fn pipe_capacity_saturates_at_usize_max() {
|
|
661
|
-
assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
#[test]
|
|
665
|
-
fn clip_capture_returns_full_bytes_when_under_cap() {
|
|
666
|
-
let (bytes, truncated) = clip_capture(b"abc", Some(5));
|
|
667
|
-
assert_eq!(bytes, b"abc");
|
|
668
|
-
assert!(!truncated);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
#[test]
|
|
672
|
-
fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
|
|
673
|
-
let (bytes, truncated) = clip_capture(b"abcde", Some(5));
|
|
674
|
-
assert_eq!(bytes, b"abcde");
|
|
675
|
-
assert!(!truncated);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
#[test]
|
|
679
|
-
fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
|
|
680
|
-
// The pipe is sized `cap + 1`, so the snapshot can be at most
|
|
681
|
-
// 6 bytes when `cap == 5`; that surface is what triggers the
|
|
682
|
-
// truncation flag.
|
|
683
|
-
let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
|
|
684
|
-
assert_eq!(bytes, b"abcde");
|
|
685
|
-
assert!(truncated);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
#[test]
|
|
689
|
-
fn clip_capture_treats_none_as_uncapped() {
|
|
690
|
-
let (bytes, truncated) = clip_capture(b"abcdef", None);
|
|
691
|
-
assert_eq!(bytes, b"abcdef");
|
|
692
|
-
assert!(!truncated);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
#[test]
|
|
696
|
-
fn clip_capture_handles_empty_input() {
|
|
697
|
-
let (bytes, truncated) = clip_capture(b"", Some(5));
|
|
698
|
-
assert_eq!(bytes, b"");
|
|
699
|
-
assert!(!truncated);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
#[test]
|
|
703
|
-
fn envelope_len_to_i32_accepts_zero_and_max() {
|
|
704
|
-
assert_eq!(envelope_len_to_i32(0), Ok(0));
|
|
705
|
-
assert_eq!(envelope_len_to_i32(i32::MAX as usize), Ok(i32::MAX));
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
#[test]
|
|
709
|
-
fn envelope_len_to_i32_rejects_past_i32_max() {
|
|
710
|
-
assert!(envelope_len_to_i32(i32::MAX as usize + 1).is_err());
|
|
711
|
-
assert!(envelope_len_to_i32(usize::MAX).is_err());
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
#[test]
|
|
715
|
-
fn guest_buffer_range_returns_half_open_range() {
|
|
716
|
-
// Standard case: ptr + len fits inside memory.
|
|
717
|
-
assert_eq!(guest_buffer_range(10, 5, 100), Ok(10..15));
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
#[test]
|
|
721
|
-
fn guest_buffer_range_accepts_zero_length_at_any_in_bounds_ptr() {
|
|
722
|
-
// Zero-length writes / reads must succeed as long as ptr is in
|
|
723
|
-
// bounds — both reactor calls hand zero-length frames through
|
|
724
|
-
// (e.g. an empty Frame 3 snippets list).
|
|
725
|
-
assert_eq!(guest_buffer_range(0, 0, 0), Ok(0..0));
|
|
726
|
-
assert_eq!(guest_buffer_range(42, 0, 100), Ok(42..42));
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
#[test]
|
|
730
|
-
fn guest_buffer_range_rejects_ptr_plus_len_overflow() {
|
|
731
|
-
assert!(guest_buffer_range(usize::MAX, 1, usize::MAX).is_err());
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
#[test]
|
|
735
|
-
fn guest_buffer_range_rejects_end_past_memory() {
|
|
736
|
-
assert!(guest_buffer_range(10, 100, 50).is_err());
|
|
737
|
-
// End exactly equal to mem_size is in-bounds.
|
|
738
|
-
assert_eq!(guest_buffer_range(0, 50, 50), Ok(0..50));
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
#[test]
|
|
742
|
-
fn unpack_outcome_packed_extracts_high_ptr_low_len() {
|
|
743
|
-
assert_eq!(
|
|
744
|
-
unpack_outcome_packed(0xAABB_CCDD_1122_3344),
|
|
745
|
-
(0xAABB_CCDD, 0x1122_3344)
|
|
746
|
-
);
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
#[test]
|
|
750
|
-
fn unpack_outcome_packed_zero_decodes_to_zero_pair() {
|
|
751
|
-
assert_eq!(unpack_outcome_packed(0), (0, 0));
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
#[test]
|
|
755
|
-
fn classify_trap_routes_timeout_trap_to_timeout() {
|
|
756
|
-
let err = wasmtime::Error::new(TimeoutTrap);
|
|
757
|
-
assert_eq!(classify_trap(&err), TrapClass::Timeout);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
#[test]
|
|
761
|
-
fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
|
|
762
|
-
let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
|
|
763
|
-
assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
#[test]
|
|
767
|
-
fn classify_trap_falls_back_to_other_for_unknown_errors() {
|
|
768
|
-
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
769
|
-
assert_eq!(classify_trap(&err), TrapClass::Other);
|
|
770
|
-
}
|
|
771
|
-
}
|