kobako 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +71 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +100 -125
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +31 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +4 -3
  11. data/crates/kobako-runtime/src/error.rs +8 -9
  12. data/crates/kobako-runtime/src/lib.rs +1 -1
  13. data/crates/kobako-runtime/src/profile.rs +1 -1
  14. data/crates/kobako-runtime/src/runtime.rs +31 -17
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -10
  16. data/crates/kobako-runtime/src/yielder.rs +9 -6
  17. data/crates/kobako-transport/CHANGELOG.md +28 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +21 -12
  20. data/crates/kobako-transport/src/abi.rs +11 -14
  21. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  22. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  23. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  24. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  25. data/crates/kobako-transport/src/envelope/reply.rs +77 -53
  26. data/crates/kobako-transport/src/envelope/request.rs +295 -0
  27. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  28. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  29. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  30. data/crates/kobako-transport/src/envelope.rs +19 -12
  31. data/crates/kobako-transport/src/lib.rs +0 -2
  32. data/crates/kobako-wasmtime/CHANGELOG.md +44 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +2 -2
  35. data/crates/kobako-wasmtime/src/abi.rs +5 -8
  36. data/crates/kobako-wasmtime/src/ambient.rs +2 -2
  37. data/crates/kobako-wasmtime/src/cache.rs +25 -59
  38. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  39. data/crates/kobako-wasmtime/src/config.rs +11 -14
  40. data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
  41. data/crates/kobako-wasmtime/src/driver.rs +133 -86
  42. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  43. data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
  44. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
  45. data/crates/kobako-wasmtime/src/invocation.rs +55 -372
  46. data/crates/kobako-wasmtime/src/io.rs +286 -0
  47. data/crates/kobako-wasmtime/src/lib.rs +3 -24
  48. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  49. data/crates/kobako-wasmtime/src/trap.rs +32 -45
  50. data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
  51. data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
  52. data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
  53. data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
  54. data/data/kobako.wasm +0 -0
  55. data/ext/kobako/Cargo.toml +2 -2
  56. data/ext/kobako/src/runtime/bridge.rs +38 -47
  57. data/ext/kobako/src/runtime/errors.rs +26 -32
  58. data/ext/kobako/src/runtime/gvl.rs +4 -12
  59. data/ext/kobako/src/runtime.rs +169 -212
  60. data/lib/kobako/capture.rb +1 -4
  61. data/lib/kobako/catalog/extensions.rb +21 -59
  62. data/lib/kobako/catalog/flags.rb +41 -0
  63. data/lib/kobako/catalog/handles.rb +7 -54
  64. data/lib/kobako/catalog/services.rb +7 -61
  65. data/lib/kobako/catalog/snippets.rb +42 -66
  66. data/lib/kobako/catalog.rb +2 -10
  67. data/lib/kobako/codec/decoder.rb +9 -28
  68. data/lib/kobako/codec/encoder.rb +3 -22
  69. data/lib/kobako/codec/error.rb +6 -27
  70. data/lib/kobako/codec/ext_types.rb +72 -94
  71. data/lib/kobako/codec/handle_walk.rb +65 -130
  72. data/lib/kobako/codec/nesting.rb +3 -8
  73. data/lib/kobako/codec/state.rb +4 -10
  74. data/lib/kobako/codec/utils.rb +6 -34
  75. data/lib/kobako/codec.rb +3 -22
  76. data/lib/kobako/constant_name.rb +8 -0
  77. data/lib/kobako/context.rb +51 -112
  78. data/lib/kobako/errors.rb +51 -127
  79. data/lib/kobako/execution.rb +16 -21
  80. data/lib/kobako/extension.rb +23 -40
  81. data/lib/kobako/handle.rb +7 -12
  82. data/lib/kobako/outcome.rb +51 -80
  83. data/lib/kobako/payload/arguments.rb +1 -16
  84. data/lib/kobako/payload.rb +4 -12
  85. data/lib/kobako/pool.rb +23 -53
  86. data/lib/kobako/runtime.rb +7 -21
  87. data/lib/kobako/sandbox.rb +96 -133
  88. data/lib/kobako/sandbox_options.rb +38 -84
  89. data/lib/kobako/snippet/binary.rb +1 -16
  90. data/lib/kobako/snippet/source.rb +1 -16
  91. data/lib/kobako/snippet.rb +1 -9
  92. data/lib/kobako/transport/call.rb +1 -14
  93. data/lib/kobako/transport/dispatcher.rb +112 -195
  94. data/lib/kobako/transport/error.rb +6 -20
  95. data/lib/kobako/transport/exposure.rb +5 -10
  96. data/lib/kobako/transport/reflection.rb +39 -50
  97. data/lib/kobako/transport/run.rb +17 -71
  98. data/lib/kobako/transport/yielder.rb +21 -73
  99. data/lib/kobako/transport.rb +3 -15
  100. data/lib/kobako/unresolved.rb +4 -11
  101. data/lib/kobako/usage.rb +2 -6
  102. data/lib/kobako/version.rb +1 -1
  103. data/sig/kobako/catalog/extensions.rbs +2 -2
  104. data/sig/kobako/catalog/flags.rbs +17 -0
  105. data/sig/kobako/catalog/services.rbs +0 -6
  106. data/sig/kobako/catalog/snippets.rbs +4 -4
  107. data/sig/kobako/codec/ext_types.rbs +10 -8
  108. data/sig/kobako/codec/handle_walk.rbs +9 -7
  109. data/sig/kobako/codec/utils.rbs +2 -2
  110. data/sig/kobako/codec.rbs +2 -2
  111. data/sig/kobako/constant_name.rbs +3 -0
  112. data/sig/kobako/context.rbs +2 -3
  113. data/sig/kobako/errors.rbs +6 -0
  114. data/sig/kobako/outcome.rbs +6 -8
  115. data/sig/kobako/runtime.rbs +12 -8
  116. data/sig/kobako/sandbox.rbs +4 -0
  117. data/sig/kobako/sandbox_options.rbs +2 -6
  118. data/sig/kobako/transport/dispatcher.rbs +14 -18
  119. data/sig/kobako/transport/reflection.rbs +5 -3
  120. data/sig/kobako/transport/run.rbs +1 -3
  121. data/sig/kobako/transport/yielder.rbs +3 -3
  122. data/sig/kobako/transport.rbs +7 -6
  123. metadata +16 -4
  124. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  125. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  126. data/rust-toolchain.toml +0 -15
