Skip to main content

sim_lib_server/trigger/
runtime.rs

1use std::{
2    fs,
3    path::PathBuf,
4    sync::{
5        Arc, Mutex,
6        atomic::{AtomicBool, Ordering},
7        mpsc,
8    },
9    thread,
10    time::Duration,
11};
12
13use sim_kernel::{Args, Cx, Error, Expr, Result, Symbol, Value};
14
15use crate::{
16    Server, ServerAddress, ServerFrame, decode_frame_payload, helpers::clone_server_cx,
17    symbol_list_value,
18};
19
20#[cfg(not(test))]
21use super::sources::spawn_stdin_reader;
22#[cfg(test)]
23use super::sources::{remove_test_stdin_sender, test_stdin_sender, test_stdin_senders};
24use super::{
25    NEXT_TRIGGER_ID, StdinSource, TriggerConfig, TriggerDecoder, TriggerHandle, TriggerState,
26    delivery_timeout, io_error_to_host, require_source_capability, source_capability,
27    trigger_read_policy,
28};
29
30impl TriggerHandle {
31    pub(super) fn new(server: Arc<Server>, config: TriggerConfig) -> Self {
32        let TriggerConfig {
33            source,
34            source_expr,
35            role,
36            codec,
37            decode_expr,
38            decoder,
39            cron,
40            network_source,
41        } = config;
42        let id = NEXT_TRIGGER_ID.fetch_add(1, Ordering::Relaxed);
43        let wall_clock = server.wall_clock().clone();
44        let stdin = match &source {
45            ServerAddress::Stdin => {
46                let (tx, rx) = mpsc::channel();
47                #[cfg(not(test))]
48                spawn_stdin_reader(tx);
49                #[cfg(test)]
50                {
51                    test_stdin_senders()
52                        .lock()
53                        .expect("test stdin sender registry poisoned")
54                        .insert(id, tx);
55                }
56                StdinSource::Channel(Mutex::new(rx))
57            }
58            _ => StdinSource::Unavailable,
59        };
60        Self {
61            id,
62            server,
63            source,
64            source_expr,
65            role,
66            codec,
67            decode_expr,
68            decoder,
69            cron,
70            network_source: network_source.map(Mutex::new),
71            wall_clock,
72            stopping: AtomicBool::new(false),
73            handle: Mutex::new(None),
74            stdin,
75            state: Mutex::new(TriggerState::default()),
76        }
77    }
78
79    pub(crate) fn start(self: &Arc<Self>, seed: &Cx) -> Result<()> {
80        let mut handle = self
81            .handle
82            .lock()
83            .map_err(|_| Error::PoisonedLock("trigger handle"))?;
84        if handle.is_some() {
85            return Ok(());
86        }
87        let mut cx = clone_server_cx(seed);
88        let trigger = Arc::clone(self);
89        *handle = Some(thread::spawn(move || {
90            trigger.run(&mut cx);
91        }));
92        Ok(())
93    }
94
95    pub(crate) fn stop(&self) -> Result<()> {
96        self.stopping.store(true, Ordering::Relaxed);
97        #[cfg(test)]
98        remove_test_stdin_sender(self.id);
99        let join = self
100            .handle
101            .lock()
102            .map_err(|_| Error::PoisonedLock("trigger handle"))?
103            .take();
104        if let Some(join) = join {
105            join.join()
106                .map_err(|_| Error::HostError("trigger thread panicked".to_owned()))?;
107        }
108        Ok(())
109    }
110
111    pub(crate) fn poll(&self, cx: &mut Cx) -> Result<u64> {
112        match &self.source {
113            ServerAddress::Stdin => self.poll_stdin(cx),
114            ServerAddress::FileTail { path } => self.poll_file_tail(cx, path),
115            ServerAddress::Cron { .. } => self.poll_cron(cx),
116            ServerAddress::Webhook { .. }
117            | ServerAddress::Imap { .. }
118            | ServerAddress::Smtp { .. }
119            | ServerAddress::Telegram { .. }
120            | ServerAddress::Matrix { .. } => self.poll_network_source(cx),
121            other => Err(Error::Eval(format!(
122                "server/trigger does not support source {}",
123                other.kind_symbol()
124            ))),
125        }
126    }
127
128    #[cfg(test)]
129    pub(crate) fn inject_text(&self, cx: &mut Cx, text: &str) -> Result<u64> {
130        let mut delivered = 0;
131        for line in text.lines() {
132            if line.trim().is_empty() {
133                continue;
134            }
135            self.inject_event(cx, line.as_bytes())?;
136            delivered += 1;
137        }
138        Ok(delivered)
139    }
140
141    #[cfg(test)]
142    pub(crate) fn feed_stdin(&self, text: &str) -> Result<()> {
143        let sender = test_stdin_sender(self.id)?;
144        for line in text.lines() {
145            sender
146                .send(Some(format!("{line}\n").into_bytes()))
147                .map_err(|_| Error::HostError("stdin trigger source closed".to_owned()))?;
148        }
149        Ok(())
150    }
151
152    #[cfg(test)]
153    pub(crate) fn finish_stdin(&self) -> Result<()> {
154        test_stdin_sender(self.id)?
155            .send(None)
156            .map_err(|_| Error::HostError("stdin trigger source closed".to_owned()))
157    }
158
159    #[cfg(test)]
160    pub(crate) fn is_source_closed(&self) -> bool {
161        self.state
162            .lock()
163            .map(|state| state.source_closed)
164            .unwrap_or(true)
165    }
166
167    #[cfg(all(test, feature = "trigger-webhook"))]
168    pub(crate) fn webhook_port(&self) -> Result<Option<u16>> {
169        let Some(source) = &self.network_source else {
170            return Ok(None);
171        };
172        let source = source
173            .lock()
174            .map_err(|_| Error::PoisonedLock("trigger source"))?;
175        let Some(source) = source
176            .as_any()
177            .downcast_ref::<super::sources::network::WebhookSource>()
178        else {
179            return Ok(None);
180        };
181        source.local_port().map(Some)
182    }
183
184    /// Reflects the trigger's state as a descriptor table value.
185    ///
186    /// Includes its kind, role, delivered-event count, and required
187    /// capabilities.
188    pub fn reflect_value(&self, cx: &mut Cx) -> Result<Value> {
189        let role = match &self.role {
190            Some(role) => cx.factory().symbol(role.clone())?,
191            None => cx.factory().nil()?,
192        };
193        let delivered = self
194            .state
195            .lock()
196            .map_err(|_| Error::PoisonedLock("trigger state"))?
197            .delivered;
198        let capabilities = match source_capability(&self.source) {
199            Some(capability) => symbol_list_value(cx, &[capability.as_symbol()])?,
200            None => cx.factory().list(Vec::new())?,
201        };
202        cx.factory().table(vec![
203            (
204                Symbol::new("kind"),
205                cx.factory().symbol(Symbol::new("trigger"))?,
206            ),
207            (Symbol::new("id"), cx.factory().string(self.id.to_string())?),
208            (
209                Symbol::new("source"),
210                cx.factory().expr(self.source_expr.clone())?,
211            ),
212            (Symbol::new("role"), role),
213            (
214                Symbol::new("codec"),
215                cx.factory().symbol(self.codec.clone())?,
216            ),
217            (
218                Symbol::new("decode"),
219                cx.factory().expr(self.decode_expr.clone())?,
220            ),
221            (
222                Symbol::new("delivered"),
223                cx.factory().string(delivered.to_string())?,
224            ),
225            (Symbol::new("requires"), capabilities),
226        ])
227    }
228
229    fn poll_file_tail(&self, cx: &mut Cx, path: &PathBuf) -> Result<u64> {
230        let mut bytes = fs::read(path).map_err(io_error_to_host)?;
231        let lines = {
232            let mut state = self
233                .state
234                .lock()
235                .map_err(|_| Error::PoisonedLock("trigger state"))?;
236            if state.file_offset > bytes.len() {
237                state.file_offset = 0;
238                state.file_remainder.clear();
239            }
240            let mut pending = std::mem::take(&mut state.file_remainder);
241            pending.extend_from_slice(&bytes[state.file_offset..]);
242            state.file_offset = bytes.len();
243
244            let mut lines = Vec::new();
245            let mut start = 0usize;
246            for index in 0..pending.len() {
247                if pending[index] == b'\n' {
248                    let mut line = pending[start..index].to_vec();
249                    if line.last() == Some(&b'\r') {
250                        line.pop();
251                    }
252                    lines.push(line);
253                    start = index + 1;
254                }
255            }
256            state.file_remainder = pending[start..].to_vec();
257            lines
258        };
259        bytes.clear();
260
261        let mut delivered = 0;
262        for line in lines {
263            if line.iter().all(u8::is_ascii_whitespace) {
264                continue;
265            }
266            self.inject_event(cx, &line)?;
267            delivered += 1;
268        }
269        Ok(delivered)
270    }
271
272    fn poll_stdin(&self, cx: &mut Cx) -> Result<u64> {
273        let receiver = match &self.stdin {
274            StdinSource::Channel(receiver) => receiver,
275            StdinSource::Unavailable => return Ok(0),
276        };
277        let mut delivered = 0;
278        loop {
279            match receiver
280                .lock()
281                .map_err(|_| Error::PoisonedLock("stdin trigger source"))?
282                .try_recv()
283            {
284                Ok(Some(mut line)) => {
285                    if line.last() == Some(&b'\n') {
286                        line.pop();
287                    }
288                    if line.last() == Some(&b'\r') {
289                        line.pop();
290                    }
291                    if line.iter().all(u8::is_ascii_whitespace) {
292                        continue;
293                    }
294                    self.inject_event(cx, &line)?;
295                    delivered += 1;
296                }
297                Ok(None) | Err(mpsc::TryRecvError::Disconnected) => {
298                    self.state
299                        .lock()
300                        .map_err(|_| Error::PoisonedLock("trigger state"))?
301                        .source_closed = true;
302                    break;
303                }
304                Err(mpsc::TryRecvError::Empty) => break,
305            }
306        }
307        Ok(delivered)
308    }
309
310    fn poll_network_source(&self, cx: &mut Cx) -> Result<u64> {
311        let Some(source) = &self.network_source else {
312            return Ok(0);
313        };
314        let mut delivered = 0;
315        loop {
316            let timeout = if delivered == 0 {
317                delivery_timeout()
318            } else {
319                Duration::from_millis(0)
320            };
321            let event = source
322                .lock()
323                .map_err(|_| Error::PoisonedLock("trigger source"))?
324                .next_event(cx, timeout)?;
325            let Some(event) = event else {
326                break;
327            };
328            self.inject_event(cx, &event)?;
329            source
330                .lock()
331                .map_err(|_| Error::PoisonedLock("trigger source"))?
332                .ack(cx)?;
333            delivered += 1;
334        }
335        Ok(delivered)
336    }
337
338    fn poll_cron(&self, cx: &mut Cx) -> Result<u64> {
339        let Some(matcher) = &self.cron else {
340            return Ok(0);
341        };
342        let now = self.wall_clock.now()?;
343        let current_minute = now.unix_millis() / 60_000;
344        {
345            let mut state = self
346                .state
347                .lock()
348                .map_err(|_| Error::PoisonedLock("trigger state"))?;
349            if !state.advance_cron_high_watermark(current_minute) {
350                return Ok(0);
351            }
352        }
353        let Some(_minute_key) = matcher.current_match(now) else {
354            return Ok(0);
355        };
356        self.inject_event(cx, b"tick")?;
357        Ok(1)
358    }
359
360    fn inject_event(&self, cx: &mut Cx, raw: &[u8]) -> Result<()> {
361        require_source_capability(cx, &self.source)?;
362        let expr = self.decode_event(cx, raw)?;
363        let source = self.source.kind_symbol();
364        let when_ms = self.wall_clock.now()?.unix_millis();
365        let mut frame = ServerFrame::from_expr(
366            cx,
367            self.codec.clone(),
368            crate::FrameKind::Trigger {
369                source: source.clone(),
370                when_ms,
371            },
372            &expr,
373            sim_kernel::Consistency::LocalFirst,
374            Vec::new(),
375            false,
376        )?;
377        frame.envelope.role = self.role.clone();
378        frame.envelope.trigger_source = Some(source);
379        self.server.deliver_trigger_frame(cx, frame)?;
380        self.state
381            .lock()
382            .map_err(|_| Error::PoisonedLock("trigger state"))?
383            .delivered += 1;
384        Ok(())
385    }
386
387    fn decode_event(&self, cx: &mut Cx, raw: &[u8]) -> Result<Expr> {
388        match &self.decoder {
389            TriggerDecoder::Codec(codec) => {
390                decode_frame_payload(cx, codec, raw, trigger_read_policy(cx), Default::default())
391            }
392            TriggerDecoder::Callable(callable) => {
393                let text = String::from_utf8(raw.to_vec()).map_err(|_| {
394                    Error::Eval(
395                        "trigger event bytes must be valid utf-8 for callable decoders".to_owned(),
396                    )
397                })?;
398                let arg = cx.factory().string(text)?;
399                let value = cx.call_value(callable.clone(), Args::new(vec![arg]))?;
400                value.object().as_expr(cx)
401            }
402        }
403    }
404
405    fn run(self: Arc<Self>, cx: &mut Cx) {
406        while !self.stopping.load(Ordering::Relaxed) {
407            match self.poll(cx) {
408                Ok(_) => {}
409                Err(_) => break,
410            }
411            let source_closed = self
412                .state
413                .lock()
414                .map(|state| state.source_closed)
415                .unwrap_or(true);
416            if source_closed {
417                break;
418            }
419            thread::sleep(self.poll_interval());
420        }
421    }
422
423    fn poll_interval(&self) -> Duration {
424        match self.source {
425            ServerAddress::Cron { .. } => Duration::from_millis(250),
426            _ => Duration::from_millis(25),
427        }
428    }
429}