1use std::sync::{Arc, Mutex};
2
3use rill_runtime_protocol::{
4 MIN_RUNTIME_API_VERSION, RUNTIME_API_VERSION, RuntimeRequest, RuntimeResponse,
5 RuntimeResponseV2,
6};
7use serde_json::Value;
8
9use crate::handler::HandlerIdentity;
10use crate::package::LoadedModelPack;
11
12#[derive(Debug, Clone)]
20pub struct InvokeError {
21 kind: InvokeErrorKind,
22 detail: Option<String>,
23}
24
25pub const MAX_DETAIL_BYTES: usize = 4 * 1024;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum InvokeErrorKind {
48 Internal,
50 Timeout,
52 Trap,
54 OutputTooLarge,
56 InvalidOutput,
58 InvalidModel,
62 InvalidInput,
66 UnsupportedCapability,
70 ExecutionFailed,
74}
75
76impl InvokeError {
77 pub const fn new(kind: InvokeErrorKind) -> Self {
79 Self { kind, detail: None }
80 }
81
82 pub fn with_detail(kind: InvokeErrorKind, detail: impl Into<String>) -> Self {
91 Self {
92 kind,
93 detail: Some(truncate_to_bytes(detail.into(), MAX_DETAIL_BYTES)),
94 }
95 }
96
97 pub const fn kind(&self) -> InvokeErrorKind {
99 self.kind
100 }
101
102 pub fn detail(&self) -> Option<&str> {
104 self.detail.as_deref()
105 }
106
107 pub const fn stable_code(&self) -> &'static str {
117 match self.kind {
118 InvokeErrorKind::Internal => "handlerInternalError",
119 InvokeErrorKind::Timeout => "handlerTimeout",
120 InvokeErrorKind::Trap => "handlerTrap",
121 InvokeErrorKind::OutputTooLarge => "handlerOutputTooLarge",
122 InvokeErrorKind::InvalidOutput => "handlerInvalidOutput",
123 InvokeErrorKind::InvalidModel
128 | InvokeErrorKind::InvalidInput
129 | InvokeErrorKind::UnsupportedCapability
130 | InvokeErrorKind::ExecutionFailed => "handlerInternalError",
131 }
132 }
133
134 pub const fn public_message(&self) -> &'static str {
136 match self.kind {
137 InvokeErrorKind::Internal => "internal runtime error",
138 InvokeErrorKind::Timeout => "handler exceeded the wall-clock deadline",
139 InvokeErrorKind::Trap => "handler trapped",
140 InvokeErrorKind::OutputTooLarge => "handler output exceeded the size limit",
141 InvokeErrorKind::InvalidOutput => "handler output was not valid JSON",
142 InvokeErrorKind::InvalidModel => "handler rejected the model configuration",
143 InvokeErrorKind::InvalidInput => "handler rejected the input",
144 InvokeErrorKind::UnsupportedCapability => "handler does not support the capability",
145 InvokeErrorKind::ExecutionFailed => "handler execution failed",
146 }
147 }
148
149 pub const fn retryable(&self) -> bool {
151 matches!(self.kind, InvokeErrorKind::Timeout)
152 }
153}
154
155impl std::fmt::Display for InvokeError {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 match &self.detail {
158 Some(detail) => write!(f, "{}: {}", self.stable_code(), detail),
159 None => f.write_str(self.stable_code()),
160 }
161 }
162}
163
164impl std::error::Error for InvokeError {}
165
166fn truncate_to_bytes(s: String, max_bytes: usize) -> String {
172 if s.len() <= max_bytes {
173 return s;
174 }
175 let mut end = max_bytes;
176 while end > 0 && !s.is_char_boundary(end) {
177 end -= 1;
178 }
179 let mut truncated = s;
180 truncated.truncate(end);
181 truncated
182}
183
184pub trait HostLogSink: Send + Sync + std::fmt::Debug {
196 fn emit(&self, message: &str);
198}
199
200#[derive(Debug, Default, Clone)]
202pub struct StderrLogSink;
203
204impl HostLogSink for StderrLogSink {
205 fn emit(&self, message: &str) {
206 eprintln!("{message}");
207 }
208}
209
210#[derive(Debug, Default)]
214pub struct CapturingLogSink {
215 messages: Mutex<Vec<String>>,
216}
217
218impl CapturingLogSink {
219 pub fn new() -> Self {
221 Self::default()
222 }
223
224 pub fn messages(&self) -> Vec<String> {
226 self.messages
227 .lock()
228 .expect("CapturingLogSink poisoned")
229 .clone()
230 }
231
232 pub fn total_bytes(&self) -> usize {
235 self.messages
236 .lock()
237 .expect("CapturingLogSink poisoned")
238 .iter()
239 .map(String::len)
240 .sum()
241 }
242
243 pub fn clear(&self) {
245 self.messages
246 .lock()
247 .expect("CapturingLogSink poisoned")
248 .clear();
249 }
250}
251
252impl HostLogSink for CapturingLogSink {
253 fn emit(&self, message: &str) {
254 self.messages
255 .lock()
256 .expect("CapturingLogSink poisoned")
257 .push(message.to_string());
258 }
259}
260
261pub trait InvokeHandler: Send + Sync + std::fmt::Debug {
263 fn invoke(&self, capability: &str, input: &Value) -> Result<Value, InvokeError>;
264}
265
266#[derive(Debug, Clone)]
270pub enum EngineResponse {
271 Handshake {
272 request_id: String,
273 runtime_version: String,
274 model_pack_id: String,
275 model_pack_version: String,
276 capabilities: Vec<String>,
277 handler: Option<HandlerIdentity>,
278 },
279 Health {
280 request_id: String,
281 healthy: bool,
282 model_pack_id: String,
283 model_pack_version: String,
284 },
285 Result {
286 request_id: String,
287 output: Value,
288 },
289 Error {
290 request_id: String,
291 code: String,
292 message: String,
293 retryable: bool,
294 },
295}
296
297impl EngineResponse {
298 pub fn to_v1(&self, api_version: u32) -> RuntimeResponse {
300 match self {
301 Self::Handshake {
302 request_id,
303 runtime_version,
304 model_pack_id,
305 model_pack_version,
306 capabilities,
307 ..
308 } => RuntimeResponse::Handshake {
309 request_id: request_id.clone(),
310 api_version,
311 runtime_version: runtime_version.clone(),
312 model_pack_id: model_pack_id.clone(),
313 model_pack_version: model_pack_version.clone(),
314 capabilities: capabilities.clone(),
315 },
316 Self::Health {
317 request_id,
318 healthy,
319 model_pack_id,
320 model_pack_version,
321 } => RuntimeResponse::Health {
322 request_id: request_id.clone(),
323 api_version,
324 healthy: *healthy,
325 model_pack_id: model_pack_id.clone(),
326 model_pack_version: model_pack_version.clone(),
327 },
328 Self::Result { request_id, output } => RuntimeResponse::Result {
329 request_id: request_id.clone(),
330 api_version,
331 output: output.clone(),
332 },
333 Self::Error {
334 request_id,
335 code,
336 message,
337 retryable,
338 } => RuntimeResponse::Error {
339 request_id: request_id.clone(),
340 api_version,
341 code: code.clone(),
342 message: message.clone(),
343 retryable: *retryable,
344 },
345 }
346 }
347
348 pub fn to_v2(&self, api_version: u32) -> RuntimeResponseV2 {
352 match self {
353 Self::Handshake {
354 request_id,
355 runtime_version,
356 model_pack_id,
357 model_pack_version,
358 capabilities,
359 handler,
360 } => {
361 let (handler_id, handler_version, handler_api_version, effective) = match handler {
362 Some(h) => (
363 h.handler_id.clone(),
364 h.handler_version.clone(),
365 h.handler_api_version,
366 h.effective_capabilities.clone(),
367 ),
368 None => (String::new(), String::new(), 0, capabilities.clone()),
369 };
370 RuntimeResponseV2::Handshake {
371 request_id: request_id.clone(),
372 api_version,
373 runtime_version: runtime_version.clone(),
374 model_pack_id: model_pack_id.clone(),
375 model_pack_version: model_pack_version.clone(),
376 capabilities: capabilities.clone(),
377 handler_id,
378 handler_version,
379 handler_api_version,
380 effective_capabilities: effective,
381 }
382 }
383 Self::Health {
384 request_id,
385 healthy,
386 model_pack_id,
387 model_pack_version,
388 } => RuntimeResponseV2::Health {
389 request_id: request_id.clone(),
390 api_version,
391 healthy: *healthy,
392 model_pack_id: model_pack_id.clone(),
393 model_pack_version: model_pack_version.clone(),
394 },
395 Self::Result { request_id, output } => RuntimeResponseV2::Result {
396 request_id: request_id.clone(),
397 api_version,
398 output: output.clone(),
399 },
400 Self::Error {
401 request_id,
402 code,
403 message,
404 retryable,
405 } => RuntimeResponseV2::Error {
406 request_id: request_id.clone(),
407 api_version,
408 code: code.clone(),
409 message: message.clone(),
410 retryable: *retryable,
411 },
412 }
413 }
414}
415
416#[derive(Debug, Clone)]
417pub struct RuntimeEngine {
418 pack: LoadedModelPack,
419 invoke_handler: Option<Arc<dyn InvokeHandler>>,
420 handler_identity: Option<HandlerIdentity>,
421 effective_capabilities: Vec<String>,
422 log_sink: Arc<dyn HostLogSink>,
423}
424
425impl RuntimeEngine {
426 pub fn new(pack: LoadedModelPack) -> Self {
427 Self {
428 pack,
429 invoke_handler: None,
430 handler_identity: None,
431 effective_capabilities: Vec::new(),
432 log_sink: Arc::new(StderrLogSink),
433 }
434 }
435
436 pub fn with_invoke_handler(mut self, handler: Arc<dyn InvokeHandler>) -> Self {
437 self.invoke_handler = Some(handler);
438 self
439 }
440
441 pub fn with_log_sink(mut self, sink: Arc<dyn HostLogSink>) -> Self {
445 self.log_sink = sink;
446 self
447 }
448
449 pub fn with_handler_identity(mut self, identity: HandlerIdentity) -> Self {
451 self.effective_capabilities = identity.effective_capabilities.clone();
452 self.handler_identity = Some(identity);
453 self
454 }
455
456 pub fn effective_capabilities(&self) -> &[String] {
459 &self.effective_capabilities
460 }
461
462 pub fn handler_identity(&self) -> Option<&HandlerIdentity> {
464 self.handler_identity.as_ref()
465 }
466
467 pub fn handle(&self, request: RuntimeRequest) -> EngineResponse {
468 let request_id = request.request_id().to_string();
469 if request_id.is_empty() || request_id.len() > 128 {
470 return self.error(request_id, "invalidRequestId", "invalid request id", false);
471 }
472 let api_version = request.api_version();
473 if !(MIN_RUNTIME_API_VERSION..=RUNTIME_API_VERSION).contains(&api_version) {
474 return self.error(
475 request_id,
476 "incompatibleApiVersion",
477 "runtime API version is not supported",
478 false,
479 );
480 }
481
482 match request {
483 RuntimeRequest::Handshake {
484 request_id,
485 client_name,
486 client_version,
487 ..
488 } => {
489 if client_name.is_empty()
490 || client_name.len() > 96
491 || client_version.is_empty()
492 || client_version.len() > 48
493 {
494 return self.error(
495 request_id,
496 "invalidClientIdentity",
497 "invalid client identity",
498 false,
499 );
500 }
501 EngineResponse::Handshake {
502 request_id,
503 runtime_version: env!("CARGO_PKG_VERSION").into(),
504 model_pack_id: self.pack.manifest.id.clone(),
505 model_pack_version: self.pack.manifest.version.clone(),
506 capabilities: self.pack.manifest.capabilities.clone(),
507 handler: self.handler_identity.clone(),
508 }
509 }
510 RuntimeRequest::Health { request_id, .. } => EngineResponse::Health {
511 request_id,
512 healthy: true,
513 model_pack_id: self.pack.manifest.id.clone(),
514 model_pack_version: self.pack.manifest.version.clone(),
515 },
516 RuntimeRequest::Invoke {
517 request_id,
518 capability,
519 input,
520 ..
521 } => {
522 if !self.is_capability_allowed(&capability) {
523 return self.error(
524 request_id,
525 "unsupportedCapability",
526 "capability is not in the effective set",
527 false,
528 );
529 }
530 let Some(handler) = &self.invoke_handler else {
531 return self.error(
532 request_id,
533 "noInvokeHandler",
534 "no invoke handler registered",
535 false,
536 );
537 };
538 match handler.invoke(&capability, &input) {
539 Ok(output) => EngineResponse::Result { request_id, output },
540 Err(invoke_err) => {
541 if let Some(detail) = invoke_err.detail() {
551 self.log_sink.emit(&format!(
552 "rill-runtime: invoke {} -> {} (detail: {})",
553 capability,
554 invoke_err.stable_code(),
555 detail
556 ));
557 }
558 self.error(
559 request_id,
560 invoke_err.stable_code(),
561 invoke_err.public_message(),
562 invoke_err.retryable(),
563 )
564 }
565 }
566 }
567 }
568 }
569
570 fn is_capability_allowed(&self, capability: &str) -> bool {
575 if !self.effective_capabilities.is_empty() {
576 self.effective_capabilities.iter().any(|c| c == capability)
577 } else {
578 self.pack
579 .manifest
580 .capabilities
581 .iter()
582 .any(|c| c == capability)
583 }
584 }
585
586 fn error(
587 &self,
588 request_id: String,
589 code: &str,
590 message: &str,
591 retryable: bool,
592 ) -> EngineResponse {
593 EngineResponse::Error {
594 request_id,
595 code: code.into(),
596 message: message.into(),
597 retryable,
598 }
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use rill_runtime_protocol::{MODEL_PACK_FORMAT_VERSION, ModelPackManifest};
605
606 use super::*;
607 use crate::handler::builtin::LINEAR_REGRESSION_CAPABILITY;
608
609 fn engine() -> RuntimeEngine {
610 RuntimeEngine::new(LoadedModelPack {
611 manifest: ModelPackManifest {
612 format_version: MODEL_PACK_FORMAT_VERSION,
613 id: "rillml.example.default".into(),
614 version: "0.7.0".into(),
615 runtime_api_version: RUNTIME_API_VERSION,
616 min_runtime_version: "0.7.0".into(),
617 publisher_key_id: "test".into(),
618 capabilities: vec!["rillml.example".into()],
619 },
620 model: serde_json::json!({}),
621 })
622 }
623
624 #[test]
625 fn handshake_reports_loaded_pack() {
626 let response = engine().handle(RuntimeRequest::Handshake {
627 request_id: "hello".into(),
628 api_version: RUNTIME_API_VERSION,
629 client_name: "example-host".into(),
630 client_version: "0.9.0".into(),
631 });
632 assert!(matches!(
633 response,
634 EngineResponse::Handshake { model_pack_id, .. }
635 if model_pack_id == "rillml.example.default"
636 ));
637 }
638
639 #[test]
640 fn incompatible_api_is_a_typed_error() {
641 let response = engine().handle(RuntimeRequest::Health {
642 request_id: "health".into(),
643 api_version: RUNTIME_API_VERSION + 1,
644 });
645 assert!(matches!(
646 response,
647 EngineResponse::Error { code, .. } if code == "incompatibleApiVersion"
648 ));
649 }
650
651 #[test]
652 fn invoke_without_handler_returns_no_invoke_handler_error() {
653 let response = engine().handle(RuntimeRequest::Invoke {
654 request_id: "invoke-1".into(),
655 api_version: RUNTIME_API_VERSION,
656 capability: "rillml.example".into(),
657 input: serde_json::json!({}),
658 });
659 assert!(matches!(
660 response,
661 EngineResponse::Error { code, .. } if code == "noInvokeHandler"
662 ));
663 }
664
665 #[test]
666 fn invoke_rejects_capability_not_declared_by_signed_manifest() {
667 let response = engine().handle(RuntimeRequest::Invoke {
668 request_id: "invoke-undeclared".into(),
669 api_version: RUNTIME_API_VERSION,
670 capability: "undeclared.capability".into(),
671 input: serde_json::json!({}),
672 });
673 assert!(matches!(
674 response,
675 EngineResponse::Error { code, .. } if code == "unsupportedCapability"
676 ));
677 }
678
679 #[test]
680 fn v1_handshake_omits_handler_fields() {
681 let identity = HandlerIdentity {
682 handler_id: "org.example.handler".into(),
683 handler_version: "1.0.0".into(),
684 handler_api_version: 1,
685 effective_capabilities: vec!["rillml.example".into()],
686 };
687 let engine = engine().with_handler_identity(identity);
688 let response = engine.handle(RuntimeRequest::Handshake {
689 request_id: "v1-test".into(),
690 api_version: 1,
691 client_name: "v1-host".into(),
692 client_version: "0.6.0".into(),
693 });
694 let v1 = response.to_v1(1);
695 let json = serde_json::to_string(&v1).unwrap();
696 assert!(!json.contains("handlerId"));
697 assert!(!json.contains("effectiveCapabilities"));
698 }
699
700 #[test]
701 fn v2_handshake_includes_handler_fields() {
702 let identity = HandlerIdentity {
703 handler_id: "org.example.handler".into(),
704 handler_version: "1.0.0".into(),
705 handler_api_version: 1,
706 effective_capabilities: vec!["rillml.example".into()],
707 };
708 let engine = engine().with_handler_identity(identity);
709 let response = engine.handle(RuntimeRequest::Handshake {
710 request_id: "v2-test".into(),
711 api_version: 2,
712 client_name: "v2-host".into(),
713 client_version: "0.7.0".into(),
714 });
715 let v2 = response.to_v2(2);
716 let json = serde_json::to_string(&v2).unwrap();
717 assert!(json.contains("\"handlerId\":\"org.example.handler\""));
718 assert!(json.contains("\"handlerApiVersion\":1"));
719 assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
720 }
721
722 #[test]
723 fn v2_handshake_without_handler_has_empty_fields() {
724 let response = engine().handle(RuntimeRequest::Handshake {
725 request_id: "v2-no-handler".into(),
726 api_version: 2,
727 client_name: "v2-host".into(),
728 client_version: "0.7.0".into(),
729 });
730 let v2 = response.to_v2(2);
731 match v2 {
732 RuntimeResponseV2::Handshake {
733 handler_id,
734 handler_version,
735 handler_api_version,
736 effective_capabilities,
737 ..
738 } => {
739 assert!(handler_id.is_empty());
740 assert!(handler_version.is_empty());
741 assert_eq!(handler_api_version, 0);
742 assert_eq!(effective_capabilities, vec!["rillml.example"]);
743 }
744 _ => panic!("expected handshake"),
745 }
746 }
747
748 #[test]
749 fn linear_regression_handler_validates_and_predicts() {
750 use crate::handler::builtin::LinearRegressionInvokeHandler;
751
752 let pack = LoadedModelPack {
753 manifest: ModelPackManifest {
754 format_version: MODEL_PACK_FORMAT_VERSION,
755 id: "rillml.example.default".into(),
756 version: "0.7.0".into(),
757 runtime_api_version: RUNTIME_API_VERSION,
758 min_runtime_version: "0.7.0".into(),
759 publisher_key_id: "test".into(),
760 capabilities: vec![LINEAR_REGRESSION_CAPABILITY.into()],
761 },
762 model: serde_json::json!({
763 "kind": "linearRegression",
764 "weights": [0.5, -0.25],
765 "intercept": 1.0
766 }),
767 };
768 let handler = LinearRegressionInvokeHandler::from_pack(&pack).unwrap();
769 let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(handler));
770 let response = engine.handle(RuntimeRequest::Invoke {
771 request_id: "invoke-linear".into(),
772 api_version: RUNTIME_API_VERSION,
773 capability: LINEAR_REGRESSION_CAPABILITY.into(),
774 input: serde_json::json!({"features": [4.0, 2.0]}),
775 });
776 assert!(matches!(
777 response,
778 EngineResponse::Result { output, .. } if output["prediction"] == 2.5
779 ));
780 }
781
782 #[test]
783 fn invoke_error_stable_codes_match_wire_format() {
784 assert_eq!(
788 InvokeError::new(InvokeErrorKind::Trap).stable_code(),
789 "handlerTrap"
790 );
791 assert_eq!(
792 InvokeError::new(InvokeErrorKind::Timeout).stable_code(),
793 "handlerTimeout"
794 );
795 assert_eq!(
796 InvokeError::new(InvokeErrorKind::OutputTooLarge).stable_code(),
797 "handlerOutputTooLarge"
798 );
799 assert_eq!(
800 InvokeError::new(InvokeErrorKind::InvalidOutput).stable_code(),
801 "handlerInvalidOutput"
802 );
803 assert_eq!(
804 InvokeError::new(InvokeErrorKind::Internal).stable_code(),
805 "handlerInternalError"
806 );
807 for kind in [
813 InvokeErrorKind::InvalidModel,
814 InvokeErrorKind::InvalidInput,
815 InvokeErrorKind::UnsupportedCapability,
816 InvokeErrorKind::ExecutionFailed,
817 ] {
818 assert_eq!(
819 InvokeError::new(kind).stable_code(),
820 "handlerInternalError",
821 "{kind:?} must map to handlerInternalError for v1/v2 compat"
822 );
823 }
824 }
825
826 #[test]
827 fn invoke_error_retryable_only_for_timeout() {
828 assert!(InvokeError::new(InvokeErrorKind::Timeout).retryable());
829 for kind in [
830 InvokeErrorKind::Trap,
831 InvokeErrorKind::OutputTooLarge,
832 InvokeErrorKind::InvalidOutput,
833 InvokeErrorKind::Internal,
834 InvokeErrorKind::InvalidModel,
835 InvokeErrorKind::InvalidInput,
836 InvokeErrorKind::UnsupportedCapability,
837 InvokeErrorKind::ExecutionFailed,
838 ] {
839 assert!(
840 !InvokeError::new(kind).retryable(),
841 "{kind:?} must not be retryable"
842 );
843 }
844 }
845
846 #[test]
847 fn invoke_error_guest_variants_have_distinct_public_messages() {
848 let messages = [
852 InvokeError::new(InvokeErrorKind::InvalidModel).public_message(),
853 InvokeError::new(InvokeErrorKind::InvalidInput).public_message(),
854 InvokeError::new(InvokeErrorKind::UnsupportedCapability).public_message(),
855 InvokeError::new(InvokeErrorKind::ExecutionFailed).public_message(),
856 ];
857 for i in 0..messages.len() {
859 for j in (i + 1)..messages.len() {
860 assert_ne!(messages[i], messages[j], "public messages must be distinct");
861 }
862 }
863 for msg in messages {
865 assert!(!msg.contains("detail"));
866 assert!(!msg.contains("guest"));
867 }
868 }
869
870 #[test]
871 fn invoke_error_public_message_never_contains_detail() {
872 let err = InvokeError::with_detail(
875 InvokeErrorKind::ExecutionFailed,
876 "SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload",
877 );
878 assert_eq!(err.public_message(), "handler execution failed");
879 assert_eq!(err.stable_code(), "handlerInternalError");
880 assert_eq!(
881 err.detail(),
882 Some("SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload")
883 );
884 assert!(err.to_string().contains("SECRET-TOKEN-LEAK-ATTEMPT"));
887 assert!(!err.public_message().contains("SECRET"));
889 }
890
891 #[test]
892 fn invoke_error_without_detail_has_no_detail() {
893 let err = InvokeError::new(InvokeErrorKind::Trap);
894 assert_eq!(err.kind(), InvokeErrorKind::Trap);
895 assert_eq!(err.detail(), None);
896 assert_eq!(err.stable_code(), "handlerTrap");
897 assert_eq!(err.to_string(), "handlerTrap");
898 }
899
900 #[test]
901 fn invoke_error_detail_is_truncated_to_4kib_on_char_boundary() {
902 let huge = "A".repeat(MAX_DETAIL_BYTES * 4);
906 let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge);
907 let detail = err.detail().expect("detail must be stored");
908 assert!(
909 detail.len() <= MAX_DETAIL_BYTES,
910 "detail length {} must not exceed {}",
911 detail.len(),
912 MAX_DETAIL_BYTES
913 );
914 assert!(detail.chars().all(|c| c == 'A'));
917 }
918
919 #[test]
920 fn invoke_error_detail_truncation_respects_multibyte_chars() {
921 let emoji = "🌟".repeat(MAX_DETAIL_BYTES); let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, emoji);
926 let detail = err.detail().expect("detail must be stored");
927 assert!(detail.len() <= MAX_DETAIL_BYTES);
928 for c in detail.chars() {
930 assert_eq!(c, '🌟');
931 }
932 }
933
934 #[derive(Debug)]
937 struct FailingHandler {
938 err: InvokeError,
939 }
940
941 impl InvokeHandler for FailingHandler {
942 fn invoke(&self, _capability: &str, _input: &Value) -> Result<Value, InvokeError> {
943 Err(self.err.clone())
944 }
945 }
946
947 #[test]
948 fn engine_invoke_error_does_not_leak_guest_detail_in_message() {
949 let err = InvokeError::with_detail(
953 InvokeErrorKind::ExecutionFailed,
954 "leak-attempt:SECRET-TOKEN",
955 );
956 let pack = LoadedModelPack {
957 manifest: ModelPackManifest {
958 format_version: MODEL_PACK_FORMAT_VERSION,
959 id: "rillml.example.default".into(),
960 version: "0.7.0".into(),
961 runtime_api_version: RUNTIME_API_VERSION,
962 min_runtime_version: "0.7.0".into(),
963 publisher_key_id: "test".into(),
964 capabilities: vec!["rillml.example".into()],
965 },
966 model: serde_json::json!({}),
967 };
968 let sink = Arc::new(CapturingLogSink::new());
969 let engine = RuntimeEngine::new(pack)
970 .with_invoke_handler(Arc::new(FailingHandler { err }))
971 .with_log_sink(sink.clone());
972 let response = engine.handle(RuntimeRequest::Invoke {
973 request_id: "leak-test".into(),
974 api_version: RUNTIME_API_VERSION,
975 capability: "rillml.example".into(),
976 input: serde_json::json!({}),
977 });
978 match response {
979 EngineResponse::Error {
980 code,
981 message,
982 retryable,
983 ..
984 } => {
985 assert_eq!(code, "handlerInternalError");
986 assert_eq!(message, "handler execution failed");
987 assert!(!retryable);
988 assert!(!message.contains("SECRET"));
991 assert!(!message.contains("leak-attempt"));
992 }
993 _ => panic!("expected EngineResponse::Error"),
994 }
995 let messages = sink.messages();
1001 assert_eq!(
1002 messages.len(),
1003 1,
1004 "the engine must log the invoke error exactly once"
1005 );
1006 assert!(messages[0].contains("SECRET-TOKEN"));
1007 }
1008
1009 #[test]
1014 fn engine_log_does_not_emit_oversized_guest_detail() {
1015 let huge_detail = "X".repeat(MAX_DETAIL_BYTES * 4); let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge_detail);
1017 let pack = LoadedModelPack {
1018 manifest: ModelPackManifest {
1019 format_version: MODEL_PACK_FORMAT_VERSION,
1020 id: "rillml.example.default".into(),
1021 version: "0.7.0".into(),
1022 runtime_api_version: RUNTIME_API_VERSION,
1023 min_runtime_version: "0.7.0".into(),
1024 publisher_key_id: "test".into(),
1025 capabilities: vec!["rillml.example".into()],
1026 },
1027 model: serde_json::json!({}),
1028 };
1029 let sink = Arc::new(CapturingLogSink::new());
1030 let engine = RuntimeEngine::new(pack)
1031 .with_invoke_handler(Arc::new(FailingHandler { err }))
1032 .with_log_sink(sink.clone());
1033 let _ = engine.handle(RuntimeRequest::Invoke {
1034 request_id: "oversized".into(),
1035 api_version: RUNTIME_API_VERSION,
1036 capability: "rillml.example".into(),
1037 input: serde_json::json!({}),
1038 });
1039 let messages = sink.messages();
1040 assert_eq!(messages.len(), 1, "exactly one log line expected");
1041 let log_line = &messages[0];
1042 assert!(
1046 log_line.len() < MAX_DETAIL_BYTES * 2,
1047 "log line length {} must be well under 2x MAX_DETAIL_BYTES ({}); \
1048 a 16 KiB guest payload must not produce a 16 KiB log",
1049 log_line.len(),
1050 MAX_DETAIL_BYTES * 2
1051 );
1052 assert!(
1054 log_line.len() < MAX_DETAIL_BYTES + 256,
1055 "log line length {} must be < MAX_DETAIL_BYTES + prefix overhead",
1056 log_line.len()
1057 );
1058 }
1059
1060 #[test]
1065 fn engine_logs_invoke_error_exactly_once() {
1066 let err = InvokeError::with_detail(
1067 InvokeErrorKind::UnsupportedCapability,
1068 "capability foo not supported",
1069 );
1070 let pack = LoadedModelPack {
1071 manifest: ModelPackManifest {
1072 format_version: MODEL_PACK_FORMAT_VERSION,
1073 id: "rillml.example.default".into(),
1074 version: "0.7.0".into(),
1075 runtime_api_version: RUNTIME_API_VERSION,
1076 min_runtime_version: "0.7.0".into(),
1077 publisher_key_id: "test".into(),
1078 capabilities: vec!["rillml.example".into()],
1079 },
1080 model: serde_json::json!({}),
1081 };
1082 let sink = Arc::new(CapturingLogSink::new());
1083 let engine = RuntimeEngine::new(pack)
1084 .with_invoke_handler(Arc::new(FailingHandler { err }))
1085 .with_log_sink(sink.clone());
1086 let _ = engine.handle(RuntimeRequest::Invoke {
1087 request_id: "once".into(),
1088 api_version: RUNTIME_API_VERSION,
1089 capability: "rillml.example".into(),
1090 input: serde_json::json!({}),
1091 });
1092 assert_eq!(
1093 sink.messages().len(),
1094 1,
1095 "the engine must log the invoke error exactly once, not twice"
1096 );
1097 }
1098
1099 #[test]
1103 fn engine_log_traps_backtrace_is_truncated() {
1104 let fake_backtrace = "trap: unreachable\n".repeat(1024); let err = InvokeError::with_detail(InvokeErrorKind::Trap, fake_backtrace);
1106 let pack = LoadedModelPack {
1107 manifest: ModelPackManifest {
1108 format_version: MODEL_PACK_FORMAT_VERSION,
1109 id: "rillml.example.default".into(),
1110 version: "0.7.0".into(),
1111 runtime_api_version: RUNTIME_API_VERSION,
1112 min_runtime_version: "0.7.0".into(),
1113 publisher_key_id: "test".into(),
1114 capabilities: vec!["rillml.example".into()],
1115 },
1116 model: serde_json::json!({}),
1117 };
1118 let sink = Arc::new(CapturingLogSink::new());
1119 let engine = RuntimeEngine::new(pack)
1120 .with_invoke_handler(Arc::new(FailingHandler { err }))
1121 .with_log_sink(sink.clone());
1122 let _ = engine.handle(RuntimeRequest::Invoke {
1123 request_id: "trap-trunc".into(),
1124 api_version: RUNTIME_API_VERSION,
1125 capability: "rillml.example".into(),
1126 input: serde_json::json!({}),
1127 });
1128 let messages = sink.messages();
1129 assert_eq!(messages.len(), 1);
1130 let log_line = &messages[0];
1131 assert!(
1132 log_line.len() < MAX_DETAIL_BYTES + 256,
1133 "trap backtrace log must be truncated; got {} bytes",
1134 log_line.len()
1135 );
1136 }
1137
1138 #[test]
1142 fn engine_preserves_guest_variant_kind_for_all_wit_variants() {
1143 for (kind, expected_message) in [
1144 (
1145 InvokeErrorKind::InvalidModel,
1146 "handler rejected the model configuration",
1147 ),
1148 (InvokeErrorKind::InvalidInput, "handler rejected the input"),
1149 (
1150 InvokeErrorKind::UnsupportedCapability,
1151 "handler does not support the capability",
1152 ),
1153 (InvokeErrorKind::ExecutionFailed, "handler execution failed"),
1154 ] {
1155 let err = InvokeError::with_detail(kind, "guest detail");
1156 let pack = LoadedModelPack {
1157 manifest: ModelPackManifest {
1158 format_version: MODEL_PACK_FORMAT_VERSION,
1159 id: "rillml.example.default".into(),
1160 version: "0.7.0".into(),
1161 runtime_api_version: RUNTIME_API_VERSION,
1162 min_runtime_version: "0.7.0".into(),
1163 publisher_key_id: "test".into(),
1164 capabilities: vec!["rillml.example".into()],
1165 },
1166 model: serde_json::json!({}),
1167 };
1168 let engine =
1169 RuntimeEngine::new(pack).with_invoke_handler(Arc::new(FailingHandler { err }));
1170 let response = engine.handle(RuntimeRequest::Invoke {
1171 request_id: "variant".into(),
1172 api_version: RUNTIME_API_VERSION,
1173 capability: "rillml.example".into(),
1174 input: serde_json::json!({}),
1175 });
1176 match response {
1177 EngineResponse::Error { code, message, .. } => {
1178 assert_eq!(
1179 code, "handlerInternalError",
1180 "{kind:?}: stable code must stay handlerInternalError"
1181 );
1182 assert_eq!(
1183 message, expected_message,
1184 "{kind:?}: public message mismatch"
1185 );
1186 }
1187 _ => panic!("{kind:?}: expected EngineResponse::Error"),
1188 }
1189 }
1190 }
1191}