Skip to main content

sim_lib_control/
model.rs

1use sim_kernel::{
2    ClassRef, Cx, Error, Expr, MatchScore, Object, ObjectCompat, ObjectEncode, ObjectEncoding,
3    Origin, Ref, Result, Shape, ShapeDoc, ShapeMatch, Symbol, Value,
4};
5
6/// Stable read-construct identity for a raised completion envelope.
7pub const RAISED_SYMBOL: &str = "control/Raised";
8
9fn raised_symbol() -> Symbol {
10    Symbol::qualified("control", "Raised")
11}
12
13/// Explicit byte budget for rendering a raised payload into a browse face.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct RaisedBrowseBudget {
16    max_payload_bytes: usize,
17}
18
19impl RaisedBrowseBudget {
20    /// Creates a non-zero payload rendering budget.
21    pub fn new(max_payload_bytes: usize) -> Result<Self> {
22        if max_payload_bytes == 0 {
23            return Err(Error::Eval(
24                "raised browse payload budget must be non-zero".to_owned(),
25            ));
26        }
27        Ok(Self { max_payload_bytes })
28    }
29
30    /// Returns the maximum rendered payload bytes.
31    pub fn max_payload_bytes(self) -> usize {
32        self.max_payload_bytes
33    }
34}
35
36/// Result of a bounded raised-envelope browse projection.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct RaisedBrowseProjection {
39    /// UTF-8 payload prefix fitting the requested budget.
40    pub payload: String,
41    /// Whether bytes were omitted from the payload display.
42    pub truncated: bool,
43    /// Full payload display size before truncation.
44    pub original_payload_bytes: usize,
45}
46
47/// The one language-neutral exceptional-completion envelope.
48///
49/// Recursive relations such as causes, contexts, groups, and suppressed
50/// exceptions belong to guest-owned managed payload objects. They are never
51/// fields of this envelope.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct Raised {
54    class: ClassRef,
55    payload: Value,
56    origin: Origin,
57    profile: Symbol,
58}
59
60impl Raised {
61    /// Builds a checked, non-recursive raised envelope.
62    ///
63    /// A raised envelope cannot itself be used as the immediate payload. Guest
64    /// relation graphs must instead live in their ordinary managed objects.
65    pub fn new(class: ClassRef, payload: Value, origin: Origin, profile: Symbol) -> Result<Self> {
66        if payload.object().downcast_ref::<Self>().is_some() {
67            return Err(Error::Eval(
68                "Raised payload cannot be another Raised envelope".to_owned(),
69            ));
70        }
71        Ok(Self {
72            class,
73            payload,
74            origin,
75            profile,
76        })
77    }
78
79    /// Returns the kernel class identity used for handler matching.
80    pub fn class_ref(&self) -> &ClassRef {
81        &self.class
82    }
83
84    /// Returns the ordinary guest payload value.
85    pub fn payload(&self) -> &Value {
86        &self.payload
87    }
88
89    /// Returns deterministic source provenance for the raise site.
90    pub fn origin(&self) -> &Origin {
91        &self.origin
92    }
93
94    /// Returns the stable guest-profile identity used for policy routing.
95    pub fn profile(&self) -> &Symbol {
96        &self.profile
97    }
98
99    /// Renders the payload under an explicit byte budget and reports loss.
100    pub fn browse(
101        &self,
102        cx: &mut Cx,
103        budget: RaisedBrowseBudget,
104    ) -> Result<RaisedBrowseProjection> {
105        let rendered = self.payload.object().display(cx)?;
106        let original_payload_bytes = rendered.len();
107        let mut end = rendered.len().min(budget.max_payload_bytes);
108        while !rendered.is_char_boundary(end) {
109            end -= 1;
110        }
111        Ok(RaisedBrowseProjection {
112            payload: rendered[..end].to_owned(),
113            truncated: end < rendered.len(),
114            original_payload_bytes,
115        })
116    }
117
118    fn constructor_args(&self, cx: &mut Cx) -> Result<Vec<Expr>> {
119        Ok(vec![
120            self.class.object().as_expr(cx)?,
121            self.payload.object().as_expr(cx)?,
122            Expr::Vector(vec![
123                Expr::String(self.origin.codec.0.to_string()),
124                Expr::String(self.origin.source.0.clone()),
125                Expr::String(self.origin.span.start.to_string()),
126                Expr::String(self.origin.span.end.to_string()),
127                Expr::String(format!("{:?}", self.origin.trivia)),
128            ]),
129            Expr::Symbol(self.profile.clone()),
130        ])
131    }
132}
133
134impl Object for Raised {
135    fn display(&self, cx: &mut Cx) -> Result<String> {
136        let projection = self.browse(cx, RaisedBrowseBudget::new(256)?)?;
137        let suffix = if projection.truncated {
138            " [truncated]"
139        } else {
140            ""
141        };
142        Ok(format!(
143            "#<{} profile={} payload={}{}>",
144            RAISED_SYMBOL, self.profile, projection.payload, suffix
145        ))
146    }
147
148    fn as_any(&self) -> &dyn std::any::Any {
149        self
150    }
151}
152
153impl ObjectCompat for Raised {
154    fn as_expr(&self, cx: &mut Cx) -> Result<Expr> {
155        Ok(Expr::Extension {
156            tag: Symbol::qualified("citizen", "read-construct"),
157            payload: Box::new(Expr::Vector(vec![
158                Expr::Symbol(raised_symbol()),
159                Expr::Vector(self.constructor_args(cx)?),
160            ])),
161        })
162    }
163
164    fn as_table(&self, cx: &mut Cx) -> Result<Value> {
165        let projection = self.browse(cx, RaisedBrowseBudget::new(256)?)?;
166        cx.factory().table(vec![
167            (Symbol::new("class"), self.class.clone()),
168            (Symbol::new("payload"), self.payload.clone()),
169            (
170                Symbol::new("origin-source"),
171                cx.factory().string(self.origin.source.0.clone())?,
172            ),
173            (
174                Symbol::new("profile"),
175                cx.factory().symbol(self.profile.clone())?,
176            ),
177            (
178                Symbol::new("payload-rendered"),
179                cx.factory().string(projection.payload)?,
180            ),
181            (
182                Symbol::new("payload-truncated"),
183                cx.factory().bool(projection.truncated)?,
184            ),
185        ])
186    }
187
188    fn as_object_encoder(&self) -> Option<&dyn ObjectEncode> {
189        Some(self)
190    }
191}
192
193impl ObjectEncode for Raised {
194    fn object_encoding(&self, cx: &mut Cx) -> Result<ObjectEncoding> {
195        Ok(ObjectEncoding::Constructor {
196            class: raised_symbol(),
197            args: self.constructor_args(cx)?,
198        })
199    }
200}
201
202/// Shape for the non-recursive [`Raised`] object and its read-construct face.
203#[derive(Clone, Copy, Debug, Default)]
204pub struct RaisedShape;
205
206impl Shape for RaisedShape {
207    fn symbol(&self) -> Option<Symbol> {
208        Some(raised_symbol())
209    }
210
211    fn check_value(&self, _cx: &mut Cx, value: Value) -> Result<ShapeMatch> {
212        Ok(if value.object().downcast_ref::<Raised>().is_some() {
213            ShapeMatch::accept(MatchScore::exact(1))
214        } else {
215            ShapeMatch::reject("expected control/Raised")
216        })
217    }
218
219    fn check_expr(&self, _cx: &mut Cx, expr: &Expr) -> Result<ShapeMatch> {
220        let accepted = matches!(expr, Expr::Extension { tag, payload }
221            if tag == &Symbol::qualified("citizen", "read-construct")
222                && matches!(payload.as_ref(), Expr::Vector(parts)
223                    if matches!(parts.as_slice(), [Expr::Symbol(class), Expr::Vector(args)]
224                        if class == &raised_symbol() && args.len() == 4)));
225        Ok(if accepted {
226            ShapeMatch::accept(MatchScore::exact(1))
227        } else {
228            ShapeMatch::reject("expected four-field control/Raised read-construct")
229        })
230    }
231
232    fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
233        Ok(ShapeDoc::new("non-recursive raised completion envelope")
234            .with_detail("class, payload, origin, and profile"))
235    }
236}
237
238#[sim_citizen_derive::non_citizen(
239    reason = "live continuation capture handle; descriptor data is the continuation and capture refs",
240    kind = "handle",
241    descriptor = "core/Ref"
242)]
243/// A runtime object wrapping a captured continuation and its capture result.
244///
245/// Returned when a control capture succeeds; carries the continuation [`Ref`]
246/// to resume, the result the capture produced, and whether the continuation may
247/// be resumed more than once.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct ContinuationValue {
250    continuation: Ref,
251    capture_result: Ref,
252    multishot: bool,
253}
254
255impl ContinuationValue {
256    /// Wraps a captured `continuation`, its `capture_result`, and whether it is
257    /// `multishot` (resumable more than once).
258    pub fn new(continuation: Ref, capture_result: Ref, multishot: bool) -> Self {
259        Self {
260            continuation,
261            capture_result,
262            multishot,
263        }
264    }
265
266    /// Returns the continuation reference to resume.
267    pub fn continuation(&self) -> &Ref {
268        &self.continuation
269    }
270
271    /// Returns the result produced when the continuation was captured.
272    pub fn capture_result(&self) -> &Ref {
273        &self.capture_result
274    }
275
276    /// Returns whether this continuation may be resumed more than once.
277    pub fn multishot(&self) -> bool {
278        self.multishot
279    }
280}
281
282impl Object for ContinuationValue {
283    fn display(&self, _cx: &mut Cx) -> Result<String> {
284        Ok(format!("#<control-continuation {:?}>", self.continuation))
285    }
286
287    fn as_any(&self) -> &dyn std::any::Any {
288        self
289    }
290}
291
292impl ObjectCompat for ContinuationValue {
293    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
294        Ok(Expr::Call {
295            operator: Box::new(Expr::Symbol(Symbol::qualified("control", "continuation"))),
296            args: vec![ref_expr(&self.continuation)],
297        })
298    }
299}
300
301#[sim_citizen_derive::non_citizen(
302    reason = "control result ref wrapper; canonical data is the referenced value",
303    kind = "marker",
304    descriptor = "core/Ref"
305)]
306/// A runtime object wrapping the result reference of a control operation.
307///
308/// Produced by prompt, abort, and resume operations; its canonical data is the
309/// referenced value it carries.
310#[derive(Clone, Debug, PartialEq, Eq)]
311pub struct ControlResultValue {
312    reference: Ref,
313}
314
315impl ControlResultValue {
316    /// Wraps the `reference` produced by a control operation.
317    pub fn new(reference: Ref) -> Self {
318        Self { reference }
319    }
320
321    /// Returns the wrapped result reference.
322    pub fn reference(&self) -> &Ref {
323        &self.reference
324    }
325}
326
327impl Object for ControlResultValue {
328    fn display(&self, _cx: &mut Cx) -> Result<String> {
329        Ok(format!("#<control-result {:?}>", self.reference))
330    }
331
332    fn as_any(&self) -> &dyn std::any::Any {
333        self
334    }
335}
336
337impl ObjectCompat for ControlResultValue {
338    fn as_expr(&self, _cx: &mut Cx) -> Result<Expr> {
339        Ok(ref_expr(&self.reference))
340    }
341}
342
343pub(crate) fn ref_expr(reference: &Ref) -> Expr {
344    match reference {
345        Ref::Symbol(symbol) => Expr::Symbol(symbol.clone()),
346        other => Expr::String(format!("{other:?}")),
347    }
348}
349
350#[cfg(test)]
351mod raised_tests {
352    use std::sync::{Arc, Mutex};
353
354    use sim_kernel::{CodecId, DefaultFactory, NoopEvalPolicy, Span};
355
356    use crate::{
357        CleanupStack, FrameLimits, RaisedResumePacket, RaisedResumeResult, RaisedUnwind,
358        ResumableFrame,
359    };
360
361    use super::*;
362
363    fn fixture(cx: &mut Cx, payload: &str) -> Raised {
364        Raised::new(
365            cx.factory().symbol(Symbol::new("guest/Error")).unwrap(),
366            cx.factory().string(payload.to_owned()).unwrap(),
367            Origin {
368                codec: CodecId(7),
369                source: sim_kernel::SourceId("fixture.sim".to_owned()),
370                span: Span { start: 2, end: 9 },
371                trivia: Vec::new(),
372            },
373            Symbol::new("guest/profile-v1"),
374        )
375        .unwrap()
376    }
377
378    #[test]
379    fn checked_constructor_rejects_a_raised_payload() {
380        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
381        let inner = fixture(&mut cx, "inner");
382        let inner = cx.factory().opaque(Arc::new(inner)).unwrap();
383        let error = Raised::new(
384            cx.factory().symbol(Symbol::new("guest/Error")).unwrap(),
385            inner,
386            Origin {
387                codec: CodecId(7),
388                source: sim_kernel::SourceId("fixture.sim".to_owned()),
389                span: Span { start: 0, end: 1 },
390                trivia: Vec::new(),
391            },
392            Symbol::new("guest/profile-v1"),
393        )
394        .unwrap_err();
395        assert!(error.to_string().contains("cannot be another Raised"));
396    }
397
398    #[test]
399    fn api_shape_has_exactly_four_non_recursive_fields() {
400        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
401        let Raised {
402            class,
403            payload,
404            origin,
405            profile,
406        } = fixture(&mut cx, "payload");
407        let _: ClassRef = class;
408        let _: Value = payload;
409        let _: Origin = origin;
410        let _: Symbol = profile;
411    }
412
413    #[test]
414    fn browse_and_shape_report_budget_truncation_and_read_construct() {
415        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
416        let raised = fixture(&mut cx, "abcdefgh");
417        assert_eq!(
418            raised
419                .browse(&mut cx, RaisedBrowseBudget::new(4).unwrap())
420                .unwrap(),
421            RaisedBrowseProjection {
422                payload: "abcd".to_owned(),
423                truncated: true,
424                original_payload_bytes: 8,
425            }
426        );
427        let expr = raised.as_expr(&mut cx).unwrap();
428        assert!(RaisedShape.check_expr(&mut cx, &expr).unwrap().accepted);
429        let value = cx.factory().opaque(Arc::new(raised)).unwrap();
430        assert!(RaisedShape.check_value(&mut cx, value).unwrap().accepted);
431    }
432
433    #[test]
434    fn raised_unwinds_two_cleanups_then_resumes_with_stable_receipts() {
435        let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
436        let receipts = Arc::new(Mutex::new(Vec::new()));
437        let mut cleanups: CleanupStack<RaisedUnwind<(), (), ()>> = CleanupStack::new();
438        for name in ["outer", "inner"] {
439            let receipts = Arc::clone(&receipts);
440            cleanups.push(move |_| receipts.lock().unwrap().push(name));
441        }
442        let reason = cleanups.unwind(RaisedUnwind::Exception(fixture(&mut cx, "boom")));
443        let RaisedUnwind::Exception(raised) = reason else {
444            unreachable!()
445        };
446        // Start establishes the frame; the characterized resume follows a
447        // protected suspension, so model that suspension with Yielded first.
448        let mut frame = ResumableFrame::new(FrameLimits { depth: 2, work: 2 }, {
449            let mut started = false;
450            move |packet: RaisedResumePacket<()>, _: &mut crate::StepBudget| match packet {
451                RaisedResumePacket::Start if !started => {
452                    started = true;
453                    Ok::<_, crate::FrameError>(RaisedResumeResult::Yielded(()))
454                }
455                RaisedResumePacket::Throw(raised) => Ok(RaisedResumeResult::Failed(raised)),
456                _ => unreachable!(),
457            }
458        });
459        assert!(matches!(
460            frame.resume::<(), (), Raised>(RaisedResumePacket::Start),
461            Ok(RaisedResumeResult::Yielded(()))
462        ));
463        assert!(matches!(
464            frame.resume::<(), (), Raised>(RaisedResumePacket::Throw(raised)),
465            Ok(RaisedResumeResult::Failed(_))
466        ));
467        receipts.lock().unwrap().push("resumed");
468        assert_eq!(&*receipts.lock().unwrap(), &["inner", "outer", "resumed"]);
469    }
470}