@@ -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; the trap marker types
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::{Invocation, MemoryLimitTrap, TimeoutTrap};
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
- /// Epoch-deadline callback installed on every Store. Read the per-run
28
- /// wall-clock deadline from `Invocation` and trap with
29
- /// `TimeoutTrap` once the deadline has passed; otherwise extend the
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
- /// Classify a wasmtime call error into a neutral `Trap`. Pure function
46
- /// over the error's downcast chain, so the kind routing is exercisable
47
- /// from `cargo test` without any frontend. The ABI export symbol
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
- /// Compose the message for a non-cap trap. wasmtime's `Display` surfaces only
72
- /// the `"error while executing at wasm backtrace: ..."` framing; the actual
73
- /// trap reason (e.g. `"wasm trap: indirect call type mismatch"`) is the
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
- /// Classify an instantiation error as a runtime-dead `SetupError`.
89
- /// Instantiation runs while the per-path template is built, before any
90
- /// invocation — every such failure is a construction setup fault, not a
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::{Invocation, MemoryLimitTrap, TimeoutTrap};
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.27.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.8.2", features = ["rb-sys"] }
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
- /// Register the `Kobako::Runtime::GuestYielder` Ruby class. Called from
27
- /// `crate::runtime::init` after `Kobako::Runtime` is defined so the
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 `__kobako_dispatch` frame: the bridge builds one, hands
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 `__kobako_dispatch`
60
- // frame on the one Ruby thread that owns the active Invocation (SPEC.md
61
- // Single-Invocation Slot); it is never read from another thread.
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
- /// Mark this handle dead. Called the instant the dispatch frame's
84
- /// `funcall` returns, so a guest block stashed beyond its frame raises
85
- /// instead of dereferencing freed stack.
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
- /// Ruby-visible `call(args_payload) -> [arm, body, class]`: drive one
91
- /// yield round-trip and hand back the reply already split. The arm is
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 __kobako_yield");
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 the Single-Invocation Slot guarantees no other
111
- // frame aliases it. The borrow ends with this method.
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 resp = yielder
107
+ let reply = yielder
114
108
  .yield_to_block(&bytes)
115
109
  .map_err(|t| super::errors::trap_to_magnus(&ruby, t))?;
116
- match YieldReply::decode(&resp) {
117
- Ok(YieldReply::Ok(body)) => {
118
- Ok((ruby.to_symbol(ARM_OK), ruby.str_from_slice(&body), None))
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
- Ok(YieldReply::Break(body)) => {
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
- Err(err) => Err(super::errors::trap_err(&ruby, err.to_string())),
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
- /// Call the Ruby Proc with the routed Call and return its Reply. The
151
- /// envelope is already decoded, so Ruby receives the target, method,
152
- /// and block flag as ordinary values and decodes only the payload —
153
- /// through the MessagePack codec it already owns, which keeps a
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) — the invariant
167
- // SPEC Implementation Standards Architecture pins for the host gem
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 __kobako_dispatch");
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
- /// Resolve `Kobako::<name>` as an `ExceptionClass` — the shared body of
17
- /// every error-class `Lazy` below, which differ only in the constant
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
- /// Construct a `Kobako::TrapError` magnus error. Used for every
51
- /// invocation-time wasmtime engine failure that is not a configured-cap
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
- /// Map a neutral `Trap` onto its `Kobako::TrapError`-family Ruby exception.
59
- /// The boundary between the magnus-free run mechanics and the Ruby surface:
60
- /// the run path classifies a fault into a `Trap`, and this is where it
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(msg) => error_in(ruby, &TIMEOUT_ERROR, msg),
66
- Trap::MemoryLimit(msg) => error_in(ruby, &MEMORY_LIMIT_ERROR, msg),
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
- other => trap_err(ruby, other.to_string()),
57
+ _ => ruby.get_inner(&TRAP_ERROR),
70
58
  }
71
59
  }
72
60
 
73
- /// Map a neutral `SetupError` onto the `Kobako::*` class the SPEC assigns
74
- /// to each runtime state — artifact-absent, runtime-dead, runtime-intact.
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
- SetupError::ModuleNotBuilt(msg) => error_in(ruby, &MODULE_NOT_BUILT_ERROR, msg),
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 the SPEC
80
- // attributes to the sandbox / wire layer, not the engine — no
81
- // discard-and-recreate recovery is owed, so never a TrapError.
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 channel this frontend does not know is still a run that never
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
  }