1
//! Async eval bridge for the Console tab.
2
//!
3
//! `ConsoleEval` is a concrete (no `dyn`) actor: it owns an mpsc to a
4
//! per-console worker that drives `rpc::Session::handle_form`, plus the
5
//! cancel handles (`EpochBumper` + `InterruptHandle`). The UI side is
6
//! sync — `submit` is a non-blocking send, `drain` a `try_recv` loop —
7
//! so the 200 ms `run_loop` never awaits eval.
8
//!
9
//! `Session::handle_form` parses an RPC request **envelope**
10
//! (`(:id N :form <form>)`), not a bare form, so `submit` wraps each
11
//! input with a monotonic id before handoff — mirroring `nms`'s
12
//! `RpcEval::eval` and the sshd `eval_channel` worker. The worker
13
//! receives already-wrapped envelopes and only forwards them.
14
//!
15
//! Two constructors form the test seam: [`ConsoleEval::spawn`] builds a
16
//! real DB-backed `Session` actor, while [`ConsoleEval::echo`] spawns a
17
//! runtime-only worker that echoes frames back (no `Session`, no DB)
18
//! for plumbing tests.
19

            
20
use rpc::session::SessionError;
21
use rpc::{EpochBumper, InterruptHandle, RequestId, ScriptCtx, Session, parse_response};
22
use sqlx::types::Uuid;
23

            
24
/// Scrollback notice emitted once when the eval worker's result channel closes.
25
pub(crate) const WORKER_STOPPED_NOTICE: &str = "eval worker stopped";
26
use tokio::runtime::Handle;
27
use tokio::sync::mpsc;
28
use tokio::sync::mpsc::error::TryRecvError;
29
use tokio::task::JoinHandle;
30

            
31
/// A single drained item from the eval worker's result channel.
32
#[derive(Debug)]
33
pub enum DrainItem {
34
    /// A response whose `:id` is a positive integer — route by that id.
35
    Routed { id: i64, wire: String },
36
    /// A response with `:id 0` (envelope/parse error from the server)
37
    /// or a non-integer id; cannot be routed to a specific pending request.
38
    Unroutable(String),
39
    /// A plain-text notice generated by the drain loop itself (e.g.
40
    /// "eval worker stopped") — not a wire frame.
41
    Notice(String),
42
}
43

            
44
/// Async bridge between the sync TUI and an `rpc::Session` eval worker.
45
pub struct ConsoleEval {
46
    forms_tx: mpsc::UnboundedSender<String>,
47
    results_rx: mpsc::UnboundedReceiver<String>,
48
    /// Epoch cancel handle for the worker's `Session`. `None` for the
49
    /// `echo` test actor, which has no engine to trip.
50
    bumper: Option<EpochBumper>,
51
    interrupt: InterruptHandle,
52
    /// Monotonic envelope id; the wire `:id` of the next submission. An `i64`
53
    /// (the wire/`RequestId` type) that starts at 1 and only ever increments
54
    /// via `checked_add`, so it can never reach 0 (reserved for server
55
    /// envelope/parse errors, routed to [`DrainItem::Unroutable`]) or a
56
    /// negative value — the id space is simply refused once exhausted.
57
    next_id: i64,
58
    /// Set once `drain` observes the result channel disconnected, so the
59
    /// "worker stopped" notice reaches scrollback exactly once rather than
60
    /// on every subsequent tick.
61
    worker_stopped_reported: bool,
62
    /// Held so the worker stays alive for the eval's lifetime. Dropping
63
    /// `ConsoleEval` closes `forms_tx`; the worker's `recv` returns
64
    /// `None` and the task exits. Read only by the test seam.
65
    _worker: JoinHandle<()>,
66
}
67

            
68
impl ConsoleEval {
69
    /// Wraps `form` in a fresh `(:id {next_id} :form {form})` envelope
70
    /// and hands it to the worker. Non-blocking. Returns `Some(id)` with
71
    /// the allocated envelope id on success, or `None` when the channel
72
    /// is closed (the worker has stopped).
73
18
    pub fn submit(&mut self, form: String) -> Option<u64> {
74
18
        let id = self.next_id;
75
        // Refuse rather than wrap back toward the reserved 0 once the i64 id
76
        // space is exhausted (practically unreachable).
77
18
        let next = id.checked_add(1)?;
78
18
        let envelope = format!("(:id {id} :form {form})");
79
18
        self.forms_tx.send(envelope).ok()?;
80
16
        self.next_id = next;
81
16
        Some(id as u64)
82
18
    }
83

            
84
    /// Drains every ready response without blocking. Each received wire
85
    /// frame is parsed to extract its `:id` and classified as
86
    /// [`DrainItem::Routed`] (positive id), [`DrainItem::Unroutable`]
87
    /// (id 0 / non-integer id / parse error), or [`DrainItem::Notice`]
88
    /// (worker-stopped plain-text, emitted at most once).
89
410
    pub fn drain(&mut self) -> Vec<DrainItem> {
90
410
        let mut out = Vec::new();
91
        loop {
92
422
            match self.results_rx.try_recv() {
93
12
                Ok(wire) => out.push(classify_response(wire)),
94
406
                Err(TryRecvError::Empty) => break,
95
                Err(TryRecvError::Disconnected) => {
96
4
                    if !self.worker_stopped_reported {
97
3
                        self.worker_stopped_reported = true;
98
3
                        out.push(DrainItem::Notice(WORKER_STOPPED_NOTICE.to_string()));
99
3
                    }
100
4
                    break;
101
                }
102
            }
103
        }
104
410
        out
105
410
    }
106

            
107
    /// Requests a cooperative cancel of the in-flight eval. Advances the
108
    /// interrupt generation first (caught by `handle_form`'s pre-start
109
    /// `check_interrupt` if the eval has not entered Wasm yet) then bumps
110
    /// the engine epoch (traps an eval already running) — the
111
    /// eval_channel ordering that closes the worker-pickup window.
112
3
    pub fn interrupt(&self) {
113
3
        self.interrupt.interrupt();
114
3
        if let Some(bumper) = &self.bumper {
115
2
            bumper.bump();
116
2
        }
117
3
    }
118

            
119
    /// Builds a real `Session` for `user_id` and spawns its eval worker.
120
2
    pub fn spawn(handle: &Handle, user_id: Uuid) -> Result<Self, SessionError> {
121
2
        Self::from_ctx(handle, ScriptCtx::new(user_id))
122
2
    }
123

            
124
    /// Builds the worker from a caller-supplied context.
125
4
    fn from_ctx(handle: &Handle, ctx: ScriptCtx) -> Result<Self, SessionError> {
126
4
        let mut session = Session::new(ctx)?;
127
4
        let bumper = session.epoch_bumper();
128
4
        let interrupt = session.interrupt_handle();
129
4
        let (forms_tx, mut forms_rx) = mpsc::unbounded_channel::<String>();
130
4
        let (results_tx, results_rx) = mpsc::unbounded_channel::<String>();
131
4
        let worker = handle.spawn(async move {
132
9
            while let Some(frame) = forms_rx.recv().await {
133
5
                let response = session.handle_form(&frame).await;
134
5
                if results_tx.send(response).is_err() {
135
                    break;
136
5
                }
137
            }
138
1
        });
139
4
        Ok(Self {
140
4
            forms_tx,
141
4
            results_rx,
142
4
            bumper: Some(bumper),
143
4
            interrupt,
144
4
            next_id: 1,
145
4
            worker_stopped_reported: false,
146
4
            _worker: worker,
147
4
        })
148
4
    }
149

            
150
    /// Spawns a runtime-only worker that echoes each received frame back
151
    /// verbatim. No `Session`, no DB; used to exercise the channel/drain
152
    /// plumbing in isolation.
153
15
    pub fn echo(handle: &Handle) -> Self {
154
15
        let (forms_tx, mut forms_rx) = mpsc::unbounded_channel::<String>();
155
15
        let (results_tx, results_rx) = mpsc::unbounded_channel::<String>();
156
15
        let worker = handle.spawn(async move {
157
16
            while let Some(frame) = forms_rx.recv().await {
158
8
                if results_tx.send(frame).is_err() {
159
1
                    break;
160
7
                }
161
            }
162
3
        });
163
15
        Self {
164
15
            forms_tx,
165
15
            results_rx,
166
15
            bumper: None,
167
15
            interrupt: InterruptHandle::default(),
168
15
            next_id: 1,
169
15
            worker_stopped_reported: false,
170
15
            _worker: worker,
171
15
        }
172
15
    }
173
}
174

            
175
/// Parse a wire frame and classify it as routed, unroutable, or notice.
176
///
177
/// Envelope parse errors result in `Unroutable`; id 0 (server-assigned
178
/// for malformed requests) results in `Unroutable`; positive integer ids
179
/// result in `Routed`; string ids result in `Unroutable`.
180
16
fn classify_response(wire: String) -> DrainItem {
181
16
    match parse_response(&wire) {
182
8
        Ok(response) => match response.id {
183
7
            RequestId::Int(n) if n > 0 => DrainItem::Routed { id: n, wire },
184
2
            _ => DrainItem::Unroutable(wire),
185
        },
186
8
        Err(_) => DrainItem::Unroutable(wire),
187
    }
188
16
}
189

            
190
#[cfg(test)]
191
impl ConsoleEval {
192
    /// Build the worker from a caller-supplied context — the test seam
193
    /// that injects custom `ScriptLimits` (e.g. unbounded fuel for the
194
    /// interrupt test). Not part of the production surface.
195
2
    pub(crate) fn spawn_with_ctx(handle: &Handle, ctx: ScriptCtx) -> Result<Self, SessionError> {
196
2
        Self::from_ctx(handle, ctx)
197
2
    }
198

            
199
    /// An owned abort handle for the worker task, so a test can observe
200
    /// the worker finishing after the eval itself is dropped or abort it
201
    /// to simulate a stopped worker.
202
6
    pub(crate) fn worker_handle(&self) -> tokio::task::AbortHandle {
203
6
        self._worker.abort_handle()
204
6
    }
205
}
206

            
207
#[cfg(test)]
208
use rpc::ScriptLimits;
209

            
210
#[cfg(test)]
211
mod tests;