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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +71 -0
- data/Cargo.lock +117 -140
- data/README.md +100 -125
- data/ROADMAP.md +23 -22
- data/SECURITY.md +1 -1
- data/crates/kobako-runtime/CHANGELOG.md +31 -0
- data/crates/kobako-runtime/Cargo.toml +2 -2
- data/crates/kobako-runtime/README.md +4 -3
- data/crates/kobako-runtime/src/error.rs +8 -9
- data/crates/kobako-runtime/src/lib.rs +1 -1
- data/crates/kobako-runtime/src/profile.rs +1 -1
- data/crates/kobako-runtime/src/runtime.rs +31 -17
- data/crates/kobako-runtime/src/snapshot.rs +13 -10
- data/crates/kobako-runtime/src/yielder.rs +9 -6
- data/crates/kobako-transport/CHANGELOG.md +28 -0
- data/crates/kobako-transport/Cargo.toml +1 -1
- data/crates/kobako-transport/README.md +21 -12
- data/crates/kobako-transport/src/abi.rs +11 -14
- data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
- data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
- data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
- data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
- data/crates/kobako-transport/src/envelope/reply.rs +77 -53
- data/crates/kobako-transport/src/envelope/request.rs +295 -0
- data/crates/kobako-transport/src/envelope/run.rs +1 -2
- data/crates/kobako-transport/src/envelope/tag.rs +49 -0
- data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
- data/crates/kobako-transport/src/envelope.rs +19 -12
- data/crates/kobako-transport/src/lib.rs +0 -2
- data/crates/kobako-wasmtime/CHANGELOG.md +44 -0
- data/crates/kobako-wasmtime/Cargo.toml +7 -5
- data/crates/kobako-wasmtime/README.md +2 -2
- data/crates/kobako-wasmtime/src/abi.rs +5 -8
- data/crates/kobako-wasmtime/src/ambient.rs +2 -2
- data/crates/kobako-wasmtime/src/cache.rs +25 -59
- data/crates/kobako-wasmtime/src/capture.rs +4 -11
- data/crates/kobako-wasmtime/src/config.rs +11 -14
- data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
- data/crates/kobako-wasmtime/src/driver.rs +133 -86
- data/crates/kobako-wasmtime/src/exports.rs +88 -34
- data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
- data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
- data/crates/kobako-wasmtime/src/invocation.rs +55 -372
- data/crates/kobako-wasmtime/src/io.rs +286 -0
- data/crates/kobako-wasmtime/src/lib.rs +3 -24
- data/crates/kobako-wasmtime/src/limiter.rs +253 -0
- data/crates/kobako-wasmtime/src/trap.rs +32 -45
- data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
- data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
- data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
- data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +2 -2
- data/ext/kobako/src/runtime/bridge.rs +38 -47
- data/ext/kobako/src/runtime/errors.rs +26 -32
- data/ext/kobako/src/runtime/gvl.rs +4 -12
- data/ext/kobako/src/runtime.rs +169 -212
- data/lib/kobako/capture.rb +1 -4
- data/lib/kobako/catalog/extensions.rb +21 -59
- data/lib/kobako/catalog/flags.rb +41 -0
- data/lib/kobako/catalog/handles.rb +7 -54
- data/lib/kobako/catalog/services.rb +7 -61
- data/lib/kobako/catalog/snippets.rb +42 -66
- data/lib/kobako/catalog.rb +2 -10
- data/lib/kobako/codec/decoder.rb +9 -28
- data/lib/kobako/codec/encoder.rb +3 -22
- data/lib/kobako/codec/error.rb +6 -27
- data/lib/kobako/codec/ext_types.rb +72 -94
- data/lib/kobako/codec/handle_walk.rb +65 -130
- data/lib/kobako/codec/nesting.rb +3 -8
- data/lib/kobako/codec/state.rb +4 -10
- data/lib/kobako/codec/utils.rb +6 -34
- data/lib/kobako/codec.rb +3 -22
- data/lib/kobako/constant_name.rb +8 -0
- data/lib/kobako/context.rb +51 -112
- data/lib/kobako/errors.rb +51 -127
- data/lib/kobako/execution.rb +16 -21
- data/lib/kobako/extension.rb +23 -40
- data/lib/kobako/handle.rb +7 -12
- data/lib/kobako/outcome.rb +51 -80
- data/lib/kobako/payload/arguments.rb +1 -16
- data/lib/kobako/payload.rb +4 -12
- data/lib/kobako/pool.rb +23 -53
- data/lib/kobako/runtime.rb +7 -21
- data/lib/kobako/sandbox.rb +96 -133
- data/lib/kobako/sandbox_options.rb +38 -84
- data/lib/kobako/snippet/binary.rb +1 -16
- data/lib/kobako/snippet/source.rb +1 -16
- data/lib/kobako/snippet.rb +1 -9
- data/lib/kobako/transport/call.rb +1 -14
- data/lib/kobako/transport/dispatcher.rb +112 -195
- data/lib/kobako/transport/error.rb +6 -20
- data/lib/kobako/transport/exposure.rb +5 -10
- data/lib/kobako/transport/reflection.rb +39 -50
- data/lib/kobako/transport/run.rb +17 -71
- data/lib/kobako/transport/yielder.rb +21 -73
- data/lib/kobako/transport.rb +3 -15
- data/lib/kobako/unresolved.rb +4 -11
- data/lib/kobako/usage.rb +2 -6
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/catalog/extensions.rbs +2 -2
- data/sig/kobako/catalog/flags.rbs +17 -0
- data/sig/kobako/catalog/services.rbs +0 -6
- data/sig/kobako/catalog/snippets.rbs +4 -4
- data/sig/kobako/codec/ext_types.rbs +10 -8
- data/sig/kobako/codec/handle_walk.rbs +9 -7
- data/sig/kobako/codec/utils.rbs +2 -2
- data/sig/kobako/codec.rbs +2 -2
- data/sig/kobako/constant_name.rbs +3 -0
- data/sig/kobako/context.rbs +2 -3
- data/sig/kobako/errors.rbs +6 -0
- data/sig/kobako/outcome.rbs +6 -8
- data/sig/kobako/runtime.rbs +12 -8
- data/sig/kobako/sandbox.rbs +4 -0
- data/sig/kobako/sandbox_options.rbs +2 -6
- data/sig/kobako/transport/dispatcher.rbs +14 -18
- data/sig/kobako/transport/reflection.rbs +5 -3
- data/sig/kobako/transport/run.rbs +1 -3
- data/sig/kobako/transport/yielder.rbs +3 -3
- data/sig/kobako/transport.rbs +7 -6
- metadata +16 -4
- data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
- data/crates/kobako-wasmtime/src/frames.rs +0 -273
- data/rust-toolchain.toml +0 -15
|
@@ -5,17 +5,30 @@
|
|
|
5
5
|
//! onto its own error surface, and hosts the epoch-deadline callback
|
|
6
6
|
//! that raises the wall-clock `TimeoutTrap`. The classification is a
|
|
7
7
|
//! pure function over the error's downcast chain so it can be exercised
|
|
8
|
-
//! from `cargo test` without any frontend
|
|
9
|
-
//! themselves live in `crate::invocation` (where the limiter / callback
|
|
10
|
-
//! construct them).
|
|
8
|
+
//! from `cargo test` without any frontend.
|
|
11
9
|
|
|
12
10
|
use std::time::Instant;
|
|
13
11
|
|
|
14
12
|
use wasmtime::{StoreContextMut, UpdateDeadline};
|
|
15
13
|
|
|
16
|
-
use crate::invocation::
|
|
14
|
+
use crate::invocation::Invocation;
|
|
15
|
+
use crate::limiter::MemoryLimitTrap;
|
|
17
16
|
use kobako_runtime::error::{SetupError, Trap};
|
|
18
17
|
|
|
18
|
+
/// Marker error returned from the epoch-deadline callback when the
|
|
19
|
+
/// wall-clock deadline is exceeded. Downcast from the wasmtime trap
|
|
20
|
+
/// error to classify the failure as a timeout `Trap`.
|
|
21
|
+
#[derive(Debug)]
|
|
22
|
+
pub(crate) struct TimeoutTrap;
|
|
23
|
+
|
|
24
|
+
impl std::fmt::Display for TimeoutTrap {
|
|
25
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
26
|
+
write!(f, "wall-clock deadline exceeded")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl std::error::Error for TimeoutTrap {}
|
|
31
|
+
|
|
19
32
|
/// Epoch delta that keeps the deadline effectively unreachable when no
|
|
20
33
|
/// wall-clock cap is configured. Half the epoch range rather than
|
|
21
34
|
/// `u64::MAX`: wasmtime adds the delta to the engine's current epoch,
|
|
@@ -24,14 +37,9 @@ use kobako_runtime::error::{SetupError, Trap};
|
|
|
24
37
|
/// overflow checks).
|
|
25
38
|
pub(crate) const NO_TIMEOUT_EPOCH_DELTA: u64 = u64::MAX / 2;
|
|
26
39
|
|
|
27
|
-
///
|
|
28
|
-
///
|
|
29
|
-
///
|
|
30
|
-
/// next check by one tick of the process-wide epoch ticker. When the
|
|
31
|
-
/// deadline is `None` the callback should not fire under the normal
|
|
32
|
-
/// `Driver` invoke flow because
|
|
33
|
-
/// `NO_TIMEOUT_EPOCH_DELTA` is primed; returning the same long
|
|
34
|
-
/// extension keeps the callback inert as a defence in depth.
|
|
40
|
+
/// With no deadline the primed `NO_TIMEOUT_EPOCH_DELTA` keeps this from
|
|
41
|
+
/// firing; returning the same long extension keeps it inert as a defence
|
|
42
|
+
/// in depth.
|
|
35
43
|
pub(crate) fn epoch_deadline_callback(
|
|
36
44
|
ctx: StoreContextMut<'_, Invocation>,
|
|
37
45
|
) -> wasmtime::Result<UpdateDeadline> {
|
|
@@ -42,22 +50,9 @@ pub(crate) fn epoch_deadline_callback(
|
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
///
|
|
46
|
-
///
|
|
47
|
-
///
|
|
48
|
-
/// (`__kobako_eval` / `__kobako_run`) is deliberately omitted from the
|
|
49
|
-
/// message — the Sandbox layer attaches the user-facing verb
|
|
50
|
-
/// (`Sandbox#eval` / `Sandbox#run`) so the message reads in caller
|
|
51
|
-
/// vocabulary rather than ABI vocabulary.
|
|
52
|
-
///
|
|
53
|
-
/// For the configured-cap paths the trap's own `std::fmt::Display`
|
|
54
|
-
/// carries the user-facing reason (`"wall-clock deadline exceeded"`,
|
|
55
|
-
/// `"linear memory growth exceeded memory_limit: ..."`); the wasmtime
|
|
56
|
-
/// outer wrapper would otherwise surface only the `"error while
|
|
57
|
-
/// executing at wasm backtrace: ..."` framing, which is operator noise
|
|
58
|
-
/// on a cap trap. For any other error the framing is kept but the
|
|
59
|
-
/// chain's root cause is appended (see `other_trap_message`) so the
|
|
60
|
-
/// real trap reason survives.
|
|
53
|
+
/// The message leaves out the ABI export symbol, so the Sandbox layer can
|
|
54
|
+
/// attach the caller's verb instead. A cap trap carries its own message,
|
|
55
|
+
/// since wasmtime's backtrace framing is noise there.
|
|
61
56
|
pub(crate) fn trap_from(err: wasmtime::Error) -> Trap {
|
|
62
57
|
if let Some(t) = err.downcast_ref::<TimeoutTrap>() {
|
|
63
58
|
Trap::Timeout(t.to_string())
|
|
@@ -68,13 +63,9 @@ pub(crate) fn trap_from(err: wasmtime::Error) -> Trap {
|
|
|
68
63
|
}
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
///
|
|
72
|
-
/// the
|
|
73
|
-
///
|
|
74
|
-
/// chain's root cause and would otherwise be dropped, making real guest
|
|
75
|
-
/// faults undiagnosable. Append the root cause unless the framing already
|
|
76
|
-
/// carries it. Pure so it can be exercised from `cargo test` without the
|
|
77
|
-
/// magnus surface.
|
|
66
|
+
/// wasmtime's `Display` shows only the backtrace framing; the real trap
|
|
67
|
+
/// reason is the chain's root cause and would otherwise be dropped, leaving
|
|
68
|
+
/// a guest fault undiagnosable.
|
|
78
69
|
fn other_trap_message(err: &wasmtime::Error) -> String {
|
|
79
70
|
let display = format!("{}", err);
|
|
80
71
|
let root = err.root_cause().to_string();
|
|
@@ -85,22 +76,18 @@ fn other_trap_message(err: &wasmtime::Error) -> String {
|
|
|
85
76
|
}
|
|
86
77
|
}
|
|
87
78
|
|
|
88
|
-
///
|
|
89
|
-
///
|
|
90
|
-
///
|
|
91
|
-
/// per-invocation cap outcome. Neither cap can fire there: the memory
|
|
92
|
-
/// cap is dormant until an invocation arms it
|
|
93
|
-
/// (`Invocation::arm_memory_cap`) and the probe Store names an epoch
|
|
94
|
-
/// deadline it cannot reach, so the `trap_from` trap-class split does
|
|
95
|
-
/// not apply here.
|
|
79
|
+
/// The template is built before any invocation, so neither cap can fire:
|
|
80
|
+
/// the memory cap is not yet armed and the probe Store's epoch deadline is
|
|
81
|
+
/// out of reach. Every failure here is a setup fault, not a trap.
|
|
96
82
|
pub(crate) fn instantiate_err(err: wasmtime::Error) -> SetupError {
|
|
97
83
|
SetupError::Dead(format!("instantiate: {err}"))
|
|
98
84
|
}
|
|
99
85
|
|
|
100
86
|
#[cfg(test)]
|
|
101
87
|
mod tests {
|
|
102
|
-
use super::{other_trap_message, trap_from, NO_TIMEOUT_EPOCH_DELTA};
|
|
103
|
-
use crate::invocation::
|
|
88
|
+
use super::{other_trap_message, trap_from, TimeoutTrap, NO_TIMEOUT_EPOCH_DELTA};
|
|
89
|
+
use crate::invocation::Invocation;
|
|
90
|
+
use crate::limiter::MemoryLimitTrap;
|
|
104
91
|
use kobako_runtime::error::Trap;
|
|
105
92
|
|
|
106
93
|
// The no-timeout priming delta is added to the engine's current
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//! The bundled guest recognises no flag, so every flag a host sets meets
|
|
2
|
+
//! the unrecognised path: a critical one must refuse the invocation under
|
|
3
|
+
//! its own class, and an advisory one must leave the run untouched.
|
|
4
|
+
|
|
5
|
+
mod support;
|
|
6
|
+
|
|
7
|
+
use kobako_runtime::{Completion, Entry, Runtime};
|
|
8
|
+
use kobako_transport::envelope::{Flag, Origin, Outcome, Preamble};
|
|
9
|
+
|
|
10
|
+
/// The Outcome and what the script printed, or `None` when the Guest
|
|
11
|
+
/// Binary has not been built outside CI. The script prints before it
|
|
12
|
+
/// answers, so empty stdout means it never ran.
|
|
13
|
+
fn eval_with_flag(critical: bool) -> Option<(Outcome, Vec<u8>)> {
|
|
14
|
+
let driver = support::bundled_driver()?;
|
|
15
|
+
let mut preamble = Preamble::default();
|
|
16
|
+
preamble.flags = vec![Flag {
|
|
17
|
+
name: "no_such_flag".into(),
|
|
18
|
+
value: String::new(),
|
|
19
|
+
critical,
|
|
20
|
+
}];
|
|
21
|
+
|
|
22
|
+
let snapshot = driver
|
|
23
|
+
.invoke(
|
|
24
|
+
Entry::Eval {
|
|
25
|
+
source: b"puts 'ran'; 1 + 1",
|
|
26
|
+
},
|
|
27
|
+
&preamble,
|
|
28
|
+
None,
|
|
29
|
+
)
|
|
30
|
+
.expect("the invocation must start");
|
|
31
|
+
let Completion::Outcome(outcome) = snapshot.completion else {
|
|
32
|
+
panic!("the invocation must answer rather than trap");
|
|
33
|
+
};
|
|
34
|
+
Some((outcome, snapshot.stdout.bytes))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// @behavior MR-017
|
|
38
|
+
#[test]
|
|
39
|
+
fn a_critical_flag_the_guest_does_not_recognise_refuses_the_invocation() {
|
|
40
|
+
let Some((outcome, stdout)) = eval_with_flag(true) else {
|
|
41
|
+
return;
|
|
42
|
+
};
|
|
43
|
+
let Outcome::Panic(panic) = outcome else {
|
|
44
|
+
panic!("a critical flag the guest does not recognise must answer with a Panic");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
assert_eq!(
|
|
48
|
+
(panic.origin, panic.error.name.as_str(), stdout.as_slice()),
|
|
49
|
+
(Origin::Sandbox, "Kobako::UnsupportedFlagError", &b""[..]),
|
|
50
|
+
"a critical flag the bundled guest does not recognise through Driver#invoke must \
|
|
51
|
+
refuse the invocation as the Sandbox's Kobako::UnsupportedFlagError before the \
|
|
52
|
+
script runs"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// @behavior MR-018
|
|
57
|
+
#[test]
|
|
58
|
+
fn an_advisory_flag_the_guest_does_not_recognise_is_skipped() {
|
|
59
|
+
let Some(answer) = eval_with_flag(false) else {
|
|
60
|
+
return;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
assert_eq!(
|
|
64
|
+
answer,
|
|
65
|
+
(Outcome::Ok(vec![0x02]), b"ran\n".to_vec()),
|
|
66
|
+
"an advisory flag the bundled guest does not recognise through Driver#invoke must be \
|
|
67
|
+
skipped, leaving the invocation's value as it would be without the flag"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//! The bundled guest compiles a Preamble's source snippets on an instance
|
|
2
|
+
//! of their own. What comes back has to replay in a later invocation in
|
|
3
|
+
//! place of the source, under the same flags a replay would apply, and
|
|
4
|
+
//! two compiles of the same Preamble have to agree byte for byte so
|
|
5
|
+
//! either can stand in for the other.
|
|
6
|
+
|
|
7
|
+
mod support;
|
|
8
|
+
|
|
9
|
+
use kobako_runtime::{Completion, Entry, Runtime};
|
|
10
|
+
use kobako_transport::envelope::{Flag, Outcome, Preamble, Snippet, Snippets};
|
|
11
|
+
|
|
12
|
+
use support::bundled_driver as driver;
|
|
13
|
+
|
|
14
|
+
fn preamble_of(entries: Vec<Snippet>) -> Preamble {
|
|
15
|
+
let mut preamble = Preamble::default();
|
|
16
|
+
preamble.snippets = Snippets { entries };
|
|
17
|
+
preamble
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn greeter() -> Preamble {
|
|
21
|
+
preamble_of(vec![Snippet::Source {
|
|
22
|
+
name: "Greeter".into(),
|
|
23
|
+
body: "module Greeter; def self.hi = 'hi'; end".into(),
|
|
24
|
+
}])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// @behavior MR-024
|
|
28
|
+
#[test]
|
|
29
|
+
fn compiled_snippets_replay_in_a_later_invocation() {
|
|
30
|
+
let Some(driver) = driver() else {
|
|
31
|
+
return;
|
|
32
|
+
};
|
|
33
|
+
let bytecodes = driver
|
|
34
|
+
.compile(&greeter())
|
|
35
|
+
.expect("a source snippet that parses must compile");
|
|
36
|
+
let replay = preamble_of(
|
|
37
|
+
bytecodes
|
|
38
|
+
.into_iter()
|
|
39
|
+
.map(|body| Snippet::Bytecode { body })
|
|
40
|
+
.collect(),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
let snapshot = driver
|
|
44
|
+
.invoke(
|
|
45
|
+
Entry::Eval {
|
|
46
|
+
source: b"Greeter.hi",
|
|
47
|
+
},
|
|
48
|
+
&replay,
|
|
49
|
+
None,
|
|
50
|
+
)
|
|
51
|
+
.expect("the invocation must start");
|
|
52
|
+
|
|
53
|
+
assert!(
|
|
54
|
+
matches!(snapshot.completion, Completion::Outcome(Outcome::Ok(ref value)) if value == b"\xa2hi"),
|
|
55
|
+
"bytecode from Driver#compile, sent back as bytecode snippets, must define what the \
|
|
56
|
+
source did for a later invocation"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// @behavior MR-023
|
|
61
|
+
#[test]
|
|
62
|
+
fn compiling_the_same_preamble_twice_yields_the_same_bytes() {
|
|
63
|
+
let Some(driver) = driver() else {
|
|
64
|
+
return;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
assert_eq!(
|
|
68
|
+
driver.compile(&greeter()),
|
|
69
|
+
driver.compile(&greeter()),
|
|
70
|
+
"the same Preamble through Driver#compile on two fresh instances must answer \
|
|
71
|
+
byte-identical bytecode"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// @behavior MR-025
|
|
76
|
+
#[test]
|
|
77
|
+
fn a_critical_flag_the_guest_does_not_recognise_leaves_nothing_compiled() {
|
|
78
|
+
let Some(driver) = driver() else {
|
|
79
|
+
return;
|
|
80
|
+
};
|
|
81
|
+
let mut preamble = greeter();
|
|
82
|
+
preamble.flags = vec![Flag {
|
|
83
|
+
name: "no_such_flag".into(),
|
|
84
|
+
value: String::new(),
|
|
85
|
+
critical: true,
|
|
86
|
+
}];
|
|
87
|
+
|
|
88
|
+
assert_eq!(
|
|
89
|
+
driver.compile(&preamble),
|
|
90
|
+
None,
|
|
91
|
+
"a critical flag the bundled guest does not recognise through Driver#compile must \
|
|
92
|
+
leave nothing compiled, since compiling applies the flags a replay would"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//! The bundled Guest Binary every integration test drives.
|
|
2
|
+
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
use std::time::Duration;
|
|
5
|
+
|
|
6
|
+
use kobako_runtime::Profile;
|
|
7
|
+
use kobako_wasmtime::{Config, Driver};
|
|
8
|
+
|
|
9
|
+
const WASM: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../data/kobako.wasm");
|
|
10
|
+
|
|
11
|
+
/// A driver over the bundled Guest Binary, or `None` when it has not
|
|
12
|
+
/// been built outside CI, where a missing build is a failure instead.
|
|
13
|
+
pub fn bundled_driver() -> Option<Driver> {
|
|
14
|
+
if !Path::new(WASM).exists() {
|
|
15
|
+
assert!(
|
|
16
|
+
std::env::var_os("CI").is_none(),
|
|
17
|
+
"data/kobako.wasm missing under CI — run `bundle exec rake wasm:build`"
|
|
18
|
+
);
|
|
19
|
+
return None;
|
|
20
|
+
}
|
|
21
|
+
Some(
|
|
22
|
+
Driver::new(
|
|
23
|
+
Path::new(WASM),
|
|
24
|
+
Config {
|
|
25
|
+
timeout: Some(Duration::from_secs(10)),
|
|
26
|
+
memory_limit: None,
|
|
27
|
+
stdout_limit: None,
|
|
28
|
+
stderr_limit: None,
|
|
29
|
+
profile: Profile::Hermetic,
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
.expect("the Guest Binary must load"),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! A host frontend of its own may answer a guest's Call as unsupported;
|
|
2
|
+
//! the bundled guest must then hand its script a refusal it can rescue,
|
|
3
|
+
//! rather than a broken exchange. The default frontends serve every Call,
|
|
4
|
+
//! so only a handler written here reaches that answer.
|
|
5
|
+
|
|
6
|
+
mod support;
|
|
7
|
+
|
|
8
|
+
use std::sync::Arc;
|
|
9
|
+
|
|
10
|
+
use kobako_runtime::{Completion, DispatchHandler, Entry, Runtime, Yielder};
|
|
11
|
+
use kobako_transport::envelope::{Bindings, Call, Outcome, Preamble, Reply, Unsupported};
|
|
12
|
+
|
|
13
|
+
struct ServesNoCall;
|
|
14
|
+
|
|
15
|
+
impl DispatchHandler for ServesNoCall {
|
|
16
|
+
fn dispatch(&self, _call: Call<'_>, _yielder: &mut dyn Yielder) -> Option<Reply> {
|
|
17
|
+
Some(Reply::Unsupported(Unsupported::new("no calls here")))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// @behavior T-279
|
|
22
|
+
#[test]
|
|
23
|
+
fn a_call_the_host_does_not_serve_raises_unsupported_error_in_the_guest() {
|
|
24
|
+
let Some(driver) = support::bundled_driver() else {
|
|
25
|
+
return;
|
|
26
|
+
};
|
|
27
|
+
let mut preamble = Preamble::default();
|
|
28
|
+
preamble.bindings = Bindings {
|
|
29
|
+
paths: vec!["Svc".into()],
|
|
30
|
+
};
|
|
31
|
+
let source = b"begin; Svc.go; rescue Kobako::UnsupportedError => e; e.message; end";
|
|
32
|
+
|
|
33
|
+
let snapshot = driver
|
|
34
|
+
.invoke(
|
|
35
|
+
Entry::Eval { source },
|
|
36
|
+
&preamble,
|
|
37
|
+
Some(Arc::new(ServesNoCall)),
|
|
38
|
+
)
|
|
39
|
+
.expect("the invocation must start");
|
|
40
|
+
|
|
41
|
+
let Completion::Outcome(outcome) = snapshot.completion else {
|
|
42
|
+
panic!("the invocation must answer rather than trap");
|
|
43
|
+
};
|
|
44
|
+
assert_eq!(
|
|
45
|
+
outcome,
|
|
46
|
+
Outcome::Ok(b"\xadno calls here".to_vec()),
|
|
47
|
+
"a Service call answered as unsupported through Driver#invoke must raise \
|
|
48
|
+
Kobako::UnsupportedError carrying the host's message"
|
|
49
|
+
);
|
|
50
|
+
}
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "kobako"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.28.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Aotokitsuruya <contact@aotoki.me>"]
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -10,7 +10,7 @@ publish = false
|
|
|
10
10
|
crate-type = ["cdylib"]
|
|
11
11
|
|
|
12
12
|
[dependencies]
|
|
13
|
-
magnus = { version = "0.
|
|
13
|
+
magnus = { version = "0.9.2", features = ["rb-sys"] }
|
|
14
14
|
# Raw Ruby FFI for the GVL release/re-acquire calls magnus does not wrap
|
|
15
15
|
# (`rb_thread_call_without_gvl` / `rb_thread_call_with_gvl`). Pinned to the
|
|
16
16
|
# rb-sys magnus resolves so both share one binding instance.
|
|
@@ -22,10 +22,10 @@ use kobako_transport::envelope::{Call, Fault, FaultKind, Reply, Target, YieldRep
|
|
|
22
22
|
const ARM_OK: &str = "ok";
|
|
23
23
|
const ARM_BREAK: &str = "break";
|
|
24
24
|
const ARM_ERROR: &str = "error";
|
|
25
|
+
const ARM_UNSUPPORTED: &str = "unsupported";
|
|
25
26
|
|
|
26
|
-
///
|
|
27
|
-
///
|
|
28
|
-
/// `#[magnus::wrap]` class name resolves before any object is wrapped.
|
|
27
|
+
/// Runs after `Kobako::Runtime` is defined, so the `#[magnus::wrap]` class
|
|
28
|
+
/// name resolves before any object is wrapped.
|
|
29
29
|
pub(super) fn register(runtime_class: RClass) -> Result<(), MagnusError> {
|
|
30
30
|
let ruby = Ruby::get().expect("Ruby thread");
|
|
31
31
|
let class = runtime_class.define_class("GuestYielder", ruby.class_object())?;
|
|
@@ -35,7 +35,7 @@ pub(super) fn register(runtime_class: RClass) -> Result<(), MagnusError> {
|
|
|
35
35
|
|
|
36
36
|
/// Frame-scoped Ruby handle that lets the dispatch `Proc` re-enter the
|
|
37
37
|
/// guest to run a yielded block. It wraps the active `&mut dyn Yielder`
|
|
38
|
-
/// for exactly one `
|
|
38
|
+
/// for exactly one `__kobako_upcall` frame: the bridge builds one, hands
|
|
39
39
|
/// it to the `Proc` as the second argument, and `invalidate`s it the
|
|
40
40
|
/// instant the `Proc` returns. A guest block stashed and called after that
|
|
41
41
|
/// frame normally raises `LocalJumpError` at the Ruby
|
|
@@ -56,9 +56,9 @@ struct GuestYielder {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// SAFETY: magnus requires `Send + Sync` on wrapped types. The raw pointer
|
|
59
|
-
// is created, used, and invalidated within a single `
|
|
60
|
-
// frame on the one Ruby thread that owns the active Invocation
|
|
61
|
-
//
|
|
59
|
+
// is created, used, and invalidated within a single `__kobako_upcall`
|
|
60
|
+
// frame on the one Ruby thread that owns the active Invocation; it is
|
|
61
|
+
// never read from another thread.
|
|
62
62
|
unsafe impl Send for GuestYielder {}
|
|
63
63
|
unsafe impl Sync for GuestYielder {}
|
|
64
64
|
|
|
@@ -80,24 +80,17 @@ impl GuestYielder {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
///
|
|
84
|
-
///
|
|
85
|
-
///
|
|
83
|
+
/// Runs the instant the dispatch frame's `funcall` returns, so a guest
|
|
84
|
+
/// block stashed beyond its frame raises instead of dereferencing freed
|
|
85
|
+
/// stack.
|
|
86
86
|
fn invalidate(&self) {
|
|
87
87
|
self.yielder.set(None);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
///
|
|
91
|
-
///
|
|
92
|
-
/// named here off the core envelope, so the host `Transport::Yielder`
|
|
93
|
-
/// decodes only a payload — and only on the two arms that carry one,
|
|
94
|
-
/// where `body` is the block's value; on the error arm it is the
|
|
95
|
-
/// message, and `class` names what raised.
|
|
96
|
-
/// Raises `Kobako::TrapError` when the handle has been invalidated
|
|
97
|
-
/// (escaped guest block), when the re-entry itself traps, or when the
|
|
98
|
-
/// guest answers with bytes the envelope cannot frame.
|
|
90
|
+
/// The arm is named here off the decoded Yield Reply, so the host
|
|
91
|
+
/// `Transport::Yielder` decodes only a payload.
|
|
99
92
|
fn call(&self, args: RString) -> Result<(Symbol, RString, Option<String>), MagnusError> {
|
|
100
|
-
let ruby = Ruby::get().expect("Ruby handle unavailable in
|
|
93
|
+
let ruby = Ruby::get().expect("Ruby handle unavailable in the block bridge");
|
|
101
94
|
let Some(mut ptr) = self.yielder.get() else {
|
|
102
95
|
return Err(super::errors::trap_err(
|
|
103
96
|
&ruby,
|
|
@@ -107,26 +100,31 @@ impl GuestYielder {
|
|
|
107
100
|
let bytes = super::rstring_to_vec(args);
|
|
108
101
|
// SAFETY: `yielder` is `Some`, so `invalidate` has not run — the
|
|
109
102
|
// dispatch frame that lent the `&mut dyn Yielder` is still on the
|
|
110
|
-
// Rust stack, and
|
|
111
|
-
//
|
|
103
|
+
// Rust stack, and each dispatch frame lends its own Yielder on the
|
|
104
|
+
// one thread driving the invocation, so no other frame aliases it.
|
|
105
|
+
// The borrow ends with this method.
|
|
112
106
|
let yielder: &mut dyn Yielder = unsafe { ptr.as_mut() };
|
|
113
|
-
let
|
|
107
|
+
let reply = yielder
|
|
114
108
|
.yield_to_block(&bytes)
|
|
115
109
|
.map_err(|t| super::errors::trap_to_magnus(&ruby, t))?;
|
|
116
|
-
match
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
Ok(match reply {
|
|
111
|
+
YieldReply::Ok(body) => (ruby.to_symbol(ARM_OK), ruby.str_from_slice(&body), None),
|
|
112
|
+
YieldReply::Break(body) => {
|
|
113
|
+
(ruby.to_symbol(ARM_BREAK), ruby.str_from_slice(&body), None)
|
|
119
114
|
}
|
|
120
|
-
|
|
121
|
-
Ok((ruby.to_symbol(ARM_BREAK), ruby.str_from_slice(&body), None))
|
|
122
|
-
}
|
|
123
|
-
Ok(YieldReply::Error(record)) => Ok((
|
|
115
|
+
YieldReply::Error(record) => (
|
|
124
116
|
ruby.to_symbol(ARM_ERROR),
|
|
125
117
|
ruby.str_new(&record.message),
|
|
126
118
|
Some(record.name),
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
119
|
+
),
|
|
120
|
+
// The guest's own message stays behind: the host words this
|
|
121
|
+
// refusal itself.
|
|
122
|
+
YieldReply::Unsupported(_) => (
|
|
123
|
+
ruby.to_symbol(ARM_UNSUPPORTED),
|
|
124
|
+
ruby.str_from_slice(&[]),
|
|
125
|
+
None,
|
|
126
|
+
),
|
|
127
|
+
})
|
|
130
128
|
}
|
|
131
129
|
}
|
|
132
130
|
|
|
@@ -147,28 +145,21 @@ impl RubyDispatchHandler {
|
|
|
147
145
|
}
|
|
148
146
|
|
|
149
147
|
impl DispatchHandler for RubyDispatchHandler {
|
|
150
|
-
///
|
|
151
|
-
///
|
|
152
|
-
///
|
|
153
|
-
///
|
|
154
|
-
/// large payload's strings shared with the buffer rather than copied.
|
|
155
|
-
///
|
|
156
|
-
/// The Proc is contracted to fold every dispatch failure into the
|
|
157
|
-
/// Reply's fault arm (see `Kobako::Transport::Dispatcher.dispatch`),
|
|
158
|
-
/// so a raise is a contract violation surfaced as `None` — the
|
|
159
|
-
/// dispatcher then walks the 0-return wire-fault path.
|
|
148
|
+
/// Ruby decodes only the payload, through the codec it already owns,
|
|
149
|
+
/// which keeps a large payload's strings shared with the buffer rather
|
|
150
|
+
/// than copied. The Proc folds every dispatch failure into the Reply's
|
|
151
|
+
/// fault arm, so a raise is a contract violation surfaced as `None`.
|
|
160
152
|
fn dispatch(&self, call: Call<'_>, yielder: &mut dyn Yielder) -> Option<Reply> {
|
|
161
153
|
// The guest may be running GVL-free (`gvl: :release`); re-acquire the
|
|
162
154
|
// GVL for the whole callback before touching any Ruby VALUE. In hold
|
|
163
155
|
// mode `reenter` runs the body inline — the GVL is already held.
|
|
164
156
|
super::gvl::reenter(|| {
|
|
165
157
|
// The wasmtime callback runs on the same Ruby thread that called
|
|
166
|
-
// the active Sandbox invocation (#eval or #run)
|
|
167
|
-
//
|
|
168
|
-
// — so `Ruby::get()` is available here once the GVL is held.
|
|
158
|
+
// the active Sandbox invocation (#eval or #run), so `Ruby::get()`
|
|
159
|
+
// is available here once the GVL is held.
|
|
169
160
|
// Panicking with `expect` localises the violation rather than
|
|
170
161
|
// letting a nonsense error propagate.
|
|
171
|
-
let ruby = Ruby::get().expect("Ruby handle unavailable in
|
|
162
|
+
let ruby = Ruby::get().expect("Ruby handle unavailable in __kobako_upcall");
|
|
172
163
|
let proc_value: Value = ruby.get_inner(self.on_dispatch);
|
|
173
164
|
// The target crosses in one slot, already discriminated: a
|
|
174
165
|
// String for a bound constant's path, an Integer for a Handle
|
|
@@ -13,12 +13,8 @@ use magnus::{prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
|
|
|
13
13
|
|
|
14
14
|
use kobako_runtime::error::{InvokeError, SetupError, Trap};
|
|
15
15
|
|
|
16
|
-
///
|
|
17
|
-
///
|
|
18
|
-
/// name. The constants are guaranteed present by the time any of these
|
|
19
|
-
/// lazies first resolve (`lib/kobako/errors.rb` loads the hierarchy before
|
|
20
|
-
/// the ext raises into it), so a missing constant is a build / wiring bug
|
|
21
|
-
/// and the `unwrap` is the correct fail-fast.
|
|
16
|
+
/// `lib/kobako/errors.rb` loads the hierarchy before the ext raises into
|
|
17
|
+
/// it, so a missing constant is a wiring bug and the `unwrap` fails fast.
|
|
22
18
|
fn kobako_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
|
|
23
19
|
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
24
20
|
kobako.const_get(name).unwrap()
|
|
@@ -40,45 +36,46 @@ static MEMORY_LIMIT_ERROR: Lazy<ExceptionClass> =
|
|
|
40
36
|
static SANDBOX_ERROR: Lazy<ExceptionClass> =
|
|
41
37
|
Lazy::new(|ruby| kobako_error_class(ruby, "SandboxError"));
|
|
42
38
|
|
|
43
|
-
/// Build a `MagnusError` in `class` carrying `msg` — the shared body of
|
|
44
|
-
/// the named `*_err` constructors below, which differ only in which
|
|
45
|
-
/// error-class `Lazy` they target.
|
|
46
39
|
fn error_in(ruby: &Ruby, class: &Lazy<ExceptionClass>, msg: impl Into<String>) -> MagnusError {
|
|
47
40
|
MagnusError::new(ruby.get_inner(class), msg.into())
|
|
48
41
|
}
|
|
49
42
|
|
|
50
|
-
///
|
|
51
|
-
///
|
|
52
|
-
/// trap — missing exports, allocation faults, memory write/read failures.
|
|
53
|
-
/// Construction-time setup failures use `setup_err`, not this.
|
|
43
|
+
/// For an invocation-time engine failure that is not a configured cap;
|
|
44
|
+
/// construction-time failures use `setup_to_magnus`.
|
|
54
45
|
pub(super) fn trap_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
55
46
|
error_in(ruby, &TRAP_ERROR, msg)
|
|
56
47
|
}
|
|
57
48
|
|
|
58
|
-
///
|
|
59
|
-
///
|
|
60
|
-
|
|
61
|
-
/// becomes a raised exception. The verb prefix (`Sandbox#eval` / `#run`)
|
|
62
|
-
/// is added by `Kobako::Sandbox#invoke!`.
|
|
63
|
-
pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
|
|
49
|
+
/// The one place a trap meets the `Kobako::TrapError` family, whether it
|
|
50
|
+
/// stopped the run before the guest started or while it ran.
|
|
51
|
+
pub(super) fn trap_class(ruby: &Ruby, trap: &Trap) -> ExceptionClass {
|
|
64
52
|
match trap {
|
|
65
|
-
Trap::Timeout(
|
|
66
|
-
Trap::MemoryLimit(
|
|
53
|
+
Trap::Timeout(_) => ruby.get_inner(&TIMEOUT_ERROR),
|
|
54
|
+
Trap::MemoryLimit(_) => ruby.get_inner(&MEMORY_LIMIT_ERROR),
|
|
67
55
|
// A cap with no named subclass here is still an engine fault, so it
|
|
68
56
|
// takes the base class rather than borrowing another cap's name.
|
|
69
|
-
|
|
57
|
+
_ => ruby.get_inner(&TRAP_ERROR),
|
|
70
58
|
}
|
|
71
59
|
}
|
|
72
60
|
|
|
73
|
-
///
|
|
74
|
-
|
|
61
|
+
/// The verb prefix is left to `Kobako::Context#invoke!`.
|
|
62
|
+
pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
|
|
63
|
+
MagnusError::new(trap_class(ruby, &trap), trap.to_string())
|
|
64
|
+
}
|
|
65
|
+
|
|
75
66
|
pub(super) fn setup_to_magnus(ruby: &Ruby, err: SetupError) -> MagnusError {
|
|
76
67
|
match err {
|
|
77
|
-
|
|
68
|
+
// The engine serves every frontend, so the gem adds its own way to
|
|
69
|
+
// build the artifact.
|
|
70
|
+
SetupError::ModuleNotBuilt(msg) => error_in(
|
|
71
|
+
ruby,
|
|
72
|
+
&MODULE_NOT_BUILT_ERROR,
|
|
73
|
+
format!("{msg}; run `bundle exec rake wasm:build` to build it"),
|
|
74
|
+
),
|
|
78
75
|
SetupError::Dead(msg) => error_in(ruby, &SETUP_ERROR, msg),
|
|
79
|
-
// Runtime intact means a host-side pre-call fault
|
|
80
|
-
//
|
|
81
|
-
//
|
|
76
|
+
// Runtime intact means a host-side pre-call fault attributed to
|
|
77
|
+
// the sandbox / wire layer: the engine never ran, so never a
|
|
78
|
+
// TrapError.
|
|
82
79
|
SetupError::Intact(msg) => error_in(ruby, &SANDBOX_ERROR, msg),
|
|
83
80
|
// An unrecognised state is one this frontend cannot promise is
|
|
84
81
|
// recoverable, so it takes the same class as `Dead`.
|
|
@@ -86,14 +83,11 @@ pub(super) fn setup_to_magnus(ruby: &Ruby, err: SetupError) -> MagnusError {
|
|
|
86
83
|
}
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
/// Map either run-path channel onto its Ruby exception. The single
|
|
90
|
-
/// translation point the run-path entry methods funnel their `Result`
|
|
91
|
-
/// through.
|
|
92
86
|
pub(super) fn to_magnus(ruby: &Ruby, err: InvokeError) -> MagnusError {
|
|
93
87
|
match err {
|
|
94
88
|
InvokeError::Trap(trap) => trap_to_magnus(ruby, trap),
|
|
95
89
|
InvokeError::Setup(err) => setup_to_magnus(ruby, err),
|
|
96
|
-
// A
|
|
90
|
+
// A failure this frontend does not know is still a run that never
|
|
97
91
|
// started, which the taxonomy attributes to the engine.
|
|
98
92
|
other => trap_err(ruby, other.to_string()),
|
|
99
93
|
}
|