1use std::sync::Arc;
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)]
33pub enum InvokeErrorKind {
34 Internal,
36 Timeout,
38 Trap,
40 OutputTooLarge,
42 InvalidOutput,
44 ExecutionFailed,
49}
50
51impl InvokeError {
52 pub const fn new(kind: InvokeErrorKind) -> Self {
54 Self { kind, detail: None }
55 }
56
57 pub fn with_detail(kind: InvokeErrorKind, detail: impl Into<String>) -> Self {
66 Self {
67 kind,
68 detail: Some(truncate_to_bytes(detail.into(), MAX_DETAIL_BYTES)),
69 }
70 }
71
72 pub const fn kind(&self) -> InvokeErrorKind {
74 self.kind
75 }
76
77 pub fn detail(&self) -> Option<&str> {
79 self.detail.as_deref()
80 }
81
82 pub const fn stable_code(&self) -> &'static str {
85 match self.kind {
86 InvokeErrorKind::Internal => "handlerInternalError",
87 InvokeErrorKind::Timeout => "handlerTimeout",
88 InvokeErrorKind::Trap => "handlerTrap",
89 InvokeErrorKind::OutputTooLarge => "handlerOutputTooLarge",
90 InvokeErrorKind::InvalidOutput => "handlerInvalidOutput",
91 InvokeErrorKind::ExecutionFailed => "handlerInternalError",
96 }
97 }
98
99 pub const fn public_message(&self) -> &'static str {
101 match self.kind {
102 InvokeErrorKind::Internal => "internal runtime error",
103 InvokeErrorKind::Timeout => "handler exceeded the wall-clock deadline",
104 InvokeErrorKind::Trap => "handler trapped",
105 InvokeErrorKind::OutputTooLarge => "handler output exceeded the size limit",
106 InvokeErrorKind::InvalidOutput => "handler output was not valid JSON",
107 InvokeErrorKind::ExecutionFailed => "handler execution failed",
108 }
109 }
110
111 pub const fn retryable(&self) -> bool {
113 matches!(self.kind, InvokeErrorKind::Timeout)
114 }
115}
116
117impl std::fmt::Display for InvokeError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match &self.detail {
120 Some(detail) => write!(f, "{}: {}", self.stable_code(), detail),
121 None => f.write_str(self.stable_code()),
122 }
123 }
124}
125
126impl std::error::Error for InvokeError {}
127
128fn truncate_to_bytes(s: String, max_bytes: usize) -> String {
134 if s.len() <= max_bytes {
135 return s;
136 }
137 let mut end = max_bytes;
138 while end > 0 && !s.is_char_boundary(end) {
139 end -= 1;
140 }
141 let mut truncated = s;
142 truncated.truncate(end);
143 truncated
144}
145
146pub trait InvokeHandler: Send + Sync + std::fmt::Debug {
148 fn invoke(&self, capability: &str, input: &Value) -> Result<Value, InvokeError>;
149}
150
151#[derive(Debug, Clone)]
155pub enum EngineResponse {
156 Handshake {
157 request_id: String,
158 runtime_version: String,
159 model_pack_id: String,
160 model_pack_version: String,
161 capabilities: Vec<String>,
162 handler: Option<HandlerIdentity>,
163 },
164 Health {
165 request_id: String,
166 healthy: bool,
167 model_pack_id: String,
168 model_pack_version: String,
169 },
170 Result {
171 request_id: String,
172 output: Value,
173 },
174 Error {
175 request_id: String,
176 code: String,
177 message: String,
178 retryable: bool,
179 },
180}
181
182impl EngineResponse {
183 pub fn to_v1(&self, api_version: u32) -> RuntimeResponse {
185 match self {
186 Self::Handshake {
187 request_id,
188 runtime_version,
189 model_pack_id,
190 model_pack_version,
191 capabilities,
192 ..
193 } => RuntimeResponse::Handshake {
194 request_id: request_id.clone(),
195 api_version,
196 runtime_version: runtime_version.clone(),
197 model_pack_id: model_pack_id.clone(),
198 model_pack_version: model_pack_version.clone(),
199 capabilities: capabilities.clone(),
200 },
201 Self::Health {
202 request_id,
203 healthy,
204 model_pack_id,
205 model_pack_version,
206 } => RuntimeResponse::Health {
207 request_id: request_id.clone(),
208 api_version,
209 healthy: *healthy,
210 model_pack_id: model_pack_id.clone(),
211 model_pack_version: model_pack_version.clone(),
212 },
213 Self::Result { request_id, output } => RuntimeResponse::Result {
214 request_id: request_id.clone(),
215 api_version,
216 output: output.clone(),
217 },
218 Self::Error {
219 request_id,
220 code,
221 message,
222 retryable,
223 } => RuntimeResponse::Error {
224 request_id: request_id.clone(),
225 api_version,
226 code: code.clone(),
227 message: message.clone(),
228 retryable: *retryable,
229 },
230 }
231 }
232
233 pub fn to_v2(&self, api_version: u32) -> RuntimeResponseV2 {
237 match self {
238 Self::Handshake {
239 request_id,
240 runtime_version,
241 model_pack_id,
242 model_pack_version,
243 capabilities,
244 handler,
245 } => {
246 let (handler_id, handler_version, handler_api_version, effective) = match handler {
247 Some(h) => (
248 h.handler_id.clone(),
249 h.handler_version.clone(),
250 h.handler_api_version,
251 h.effective_capabilities.clone(),
252 ),
253 None => (String::new(), String::new(), 0, capabilities.clone()),
254 };
255 RuntimeResponseV2::Handshake {
256 request_id: request_id.clone(),
257 api_version,
258 runtime_version: runtime_version.clone(),
259 model_pack_id: model_pack_id.clone(),
260 model_pack_version: model_pack_version.clone(),
261 capabilities: capabilities.clone(),
262 handler_id,
263 handler_version,
264 handler_api_version,
265 effective_capabilities: effective,
266 }
267 }
268 Self::Health {
269 request_id,
270 healthy,
271 model_pack_id,
272 model_pack_version,
273 } => RuntimeResponseV2::Health {
274 request_id: request_id.clone(),
275 api_version,
276 healthy: *healthy,
277 model_pack_id: model_pack_id.clone(),
278 model_pack_version: model_pack_version.clone(),
279 },
280 Self::Result { request_id, output } => RuntimeResponseV2::Result {
281 request_id: request_id.clone(),
282 api_version,
283 output: output.clone(),
284 },
285 Self::Error {
286 request_id,
287 code,
288 message,
289 retryable,
290 } => RuntimeResponseV2::Error {
291 request_id: request_id.clone(),
292 api_version,
293 code: code.clone(),
294 message: message.clone(),
295 retryable: *retryable,
296 },
297 }
298 }
299}
300
301#[derive(Debug, Clone)]
302pub struct RuntimeEngine {
303 pack: LoadedModelPack,
304 invoke_handler: Option<Arc<dyn InvokeHandler>>,
305 handler_identity: Option<HandlerIdentity>,
306 effective_capabilities: Vec<String>,
307}
308
309impl RuntimeEngine {
310 pub fn new(pack: LoadedModelPack) -> Self {
311 Self {
312 pack,
313 invoke_handler: None,
314 handler_identity: None,
315 effective_capabilities: Vec::new(),
316 }
317 }
318
319 pub fn with_invoke_handler(mut self, handler: Arc<dyn InvokeHandler>) -> Self {
320 self.invoke_handler = Some(handler);
321 self
322 }
323
324 pub fn with_handler_identity(mut self, identity: HandlerIdentity) -> Self {
326 self.effective_capabilities = identity.effective_capabilities.clone();
327 self.handler_identity = Some(identity);
328 self
329 }
330
331 pub fn effective_capabilities(&self) -> &[String] {
334 &self.effective_capabilities
335 }
336
337 pub fn handler_identity(&self) -> Option<&HandlerIdentity> {
339 self.handler_identity.as_ref()
340 }
341
342 pub fn handle(&self, request: RuntimeRequest) -> EngineResponse {
343 let request_id = request.request_id().to_string();
344 if request_id.is_empty() || request_id.len() > 128 {
345 return self.error(request_id, "invalidRequestId", "invalid request id", false);
346 }
347 let api_version = request.api_version();
348 if !(MIN_RUNTIME_API_VERSION..=RUNTIME_API_VERSION).contains(&api_version) {
349 return self.error(
350 request_id,
351 "incompatibleApiVersion",
352 "runtime API version is not supported",
353 false,
354 );
355 }
356
357 match request {
358 RuntimeRequest::Handshake {
359 request_id,
360 client_name,
361 client_version,
362 ..
363 } => {
364 if client_name.is_empty()
365 || client_name.len() > 96
366 || client_version.is_empty()
367 || client_version.len() > 48
368 {
369 return self.error(
370 request_id,
371 "invalidClientIdentity",
372 "invalid client identity",
373 false,
374 );
375 }
376 EngineResponse::Handshake {
377 request_id,
378 runtime_version: env!("CARGO_PKG_VERSION").into(),
379 model_pack_id: self.pack.manifest.id.clone(),
380 model_pack_version: self.pack.manifest.version.clone(),
381 capabilities: self.pack.manifest.capabilities.clone(),
382 handler: self.handler_identity.clone(),
383 }
384 }
385 RuntimeRequest::Health { request_id, .. } => EngineResponse::Health {
386 request_id,
387 healthy: true,
388 model_pack_id: self.pack.manifest.id.clone(),
389 model_pack_version: self.pack.manifest.version.clone(),
390 },
391 RuntimeRequest::Invoke {
392 request_id,
393 capability,
394 input,
395 ..
396 } => {
397 if !self.is_capability_allowed(&capability) {
398 return self.error(
399 request_id,
400 "unsupportedCapability",
401 "capability is not in the effective set",
402 false,
403 );
404 }
405 let Some(handler) = &self.invoke_handler else {
406 return self.error(
407 request_id,
408 "noInvokeHandler",
409 "no invoke handler registered",
410 false,
411 );
412 };
413 match handler.invoke(&capability, &input) {
414 Ok(output) => EngineResponse::Result { request_id, output },
415 Err(invoke_err) => {
416 if let Some(detail) = invoke_err.detail() {
421 eprintln!(
422 "rill-runtime: invoke {} -> {} (detail: {})",
423 capability,
424 invoke_err.stable_code(),
425 detail
426 );
427 }
428 self.error(
429 request_id,
430 invoke_err.stable_code(),
431 invoke_err.public_message(),
432 invoke_err.retryable(),
433 )
434 }
435 }
436 }
437 }
438 }
439
440 fn is_capability_allowed(&self, capability: &str) -> bool {
445 if !self.effective_capabilities.is_empty() {
446 self.effective_capabilities.iter().any(|c| c == capability)
447 } else {
448 self.pack
449 .manifest
450 .capabilities
451 .iter()
452 .any(|c| c == capability)
453 }
454 }
455
456 fn error(
457 &self,
458 request_id: String,
459 code: &str,
460 message: &str,
461 retryable: bool,
462 ) -> EngineResponse {
463 EngineResponse::Error {
464 request_id,
465 code: code.into(),
466 message: message.into(),
467 retryable,
468 }
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use rill_runtime_protocol::{MODEL_PACK_FORMAT_VERSION, ModelPackManifest};
475
476 use super::*;
477 use crate::handler::builtin::LINEAR_REGRESSION_CAPABILITY;
478
479 fn engine() -> RuntimeEngine {
480 RuntimeEngine::new(LoadedModelPack {
481 manifest: ModelPackManifest {
482 format_version: MODEL_PACK_FORMAT_VERSION,
483 id: "rillml.example.default".into(),
484 version: "0.7.0".into(),
485 runtime_api_version: RUNTIME_API_VERSION,
486 min_runtime_version: "0.7.0".into(),
487 publisher_key_id: "test".into(),
488 capabilities: vec!["rillml.example".into()],
489 },
490 model: serde_json::json!({}),
491 })
492 }
493
494 #[test]
495 fn handshake_reports_loaded_pack() {
496 let response = engine().handle(RuntimeRequest::Handshake {
497 request_id: "hello".into(),
498 api_version: RUNTIME_API_VERSION,
499 client_name: "example-host".into(),
500 client_version: "0.9.0".into(),
501 });
502 assert!(matches!(
503 response,
504 EngineResponse::Handshake { model_pack_id, .. }
505 if model_pack_id == "rillml.example.default"
506 ));
507 }
508
509 #[test]
510 fn incompatible_api_is_a_typed_error() {
511 let response = engine().handle(RuntimeRequest::Health {
512 request_id: "health".into(),
513 api_version: RUNTIME_API_VERSION + 1,
514 });
515 assert!(matches!(
516 response,
517 EngineResponse::Error { code, .. } if code == "incompatibleApiVersion"
518 ));
519 }
520
521 #[test]
522 fn invoke_without_handler_returns_no_invoke_handler_error() {
523 let response = engine().handle(RuntimeRequest::Invoke {
524 request_id: "invoke-1".into(),
525 api_version: RUNTIME_API_VERSION,
526 capability: "rillml.example".into(),
527 input: serde_json::json!({}),
528 });
529 assert!(matches!(
530 response,
531 EngineResponse::Error { code, .. } if code == "noInvokeHandler"
532 ));
533 }
534
535 #[test]
536 fn invoke_rejects_capability_not_declared_by_signed_manifest() {
537 let response = engine().handle(RuntimeRequest::Invoke {
538 request_id: "invoke-undeclared".into(),
539 api_version: RUNTIME_API_VERSION,
540 capability: "undeclared.capability".into(),
541 input: serde_json::json!({}),
542 });
543 assert!(matches!(
544 response,
545 EngineResponse::Error { code, .. } if code == "unsupportedCapability"
546 ));
547 }
548
549 #[test]
550 fn v1_handshake_omits_handler_fields() {
551 let identity = HandlerIdentity {
552 handler_id: "org.example.handler".into(),
553 handler_version: "1.0.0".into(),
554 handler_api_version: 1,
555 effective_capabilities: vec!["rillml.example".into()],
556 };
557 let engine = engine().with_handler_identity(identity);
558 let response = engine.handle(RuntimeRequest::Handshake {
559 request_id: "v1-test".into(),
560 api_version: 1,
561 client_name: "v1-host".into(),
562 client_version: "0.6.0".into(),
563 });
564 let v1 = response.to_v1(1);
565 let json = serde_json::to_string(&v1).unwrap();
566 assert!(!json.contains("handlerId"));
567 assert!(!json.contains("effectiveCapabilities"));
568 }
569
570 #[test]
571 fn v2_handshake_includes_handler_fields() {
572 let identity = HandlerIdentity {
573 handler_id: "org.example.handler".into(),
574 handler_version: "1.0.0".into(),
575 handler_api_version: 1,
576 effective_capabilities: vec!["rillml.example".into()],
577 };
578 let engine = engine().with_handler_identity(identity);
579 let response = engine.handle(RuntimeRequest::Handshake {
580 request_id: "v2-test".into(),
581 api_version: 2,
582 client_name: "v2-host".into(),
583 client_version: "0.7.0".into(),
584 });
585 let v2 = response.to_v2(2);
586 let json = serde_json::to_string(&v2).unwrap();
587 assert!(json.contains("\"handlerId\":\"org.example.handler\""));
588 assert!(json.contains("\"handlerApiVersion\":1"));
589 assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
590 }
591
592 #[test]
593 fn v2_handshake_without_handler_has_empty_fields() {
594 let response = engine().handle(RuntimeRequest::Handshake {
595 request_id: "v2-no-handler".into(),
596 api_version: 2,
597 client_name: "v2-host".into(),
598 client_version: "0.7.0".into(),
599 });
600 let v2 = response.to_v2(2);
601 match v2 {
602 RuntimeResponseV2::Handshake {
603 handler_id,
604 handler_version,
605 handler_api_version,
606 effective_capabilities,
607 ..
608 } => {
609 assert!(handler_id.is_empty());
610 assert!(handler_version.is_empty());
611 assert_eq!(handler_api_version, 0);
612 assert_eq!(effective_capabilities, vec!["rillml.example"]);
613 }
614 _ => panic!("expected handshake"),
615 }
616 }
617
618 #[test]
619 fn linear_regression_handler_validates_and_predicts() {
620 use crate::handler::builtin::LinearRegressionInvokeHandler;
621
622 let pack = LoadedModelPack {
623 manifest: ModelPackManifest {
624 format_version: MODEL_PACK_FORMAT_VERSION,
625 id: "rillml.example.default".into(),
626 version: "0.7.0".into(),
627 runtime_api_version: RUNTIME_API_VERSION,
628 min_runtime_version: "0.7.0".into(),
629 publisher_key_id: "test".into(),
630 capabilities: vec![LINEAR_REGRESSION_CAPABILITY.into()],
631 },
632 model: serde_json::json!({
633 "kind": "linearRegression",
634 "weights": [0.5, -0.25],
635 "intercept": 1.0
636 }),
637 };
638 let handler = LinearRegressionInvokeHandler::from_pack(&pack).unwrap();
639 let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(handler));
640 let response = engine.handle(RuntimeRequest::Invoke {
641 request_id: "invoke-linear".into(),
642 api_version: RUNTIME_API_VERSION,
643 capability: LINEAR_REGRESSION_CAPABILITY.into(),
644 input: serde_json::json!({"features": [4.0, 2.0]}),
645 });
646 assert!(matches!(
647 response,
648 EngineResponse::Result { output, .. } if output["prediction"] == 2.5
649 ));
650 }
651
652 #[test]
653 fn invoke_error_stable_codes_match_wire_format() {
654 assert_eq!(
658 InvokeError::new(InvokeErrorKind::Trap).stable_code(),
659 "handlerTrap"
660 );
661 assert_eq!(
662 InvokeError::new(InvokeErrorKind::Timeout).stable_code(),
663 "handlerTimeout"
664 );
665 assert_eq!(
666 InvokeError::new(InvokeErrorKind::OutputTooLarge).stable_code(),
667 "handlerOutputTooLarge"
668 );
669 assert_eq!(
670 InvokeError::new(InvokeErrorKind::InvalidOutput).stable_code(),
671 "handlerInvalidOutput"
672 );
673 assert_eq!(
674 InvokeError::new(InvokeErrorKind::Internal).stable_code(),
675 "handlerInternalError"
676 );
677 assert_eq!(
680 InvokeError::new(InvokeErrorKind::ExecutionFailed).stable_code(),
681 "handlerInternalError"
682 );
683 }
684
685 #[test]
686 fn invoke_error_retryable_only_for_timeout() {
687 assert!(InvokeError::new(InvokeErrorKind::Timeout).retryable());
688 assert!(!InvokeError::new(InvokeErrorKind::Trap).retryable());
689 assert!(!InvokeError::new(InvokeErrorKind::OutputTooLarge).retryable());
690 assert!(!InvokeError::new(InvokeErrorKind::InvalidOutput).retryable());
691 assert!(!InvokeError::new(InvokeErrorKind::Internal).retryable());
692 assert!(!InvokeError::new(InvokeErrorKind::ExecutionFailed).retryable());
693 }
694
695 #[test]
696 fn invoke_error_public_message_never_contains_detail() {
697 let err = InvokeError::with_detail(
700 InvokeErrorKind::ExecutionFailed,
701 "SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload",
702 );
703 assert_eq!(err.public_message(), "handler execution failed");
704 assert_eq!(err.stable_code(), "handlerInternalError");
705 assert_eq!(
706 err.detail(),
707 Some("SECRET-TOKEN-LEAK-ATTEMPT guest-controlled-payload")
708 );
709 assert!(err.to_string().contains("SECRET-TOKEN-LEAK-ATTEMPT"));
712 assert!(!err.public_message().contains("SECRET"));
714 }
715
716 #[test]
717 fn invoke_error_without_detail_has_no_detail() {
718 let err = InvokeError::new(InvokeErrorKind::Trap);
719 assert_eq!(err.kind(), InvokeErrorKind::Trap);
720 assert_eq!(err.detail(), None);
721 assert_eq!(err.stable_code(), "handlerTrap");
722 assert_eq!(err.to_string(), "handlerTrap");
723 }
724
725 #[test]
726 fn invoke_error_detail_is_truncated_to_4kib_on_char_boundary() {
727 let huge = "A".repeat(MAX_DETAIL_BYTES * 4);
731 let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, huge);
732 let detail = err.detail().expect("detail must be stored");
733 assert!(
734 detail.len() <= MAX_DETAIL_BYTES,
735 "detail length {} must not exceed {}",
736 detail.len(),
737 MAX_DETAIL_BYTES
738 );
739 assert!(detail.chars().all(|c| c == 'A'));
742 }
743
744 #[test]
745 fn invoke_error_detail_truncation_respects_multibyte_chars() {
746 let emoji = "🌟".repeat(MAX_DETAIL_BYTES); let err = InvokeError::with_detail(InvokeErrorKind::ExecutionFailed, emoji);
751 let detail = err.detail().expect("detail must be stored");
752 assert!(detail.len() <= MAX_DETAIL_BYTES);
753 for c in detail.chars() {
755 assert_eq!(c, '🌟');
756 }
757 }
758
759 #[derive(Debug)]
762 struct FailingHandler {
763 err: InvokeError,
764 }
765
766 impl InvokeHandler for FailingHandler {
767 fn invoke(&self, _capability: &str, _input: &Value) -> Result<Value, InvokeError> {
768 Err(self.err.clone())
769 }
770 }
771
772 #[test]
773 fn engine_invoke_error_does_not_leak_guest_detail_in_message() {
774 let err = InvokeError::with_detail(
778 InvokeErrorKind::ExecutionFailed,
779 "leak-attempt:SECRET-TOKEN",
780 );
781 let pack = LoadedModelPack {
782 manifest: ModelPackManifest {
783 format_version: MODEL_PACK_FORMAT_VERSION,
784 id: "rillml.example.default".into(),
785 version: "0.7.0".into(),
786 runtime_api_version: RUNTIME_API_VERSION,
787 min_runtime_version: "0.7.0".into(),
788 publisher_key_id: "test".into(),
789 capabilities: vec!["rillml.example".into()],
790 },
791 model: serde_json::json!({}),
792 };
793 let engine = RuntimeEngine::new(pack).with_invoke_handler(Arc::new(FailingHandler { err }));
794 let response = engine.handle(RuntimeRequest::Invoke {
795 request_id: "leak-test".into(),
796 api_version: RUNTIME_API_VERSION,
797 capability: "rillml.example".into(),
798 input: serde_json::json!({}),
799 });
800 match response {
801 EngineResponse::Error {
802 code,
803 message,
804 retryable,
805 ..
806 } => {
807 assert_eq!(code, "handlerInternalError");
808 assert_eq!(message, "handler execution failed");
809 assert!(!retryable);
810 assert!(!message.contains("SECRET"));
813 assert!(!message.contains("leak-attempt"));
814 }
815 _ => panic!("expected EngineResponse::Error"),
816 }
817 }
818}