Skip to main content

sim_lib_lang_python/
resumable.rs

1// conformance: Python resumable control composes the shared control organ.
2
3//! Python exception and resumable-control policy over the shared control organ.
4
5use std::{fmt, sync::Arc};
6
7use sim_kernel::{
8    ClassId, ClassRef, Cx, Object, ObjectCompat, Origin, Result as KernelResult, Symbol,
9};
10use sim_lib_control::{
11    BoundedSubclassOutcome, ClassMatchBudget, ClassMatchEvidence, ClassMatchOutcome, FrameError,
12    FrameLimits, ManagedException, Raised, ResumableFrame, ResumePacket, ResumeResult,
13    match_raised_class,
14};
15use sim_lib_mutation::{
16    ArenaError, HardCappedRetainPolicy, ManagedArena, ManagedHandle, StrongEdgeMutationError,
17};
18
19use crate::PythonObjectSpace;
20
21/// Checked Python iterator state over an owned sequence.
22pub struct PythonIterator<T> {
23    values: std::vec::IntoIter<T>,
24}
25impl<T> PythonIterator<T> {
26    /// Construct an iterator whose exhaustion is explicit and stable.
27    pub fn new(values: Vec<T>) -> Self {
28        Self {
29            values: values.into_iter(),
30        }
31    }
32    /// Return the next value or `None` for Python `StopIteration`.
33    pub fn next_checked(&mut self) -> Option<T> {
34        self.values.next()
35    }
36}
37
38/// Python-owned meaning of one managed exception edge.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum PythonExceptionRelation {
41    /// Explicit `raise ... from ...` relation.
42    Cause,
43    /// Implicit active-exception relation.
44    Context,
45    /// Ordered direct member of an exception group.
46    GroupMember(usize),
47}
48
49/// Non-recursive Python data stored in a shared managed exception node.
50#[derive(Clone, Debug)]
51pub struct PythonExceptionData {
52    class: ClassRef,
53    message: String,
54    origin: Origin,
55    suppress_context: bool,
56    group_message: Option<String>,
57}
58
59/// Stable handle for an exception object owned by [`PythonExceptions`].
60pub type PythonExceptionRef = ManagedHandle;
61
62type ExceptionNode = ManagedException<PythonExceptionData, PythonExceptionRelation>;
63
64/// Failure to construct or relate Python exception objects.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum PythonExceptionError {
67    /// The class was not declared in the Python class system.
68    UnknownClass(ClassId),
69    /// An exception handle is stale.
70    Arena(ArenaError),
71    /// A managed relation exceeded its checked limits.
72    Relation(StrongEdgeMutationError),
73    /// Python forbids empty exception groups.
74    EmptyGroup,
75    /// The referenced object is not an exception group.
76    NotGroup,
77}
78
79impl From<ArenaError> for PythonExceptionError {
80    fn from(value: ArenaError) -> Self {
81        Self::Arena(value)
82    }
83}
84impl From<StrongEdgeMutationError> for PythonExceptionError {
85    fn from(value: StrongEdgeMutationError) -> Self {
86        Self::Relation(value)
87    }
88}
89
90#[derive(Debug)]
91struct PythonExceptionFace {
92    message: String,
93}
94impl Object for PythonExceptionFace {
95    fn display(&self, _cx: &mut Cx) -> KernelResult<String> {
96        Ok(self.message.clone())
97    }
98    fn as_any(&self) -> &dyn std::any::Any {
99        self
100    }
101}
102impl ObjectCompat for PythonExceptionFace {}
103
104/// Python exception heap, class policy, chaining, grouping, and handler matching.
105pub struct PythonExceptions {
106    classes: PythonObjectSpace,
107    arena: ManagedArena<ExceptionNode>,
108}
109
110impl PythonExceptions {
111    /// Construct a bounded Python exception heap.
112    pub fn new(max_objects: usize) -> Result<Self, PythonExceptionError> {
113        Ok(Self {
114            classes: PythonObjectSpace::default(),
115            arena: ManagedArena::new(HardCappedRetainPolicy::new(max_objects)?),
116        })
117    }
118
119    /// Declare an exception class through the Python class system delivered by CLASS_2.
120    pub fn define_class(
121        &mut self,
122        cx: &Cx,
123        class: ClassRef,
124        bases: Vec<ClassRef>,
125    ) -> Result<(), crate::ClassError> {
126        self.classes.define_class(cx, class, bases)
127    }
128
129    /// Allocate an ordinary exception object with exact traceback origin.
130    pub fn allocate(
131        &mut self,
132        class: ClassRef,
133        message: impl Into<String>,
134        origin: Origin,
135    ) -> Result<PythonExceptionRef, PythonExceptionError> {
136        let id = class
137            .object()
138            .as_class()
139            .map(|class| class.id())
140            .ok_or(PythonExceptionError::UnknownClass(ClassId(u32::MAX)))?;
141        if self.classes.class(id).is_none() {
142            return Err(PythonExceptionError::UnknownClass(id));
143        }
144        Ok(self
145            .arena
146            .allocate(ManagedException::new(PythonExceptionData {
147                class,
148                message: message.into(),
149                origin,
150                suppress_context: false,
151                group_message: None,
152            }))?)
153    }
154
155    /// Allocate a non-empty exception group and retain members in source order.
156    pub fn group(
157        &mut self,
158        class: ClassRef,
159        message: impl Into<String>,
160        members: &[PythonExceptionRef],
161        origin: Origin,
162    ) -> Result<PythonExceptionRef, PythonExceptionError> {
163        if members.is_empty() {
164            return Err(PythonExceptionError::EmptyGroup);
165        }
166        for member in members {
167            self.arena.get(*member)?;
168        }
169        let group_message = message.into();
170        let group = self.allocate(class, group_message.clone(), origin)?;
171        let mut payload = self.arena.get(group)?.payload().clone();
172        payload.group_message = Some(group_message);
173        self.arena.get_mut(group)?.replace_payload(payload);
174        for (ordinal, member) in members.iter().enumerate() {
175            self.arena
176                .get_mut(group)?
177                .insert_relation(PythonExceptionRelation::GroupMember(ordinal), member.id())?;
178        }
179        Ok(group)
180    }
181
182    /// Attach an explicit cause and apply Python's context-suppression rule.
183    pub fn set_cause(
184        &mut self,
185        error: PythonExceptionRef,
186        cause: PythonExceptionRef,
187    ) -> Result<(), PythonExceptionError> {
188        self.arena.get(cause)?;
189        let node = self.arena.get_mut(error)?;
190        node.insert_relation(PythonExceptionRelation::Cause, cause.id())?;
191        let mut payload = node.payload().clone();
192        payload.suppress_context = true;
193        node.replace_payload(payload);
194        Ok(())
195    }
196
197    /// Attach the exception active when another exception was raised.
198    pub fn set_context(
199        &mut self,
200        error: PythonExceptionRef,
201        context: PythonExceptionRef,
202    ) -> Result<(), PythonExceptionError> {
203        self.arena.get(context)?;
204        self.arena
205            .get_mut(error)?
206            .insert_relation(PythonExceptionRelation::Context, context.id())?;
207        Ok(())
208    }
209
210    /// Convert a managed Python exception to the shared exceptional-completion envelope.
211    pub fn raise(
212        &self,
213        cx: &Cx,
214        error: PythonExceptionRef,
215    ) -> Result<Raised, PythonExceptionError> {
216        let payload = self.arena.get(error)?.payload();
217        let value = cx
218            .factory()
219            .opaque(Arc::new(PythonExceptionFace {
220                message: payload.message.clone(),
221            }))
222            .map_err(|_| PythonExceptionError::Arena(ArenaError::IdentityExhausted))?;
223        Raised::new(
224            payload.class.clone(),
225            value,
226            payload.origin.clone(),
227            Symbol::qualified("python", "exception"),
228        )
229        .map_err(|_| PythonExceptionError::Arena(ArenaError::IdentityExhausted))
230    }
231
232    /// Match a raised completion using bounded class evidence and Python predicate policy.
233    pub fn matches(
234        &self,
235        cx: &mut Cx,
236        raised: &Raised,
237        candidate: ClassRef,
238        budget: ClassMatchBudget,
239    ) -> ClassMatchOutcome {
240        match_raised_class(
241            cx,
242            raised,
243            candidate,
244            budget,
245            |_, actual, expected, budget| {
246                let actual_id = actual
247                    .object()
248                    .as_class()
249                    .expect("validated by matcher")
250                    .id();
251                let expected_id = expected
252                    .object()
253                    .as_class()
254                    .expect("validated by matcher")
255                    .id();
256                let evidence = ClassMatchEvidence {
257                    raised: actual_id,
258                    candidate: expected_id,
259                    performed_work: self
260                        .classes
261                        .subclass_work(actual_id, expected_id, budget.work),
262                };
263                if evidence.performed_work > budget.work {
264                    BoundedSubclassOutcome::BudgetExhausted {
265                        limit: budget.work,
266                        performed_work: budget.work,
267                    }
268                } else if self.classes.is_subclass(actual_id, expected_id) {
269                    BoundedSubclassOutcome::Subclass(evidence)
270                } else {
271                    BoundedSubclassOutcome::NotSubclass(evidence)
272                }
273            },
274            |_, raised, _| Ok(raised.profile() == &Symbol::qualified("python", "exception")),
275        )
276    }
277
278    /// Split a group by handler class while preserving direct-member order.
279    pub fn split(
280        &mut self,
281        cx: &mut Cx,
282        group: PythonExceptionRef,
283        candidate: ClassRef,
284        budget: ClassMatchBudget,
285    ) -> Result<(Option<PythonExceptionRef>, Option<PythonExceptionRef>), PythonExceptionError>
286    {
287        let data = self.arena.get(group)?.payload().clone();
288        let Some(message) = data.group_message.clone() else {
289            return Err(PythonExceptionError::NotGroup);
290        };
291        let mut members = self
292            .arena
293            .get(group)?
294            .relations()
295            .filter_map(|(_, role, id)| match role {
296                PythonExceptionRelation::GroupMember(ordinal) => {
297                    Some((*ordinal, self.arena.handle(id).ok()?))
298                }
299                _ => None,
300            })
301            .collect::<Vec<_>>();
302        members.sort_by_key(|(ordinal, _)| *ordinal);
303        let mut matched = Vec::new();
304        let mut rest = Vec::new();
305        for (_, member) in members {
306            let raised = self.raise(cx, member)?;
307            if matches!(
308                self.matches(cx, &raised, candidate.clone(), budget),
309                ClassMatchOutcome::Matched(_)
310            ) {
311                matched.push(member);
312            } else {
313                rest.push(member);
314            }
315        }
316        let make = |this: &mut Self,
317                    values: &[PythonExceptionRef]|
318         -> Result<Option<PythonExceptionRef>, PythonExceptionError> {
319            if values.is_empty() {
320                Ok(None)
321            } else {
322                this.group(
323                    data.class.clone(),
324                    message.clone(),
325                    values,
326                    data.origin.clone(),
327                )
328                .map(Some)
329            }
330        };
331        let matched_group = make(self, &matched)?;
332        let rest_group = make(self, &rest)?;
333        Ok((matched_group, rest_group))
334    }
335
336    /// Return the immutable Python payload for diagnostics and policy checks.
337    pub fn inspect(
338        &self,
339        error: PythonExceptionRef,
340    ) -> Result<&PythonExceptionData, PythonExceptionError> {
341        Ok(self.arena.get(error)?.payload())
342    }
343
344    /// Return ordered typed relations for diagnostics and subgroup derivation.
345    pub fn relations(
346        &self,
347        error: PythonExceptionRef,
348    ) -> Result<Vec<(PythonExceptionRelation, PythonExceptionRef)>, PythonExceptionError> {
349        Ok(self
350            .arena
351            .get(error)?
352            .relations()
353            .map(|(_, role, id)| {
354                (
355                    *role,
356                    self.arena
357                        .handle(id)
358                        .expect("managed relation targets a live object"),
359                )
360            })
361            .collect())
362    }
363}
364
365impl PythonExceptionData {
366    /// Runtime exception class identity.
367    pub fn class(&self) -> &ClassRef {
368        &self.class
369    }
370    /// Exact guest diagnostic text.
371    pub fn message(&self) -> &str {
372        &self.message
373    }
374    /// Traceback origin captured at construction.
375    pub fn origin(&self) -> &Origin {
376        &self.origin
377    }
378    /// Whether implicit context display is suppressed.
379    pub const fn suppress_context(&self) -> bool {
380        self.suppress_context
381    }
382    /// Group message, present only for exception groups.
383    pub fn group_message(&self) -> Option<&str> {
384        self.group_message.as_deref()
385    }
386}
387
388/// Policy seam for Python's synchronous context-manager protocol.
389pub trait ContextManager<T> {
390    /// Enter and produce the body value.
391    fn enter(&mut self) -> Result<T, Box<Raised>>;
392    /// Exit after normal or exceptional completion; `true` suppresses an exception.
393    fn exit(&mut self, error: Option<&Raised>) -> Result<bool, Box<Raised>>;
394}
395
396/// Run one synchronous context extent, guaranteeing `exit` on both paths.
397pub fn run_with_context<T, R>(
398    manager: &mut impl ContextManager<T>,
399    body: impl FnOnce(T) -> Result<R, Box<Raised>>,
400) -> Result<Option<R>, Box<Raised>> {
401    let entered = manager.enter()?;
402    match body(entered) {
403        Ok(value) => {
404            manager.exit(None)?;
405            Ok(Some(value))
406        }
407        Err(error) => {
408            if manager.exit(Some(&error))? {
409                Ok(None)
410            } else {
411                Err(error)
412            }
413        }
414    }
415}
416
417/// Observable Python generator transition.
418#[derive(Clone, Debug, Eq, PartialEq)]
419pub enum PythonGeneratorStep<T> {
420    /// A value was yielded.
421    Yielded(T),
422    /// The generator returned; this is `StopIteration.value`.
423    Returned(T),
424}
425
426/// Generator protocol or guest failure.
427#[derive(Clone, Debug, Eq, PartialEq)]
428pub enum PythonGeneratorError {
429    /// Shared frame protocol rejected the transition.
430    Frame(FrameError),
431    /// Guest exception escaped the frame.
432    Raised(Box<Raised>),
433}
434
435/// Python send/throw/close policy backed by the shared resumable frame.
436pub struct PythonGenerator<T, D> {
437    frame: ResumableFrame<D>,
438    _value: std::marker::PhantomData<T>,
439}
440impl<T, D> PythonGenerator<T, D>
441where
442    D: FnMut(
443        ResumePacket<T, Raised>,
444        &mut sim_lib_control::StepBudget,
445    ) -> Result<ResumeResult<T, T, Raised>, FrameError>,
446{
447    /// Construct a bounded generator. This supplies no scheduler or event loop.
448    pub fn new(limits: FrameLimits, driver: D) -> Self {
449        Self {
450            frame: ResumableFrame::new(limits, driver),
451            _value: std::marker::PhantomData,
452        }
453    }
454    /// Start execution and advance to the first yield.
455    pub fn start(&mut self) -> Result<PythonGeneratorStep<T>, PythonGeneratorError> {
456        self.resume(ResumePacket::Start)
457    }
458    /// Send a value into the suspended generator.
459    pub fn send(&mut self, value: T) -> Result<PythonGeneratorStep<T>, PythonGeneratorError> {
460        self.resume(ResumePacket::Send(value))
461    }
462    /// Throw an exception into the suspended generator.
463    pub fn throw(&mut self, error: Raised) -> Result<PythonGeneratorStep<T>, PythonGeneratorError> {
464        self.resume(ResumePacket::Throw(error))
465    }
466    /// Close the suspended generator and run its driver cleanup.
467    pub fn close(&mut self) -> Result<PythonGeneratorStep<T>, PythonGeneratorError> {
468        self.resume(ResumePacket::Close)
469    }
470    fn resume(
471        &mut self,
472        packet: ResumePacket<T, Raised>,
473    ) -> Result<PythonGeneratorStep<T>, PythonGeneratorError> {
474        match self
475            .frame
476            .resume(packet)
477            .map_err(PythonGeneratorError::Frame)?
478        {
479            ResumeResult::Yielded(value) => Ok(PythonGeneratorStep::Yielded(value)),
480            ResumeResult::Returned(value) => Ok(PythonGeneratorStep::Returned(value)),
481            ResumeResult::Failed(error) => Err(PythonGeneratorError::Raised(Box::new(error))),
482        }
483    }
484}
485
486impl fmt::Display for PythonExceptionError {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        write!(f, "{self:?}")
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use sim_kernel::{CodecId, SourceId, Span};
496
497    fn class(cx: &Cx, id: u32, name: &str) -> ClassRef {
498        cx.factory()
499            .class_stub(ClassId(id), Symbol::qualified("python", name))
500            .unwrap()
501    }
502    fn origin(at: usize) -> Origin {
503        Origin {
504            codec: CodecId(1),
505            source: SourceId("exceptions3-python".into()),
506            span: Span {
507                start: at,
508                end: at + 1,
509            },
510            trivia: Default::default(),
511        }
512    }
513
514    #[test]
515    fn managed_chains_groups_matching_and_diagnostics_preserve_python_policy() {
516        let mut cx = sim_kernel::testing::bare_cx();
517        let mut exceptions = PythonExceptions::new(32).unwrap();
518        let base = class(&cx, 1, "Exception");
519        let key = class(&cx, 2, "KeyError");
520        let runtime = class(&cx, 3, "RuntimeError");
521        let group_class = class(&cx, 4, "ExceptionGroup");
522        exceptions.define_class(&cx, base.clone(), vec![]).unwrap();
523        for derived in [&key, &runtime, &group_class] {
524            exceptions
525                .define_class(&cx, derived.clone(), vec![base.clone()])
526                .unwrap();
527        }
528        let cause = exceptions
529            .allocate(runtime.clone(), "disk", origin(1))
530            .unwrap();
531        let explicit = exceptions.allocate(runtime, "outer", origin(2)).unwrap();
532        exceptions.set_context(explicit, cause).unwrap();
533        exceptions.set_cause(explicit, cause).unwrap();
534        assert!(exceptions.inspect(explicit).unwrap().suppress_context());
535        assert_eq!(exceptions.inspect(explicit).unwrap().origin().span.start, 2);
536        let raised_key = exceptions
537            .allocate(key.clone(), "missing", origin(3))
538            .unwrap();
539        let raised = exceptions.raise(&cx, raised_key).unwrap();
540        assert!(matches!(
541            exceptions.matches(&mut cx, &raised, base, ClassMatchBudget { work: 8 }),
542            ClassMatchOutcome::Matched(_)
543        ));
544        assert!(matches!(
545            exceptions.matches(&mut cx, &raised, key.clone(), ClassMatchBudget { work: 8 }),
546            ClassMatchOutcome::Matched(_)
547        ));
548        assert_eq!(
549            raised.payload().object().display(&mut cx).unwrap(),
550            "missing"
551        );
552        assert_eq!(
553            exceptions.group(group_class.clone(), "empty", &[], origin(4)),
554            Err(PythonExceptionError::EmptyGroup)
555        );
556        let group = exceptions
557            .group(group_class, "batch", &[explicit, raised_key], origin(5))
558            .unwrap();
559        let (matched, rest) = exceptions
560            .split(&mut cx, group, key, ClassMatchBudget { work: 8 })
561            .unwrap();
562        let matched = matched.unwrap();
563        let rest = rest.unwrap();
564        assert_eq!(
565            exceptions.relations(matched).unwrap(),
566            vec![(PythonExceptionRelation::GroupMember(0), raised_key)]
567        );
568        assert_eq!(
569            exceptions.relations(rest).unwrap(),
570            vec![(PythonExceptionRelation::GroupMember(0), explicit)]
571        );
572        assert_eq!(
573            exceptions.inspect(matched).unwrap().group_message(),
574            Some("batch")
575        );
576    }
577
578    #[test]
579    fn generator_throws_only_shared_raised_envelopes() {
580        let cx = sim_kernel::testing::bare_cx();
581        let mut exceptions = PythonExceptions::new(4).unwrap();
582        let base = class(&cx, 1, "Exception");
583        exceptions.define_class(&cx, base.clone(), vec![]).unwrap();
584        let handle = exceptions.allocate(base, "boom", origin(7)).unwrap();
585        let raised = exceptions.raise(&cx, handle).unwrap();
586        let mut generator = PythonGenerator::new(FrameLimits { depth: 1, work: 2 }, |packet, _| {
587            Ok(match packet {
588                ResumePacket::Start => ResumeResult::Yielded(0),
589                ResumePacket::Throw(error) => ResumeResult::Failed(error),
590                ResumePacket::Send(value) => ResumeResult::Yielded(value),
591                ResumePacket::Close => ResumeResult::Returned(0),
592            })
593        });
594        generator.start().unwrap();
595        assert!(matches!(
596            generator.throw(raised),
597            Err(PythonGeneratorError::Raised(_))
598        ));
599    }
600}