Skip to main content

sim_lib_lang_javascript/
jobs.rs

1//! ECMAScript resumable control and explicitly driven reaction jobs.
2
3use std::{cell::RefCell, collections::BTreeMap, fmt, rc::Rc, sync::Arc};
4
5use sim_kernel::{ClassRef, Cx, Object, ObjectCompat, Origin, Result as KernelResult, Symbol};
6
7use sim_lib_control::{
8    AdmissionLimit, CheckpointError, CheckpointReceipt, FrameError, FrameLimits, JobId, JobQueues,
9    Raised, ResumableFrame, ResumePacket, ResumeResult, RuntimeJobClass, WorkLimit,
10};
11
12use crate::JavascriptValue;
13
14#[derive(Debug)]
15struct JavascriptThrownFace {
16    value: JavascriptValue,
17}
18impl Object for JavascriptThrownFace {
19    fn display(&self, _cx: &mut Cx) -> KernelResult<String> {
20        Ok(format!("{:?}", self.value))
21    }
22    fn as_any(&self) -> &dyn std::any::Any {
23        self
24    }
25}
26impl ObjectCompat for JavascriptThrownFace {}
27
28/// Realm class identities used to classify arbitrary JavaScript thrown values.
29///
30/// Error subclasses and ordinary objects are registered by managed identity;
31/// primitives use only the realm's declared canonical classes.
32pub struct JavascriptExceptionRealm {
33    undefined_class: ClassRef,
34    null_class: ClassRef,
35    boolean_class: ClassRef,
36    number_class: ClassRef,
37    bigint_class: ClassRef,
38    string_class: ClassRef,
39    object_class: ClassRef,
40    managed_classes: BTreeMap<sim_lib_mutation::ManagedId, ClassRef>,
41}
42
43impl fmt::Debug for JavascriptExceptionRealm {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.debug_struct("JavascriptExceptionRealm")
46            .field("managed_class_count", &self.managed_classes.len())
47            .finish_non_exhaustive()
48    }
49}
50
51impl JavascriptExceptionRealm {
52    /// Declare the canonical primitive and ordinary-object classes for a realm.
53    #[allow(clippy::too_many_arguments)]
54    pub fn new(
55        undefined_class: ClassRef,
56        null_class: ClassRef,
57        boolean_class: ClassRef,
58        number_class: ClassRef,
59        bigint_class: ClassRef,
60        string_class: ClassRef,
61        object_class: ClassRef,
62    ) -> Self {
63        Self {
64            undefined_class,
65            null_class,
66            boolean_class,
67            number_class,
68            bigint_class,
69            string_class,
70            object_class,
71            managed_classes: BTreeMap::new(),
72        }
73    }
74
75    /// Associate an ordinary object with its exact realm class (including subclasses).
76    pub fn register_managed_class(
77        &mut self,
78        object: sim_lib_mutation::ManagedHandle,
79        class: ClassRef,
80    ) {
81        self.managed_classes.insert(object.id(), class);
82    }
83
84    /// Wrap an arbitrary thrown value in the one shared exceptional-completion envelope.
85    pub fn raise(&self, cx: &Cx, value: JavascriptValue, origin: Origin) -> KernelResult<Raised> {
86        let class = match &value {
87            JavascriptValue::Undefined => self.undefined_class.clone(),
88            JavascriptValue::Null => self.null_class.clone(),
89            JavascriptValue::Bool(_) => self.boolean_class.clone(),
90            JavascriptValue::Number(_) => self.number_class.clone(),
91            JavascriptValue::BigInt(_) => self.bigint_class.clone(),
92            JavascriptValue::String(_) => self.string_class.clone(),
93            JavascriptValue::Managed(handle) => self
94                .managed_classes
95                .get(&handle.id())
96                .cloned()
97                .unwrap_or_else(|| self.object_class.clone()),
98        };
99        let payload = cx
100            .factory()
101            .opaque(Arc::new(JavascriptThrownFace { value }))?;
102        Raised::new(
103            class,
104            payload,
105            origin,
106            Symbol::qualified("javascript", "exception"),
107        )
108    }
109
110    /// Recover the exact JavaScript value retained by this realm's envelope.
111    pub fn thrown_value<'a>(&self, raised: &'a Raised) -> Option<&'a JavascriptValue> {
112        (raised.profile() == &Symbol::qualified("javascript", "exception"))
113            .then(|| {
114                raised
115                    .payload()
116                    .object()
117                    .downcast_ref::<JavascriptThrownFace>()
118                    .map(|face| &face.value)
119            })
120            .flatten()
121    }
122}
123
124/// Queue classes used by the JavaScript profile.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
126pub enum JavascriptJobClass {
127    /// Promise reactions, async continuations, and dynamic-import evaluation.
128    Microtask,
129    /// Collector-admitted finalization work, never drained by a microtask checkpoint.
130    Finalization,
131}
132
133impl From<JavascriptJobClass> for RuntimeJobClass {
134    fn from(value: JavascriptJobClass) -> Self {
135        match value {
136            JavascriptJobClass::Microtask => RuntimeJobClass::LanguageMicrotask("javascript"),
137            JavascriptJobClass::Finalization => RuntimeJobClass::Finalization,
138        }
139    }
140}
141
142/// Explicit JavaScript job organ. It owns no thread, timer, or host event loop.
143pub struct JavascriptJobs {
144    queues: JobQueues<RuntimeJobClass>,
145}
146
147impl JavascriptJobs {
148    /// Creates queues under a lifetime admission limit.
149    pub fn new(admission: AdmissionLimit) -> Self {
150        Self {
151            queues: JobQueues::new(admission),
152        }
153    }
154
155    /// Admits a promise/module reaction into the JavaScript microtask FIFO.
156    pub fn enqueue_microtask(
157        &mut self,
158        job: impl FnOnce(&mut JobQueues<RuntimeJobClass>) + 'static,
159    ) -> Result<JobId, CheckpointError> {
160        self.queues
161            .enqueue(RuntimeJobClass::LanguageMicrotask("javascript"), job)
162            .map(|receipt| receipt.id)
163    }
164
165    /// Admits collector finalization independently of JavaScript microtasks.
166    pub fn enqueue_finalization(
167        &mut self,
168        job: impl FnOnce(&mut JobQueues<RuntimeJobClass>) + 'static,
169    ) -> Result<JobId, CheckpointError> {
170        self.queues
171            .enqueue(RuntimeJobClass::Finalization, job)
172            .map(|receipt| receipt.id)
173    }
174
175    /// Cancels queued work before an explicit checkpoint.
176    pub fn cancel(&mut self, id: JobId) {
177        self.queues.cancel(id);
178    }
179
180    /// Drains the JavaScript microtask class to empty, including reentrant reactions.
181    pub fn microtask_checkpoint(
182        &mut self,
183        work: WorkLimit,
184    ) -> Result<CheckpointReceipt<RuntimeJobClass>, CheckpointError> {
185        self.queues
186            .checkpoint(RuntimeJobClass::LanguageMicrotask("javascript"), work)
187    }
188
189    /// Explicitly drains collector finalization without touching microtasks.
190    pub fn finalization_checkpoint(
191        &mut self,
192        work: WorkLimit,
193    ) -> Result<CheckpointReceipt<RuntimeJobClass>, CheckpointError> {
194        self.queues.checkpoint(RuntimeJobClass::Finalization, work)
195    }
196}
197
198/// Promise state observed through a stable shared cell.
199#[derive(Clone, Debug, PartialEq)]
200pub enum JavascriptPromiseState {
201    /// No settlement has occurred.
202    Pending,
203    /// Fulfilled with one value.
204    Fulfilled(JavascriptValue),
205    /// Rejected with one exception.
206    Rejected(Raised),
207}
208
209/// A promise whose reactions are admitted only to explicit JavaScript jobs.
210#[derive(Clone)]
211pub struct JavascriptPromise(Rc<RefCell<JavascriptPromiseState>>);
212
213impl Default for JavascriptPromise {
214    fn default() -> Self {
215        Self(Rc::new(RefCell::new(JavascriptPromiseState::Pending)))
216    }
217}
218
219impl JavascriptPromise {
220    /// Snapshots the current settlement state.
221    pub fn state(&self) -> JavascriptPromiseState {
222        self.0.borrow().clone()
223    }
224
225    /// Enqueues first-settlement fulfillment as a reaction job.
226    pub fn resolve(
227        &self,
228        jobs: &mut JavascriptJobs,
229        value: JavascriptValue,
230    ) -> Result<JobId, CheckpointError> {
231        let state = Rc::clone(&self.0);
232        jobs.enqueue_microtask(move |_| {
233            let mut state = state.borrow_mut();
234            if matches!(*state, JavascriptPromiseState::Pending) {
235                *state = JavascriptPromiseState::Fulfilled(value);
236            }
237        })
238    }
239
240    /// Enqueues first-settlement rejection as a reaction job.
241    pub fn reject(
242        &self,
243        jobs: &mut JavascriptJobs,
244        error: Raised,
245    ) -> Result<JobId, CheckpointError> {
246        let state = Rc::clone(&self.0);
247        jobs.enqueue_microtask(move |_| {
248            let mut state = state.borrow_mut();
249            if matches!(*state, JavascriptPromiseState::Pending) {
250                *state = JavascriptPromiseState::Rejected(error);
251            }
252        })
253    }
254}
255
256/// Generator composed from the shared bounded resumable frame.
257pub struct JavascriptGenerator {
258    frame: ResumableFrame<Box<JavascriptResumeDriver>>,
259}
260
261type JavascriptResumeDriver =
262    dyn FnMut(
263        ResumePacket<JavascriptValue, Raised>,
264        &mut sim_lib_control::StepBudget,
265    ) -> Result<ResumeResult<JavascriptValue, JavascriptValue, Raised>, FrameError>;
266
267impl JavascriptGenerator {
268    /// Creates a bounded generator from JavaScript policy dispatch.
269    pub fn new(
270        limits: FrameLimits,
271        dispatch: impl FnMut(
272            ResumePacket<JavascriptValue, Raised>,
273            &mut sim_lib_control::StepBudget,
274        ) -> Result<
275            ResumeResult<JavascriptValue, JavascriptValue, Raised>,
276            FrameError,
277        > + 'static,
278    ) -> Self {
279        Self {
280            frame: ResumableFrame::new(limits, Box::new(dispatch) as Box<JavascriptResumeDriver>),
281        }
282    }
283
284    /// Resumes with `next`, `throw`, or `return` represented by a shared packet.
285    pub fn resume(
286        &mut self,
287        packet: ResumePacket<JavascriptValue, Raised>,
288    ) -> Result<ResumeResult<JavascriptValue, JavascriptValue, Raised>, FrameError> {
289        self.frame.resume(packet)
290    }
291}
292
293/// Async function execution is a generator whose terminal result settles a promise.
294pub struct JavascriptAsyncFunction {
295    generator: JavascriptGenerator,
296    promise: JavascriptPromise,
297}
298
299impl JavascriptAsyncFunction {
300    /// Wraps a resumable body and its externally visible promise.
301    pub fn new(generator: JavascriptGenerator) -> Self {
302        Self {
303            generator,
304            promise: JavascriptPromise::default(),
305        }
306    }
307
308    /// Promise settled by explicit calls to [`Self::resume`].
309    pub fn promise(&self) -> JavascriptPromise {
310        self.promise.clone()
311    }
312
313    /// Advances the body and admits terminal settlement to the microtask queue.
314    pub fn resume(
315        &mut self,
316        packet: ResumePacket<JavascriptValue, Raised>,
317        jobs: &mut JavascriptJobs,
318    ) -> Result<ResumeResult<JavascriptValue, JavascriptValue, Raised>, FrameError> {
319        let result = self.generator.resume(packet)?;
320        match &result {
321            ResumeResult::Returned(value) => {
322                self.promise
323                    .resolve(jobs, value.clone())
324                    .map_err(|_| FrameError::WorkExhausted)?;
325            }
326            ResumeResult::Failed(error) => {
327                self.promise
328                    .reject(jobs, error.clone())
329                    .map_err(|_| FrameError::WorkExhausted)?;
330            }
331            ResumeResult::Yielded(_) => {}
332        }
333        Ok(result)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use std::{cell::RefCell, rc::Rc};
340
341    use super::*;
342    use sim_kernel::{ClassId, CodecId, SourceId, Span};
343
344    fn class(cx: &Cx, id: u32, name: &str) -> ClassRef {
345        cx.factory()
346            .class_stub(ClassId(id), Symbol::qualified("javascript", name))
347            .unwrap()
348    }
349
350    fn origin(at: usize) -> Origin {
351        Origin {
352            codec: CodecId(1),
353            source: SourceId("exceptions3-javascript".into()),
354            span: Span {
355                start: at,
356                end: at + 1,
357            },
358            trivia: Default::default(),
359        }
360    }
361
362    fn realm(cx: &Cx) -> JavascriptExceptionRealm {
363        JavascriptExceptionRealm::new(
364            class(cx, 1, "Undefined"),
365            class(cx, 2, "Null"),
366            class(cx, 3, "Boolean"),
367            class(cx, 4, "Number"),
368            class(cx, 5, "BigInt"),
369            class(cx, 6, "String"),
370            class(cx, 7, "Object"),
371        )
372    }
373
374    fn content_id(value: &str) -> u64 {
375        value.bytes().fold(0xcbf29ce484222325, |hash, byte| {
376            (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
377        })
378    }
379
380    fn exception_characterization() -> Vec<String> {
381        let cx = sim_kernel::testing::bare_cx();
382        let realm = realm(&cx);
383        let errors = [
384            JavascriptValue::Number(3.0),
385            JavascriptValue::String("plain throw".into()),
386            JavascriptValue::Undefined,
387        ];
388        let raised = errors
389            .iter()
390            .cloned()
391            .enumerate()
392            .map(|(at, value)| realm.raise(&cx, value, origin(at)).unwrap())
393            .collect::<Vec<_>>();
394        vec![
395            format!("number={:?}", realm.thrown_value(&raised[0])),
396            format!("string={:?}", realm.thrown_value(&raised[1])),
397            format!("undefined={:?}", realm.thrown_value(&raised[2])),
398            format!(
399                "origins={:?}",
400                raised.iter().map(Raised::origin).collect::<Vec<_>>()
401            ),
402        ]
403    }
404
405    #[test]
406    fn characterize_1_exception_behavior_replays_identically() {
407        let first = exception_characterization();
408        let replay = exception_characterization();
409        assert_eq!(first, replay);
410        assert_eq!(
411            first.iter().map(|row| content_id(row)).collect::<Vec<_>>(),
412            replay.iter().map(|row| content_id(row)).collect::<Vec<_>>()
413        );
414    }
415
416    #[test]
417    fn generator_exception_and_async_settlement_use_shared_packets() {
418        let cx = sim_kernel::testing::bare_cx();
419        let realm = realm(&cx);
420        let mut generator =
421            JavascriptGenerator::new(FrameLimits { depth: 2, work: 4 }, |packet, budget| {
422                budget.charge_work()?;
423                Ok(match packet {
424                    ResumePacket::Start => ResumeResult::Yielded(JavascriptValue::Number(1.0)),
425                    ResumePacket::Send(value) => ResumeResult::Returned(value),
426                    ResumePacket::Throw(error) => ResumeResult::Failed(error),
427                    ResumePacket::Close => ResumeResult::Returned(JavascriptValue::Undefined),
428                })
429            });
430        assert!(matches!(
431            generator.resume(ResumePacket::Start).unwrap(),
432            ResumeResult::Yielded(JavascriptValue::Number(1.0))
433        ));
434        let error = realm
435            .raise(&cx, JavascriptValue::String("bounded".into()), origin(3))
436            .unwrap();
437        assert_eq!(
438            generator
439                .resume(ResumePacket::Throw(error.clone()))
440                .unwrap(),
441            ResumeResult::Failed(error)
442        );
443
444        let generator = JavascriptGenerator::new(FrameLimits { depth: 1, work: 1 }, |_, _| {
445            Ok(ResumeResult::Returned(JavascriptValue::Number(42.0)))
446        });
447        let mut function = JavascriptAsyncFunction::new(generator);
448        let promise = function.promise();
449        let mut jobs = JavascriptJobs::new(AdmissionLimit(4));
450        function.resume(ResumePacket::Start, &mut jobs).unwrap();
451        assert_eq!(promise.state(), JavascriptPromiseState::Pending);
452        jobs.microtask_checkpoint(WorkLimit(4)).unwrap();
453        assert_eq!(
454            promise.state(),
455            JavascriptPromiseState::Fulfilled(JavascriptValue::Number(42.0))
456        );
457
458        let rejected = JavascriptPromise::default();
459        let thrown = realm
460            .raise(&cx, JavascriptValue::Number(1.0), origin(4))
461            .unwrap();
462        rejected.reject(&mut jobs, thrown.clone()).unwrap();
463        jobs.microtask_checkpoint(WorkLimit(1)).unwrap();
464        assert_eq!(rejected.state(), JavascriptPromiseState::Rejected(thrown));
465
466        let async_error = realm
467            .raise(&cx, JavascriptValue::Undefined, origin(5))
468            .unwrap();
469        let failed = async_error.clone();
470        let generator = JavascriptGenerator::new(FrameLimits { depth: 1, work: 1 }, move |_, _| {
471            Ok(ResumeResult::Failed(failed.clone()))
472        });
473        let mut function = JavascriptAsyncFunction::new(generator);
474        let promise = function.promise();
475        assert_eq!(
476            function.resume(ResumePacket::Start, &mut jobs).unwrap(),
477            ResumeResult::Failed(async_error.clone())
478        );
479        jobs.microtask_checkpoint(WorkLimit(1)).unwrap();
480        assert_eq!(
481            promise.state(),
482            JavascriptPromiseState::Rejected(async_error)
483        );
484    }
485
486    #[test]
487    fn arbitrary_throws_keep_values_and_realm_class_identity() {
488        let cx = sim_kernel::testing::bare_cx();
489        let mut realm = realm(&cx);
490        let mut heap = crate::JavascriptHeap::retaining(2).unwrap();
491        let plain = heap
492            .allocate(crate::JavascriptManagedObject::new(
493                crate::JavascriptManagedKind::Object,
494            ))
495            .unwrap();
496        let subclassed_error = heap
497            .allocate(crate::JavascriptManagedObject::new(
498                crate::JavascriptManagedKind::Object,
499            ))
500            .unwrap();
501        let subclass = class(&cx, 8, "DomainError");
502        realm.register_managed_class(subclassed_error, subclass.clone());
503
504        for (at, value) in [
505            JavascriptValue::Number(1.0),
506            JavascriptValue::Undefined,
507            JavascriptValue::Managed(plain),
508        ]
509        .into_iter()
510        .enumerate()
511        {
512            let raised = realm.raise(&cx, value.clone(), origin(at)).unwrap();
513            assert_eq!(realm.thrown_value(&raised), Some(&value));
514        }
515        let raised = realm
516            .raise(&cx, JavascriptValue::Managed(subclassed_error), origin(3))
517            .unwrap();
518        assert_eq!(raised.class_ref(), &subclass);
519    }
520
521    #[test]
522    fn checkpoint_drains_reentrant_microtasks_and_isolates_finalization() {
523        let trace = Rc::new(RefCell::new(Vec::new()));
524        let mut jobs = JavascriptJobs::new(AdmissionLimit(8));
525        let outer_trace = Rc::clone(&trace);
526        jobs.enqueue_microtask(move |queues| {
527            outer_trace.borrow_mut().push("reaction-1");
528            let inner_trace = Rc::clone(&outer_trace);
529            queues
530                .enqueue(
531                    RuntimeJobClass::LanguageMicrotask("javascript"),
532                    move |_| {
533                        inner_trace.borrow_mut().push("reaction-2");
534                    },
535                )
536                .unwrap();
537        })
538        .unwrap();
539        let final_trace = Rc::clone(&trace);
540        jobs.enqueue_finalization(move |_| final_trace.borrow_mut().push("finalize"))
541            .unwrap();
542        let receipt = jobs.microtask_checkpoint(WorkLimit(3)).unwrap();
543        assert_eq!(receipt.completed.len(), 2);
544        assert_eq!(&*trace.borrow(), &["reaction-1", "reaction-2"]);
545        jobs.finalization_checkpoint(WorkLimit(1)).unwrap();
546        assert_eq!(&*trace.borrow(), &["reaction-1", "reaction-2", "finalize"]);
547    }
548
549    #[test]
550    fn exhaustion_and_cancellation_fail_closed_without_implicit_work() {
551        let ran = Rc::new(RefCell::new(0));
552        let mut jobs = JavascriptJobs::new(AdmissionLimit(2));
553        let first_ran = Rc::clone(&ran);
554        let first = jobs
555            .enqueue_microtask(move |_| *first_ran.borrow_mut() += 1)
556            .unwrap();
557        let second_ran = Rc::clone(&ran);
558        jobs.enqueue_microtask(move |_| *second_ran.borrow_mut() += 1)
559            .unwrap();
560        assert_eq!(*ran.borrow(), 0);
561        assert_eq!(
562            jobs.enqueue_microtask(|_| {}).unwrap_err(),
563            CheckpointError::AdmissionExhausted
564        );
565        jobs.cancel(first);
566        assert_eq!(
567            jobs.microtask_checkpoint(WorkLimit(1)).unwrap_err(),
568            CheckpointError::WorkExhausted
569        );
570        assert_eq!(*ran.borrow(), 0);
571        jobs.microtask_checkpoint(WorkLimit(1)).unwrap();
572        assert_eq!(*ran.borrow(), 1);
573    }
574}