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