1
//! Per-channel state for the `nomisync-eval` ssh subsystem.
2
//!
3
//! Owns the frame decoder (newline + balanced-paren), the epoch cancel
4
//! handle, and a tokio mpsc to a per-channel worker that drives
5
//! `rpc::Session::handle_form` independently of the russh handler
6
//! callback. Decoupling the worker from `data()` is what lets the
7
//! handler scan subsequent bytes (an emacs `C-g` arrives as the ETX
8
//! byte `0x03`) and bump the engine epoch synchronously, tripping the
9
//! in-flight eval and surfacing `(:error (:code interrupted ...))`
10
//! on the wire.
11
//!
12
//! Constructed in `subsystem_request` once the requested subsystem
13
//! name matches `"nomisync-eval"` AND the session has authenticated
14
//! (i.e. `user_id.is_some()`). Dropped in `channel_eof` /
15
//! `channel_close` along with the rest of the per-channel state; the
16
//! drop closes the mpsc, the worker answers every form already queued
17
//! and then finishes the SSH channel, so a client that half-closes its
18
//! input still gets its responses and sees the session end.
19

            
20
use russh::ChannelId;
21
use russh::server::Handle as RusshHandle;
22
use tokio::sync::mpsc;
23

            
24
use rpc::{EpochBumper, FrameDecoder, FrameError, InterruptHandle, Session};
25

            
26
/// ASCII End-of-Text (`Ctrl-C` / `C-g`). Clients emit this byte on
27
/// the same channel as form data to request a cooperative cancel of
28
/// the in-flight `nomi-eval`. Stripped from the byte stream before
29
/// the decoder sees it so it never becomes part of a frame.
30
const ETX: u8 = 0x03;
31

            
32
/// Trait for the worker's response back-channel. The russh-backed
33
/// impl writes to an SSH channel via the server's `Handle`; the
34
/// mpsc-backed impl drains into a tokio receiver for unit tests.
35
/// Either way, the worker emits one full response (newline-
36
/// terminated) per send call.
37
#[async_trait::async_trait]
38
pub trait ResponseSink: Send + Sync + 'static {
39
    async fn send(&self, bytes: Vec<u8>);
40
    /// Ends the channel after the last queued response was sent.
41
    async fn finish(&self);
42
}
43

            
44
pub struct RusshSink {
45
    handle: RusshHandle,
46
    channel: ChannelId,
47
}
48

            
49
impl RusshSink {
50
1
    pub fn new(handle: RusshHandle, channel: ChannelId) -> Self {
51
1
        Self { handle, channel }
52
1
    }
53
}
54

            
55
#[async_trait::async_trait]
56
impl ResponseSink for RusshSink {
57
20
    async fn send(&self, bytes: Vec<u8>) {
58
        if let Err(remaining) = self.handle.data(self.channel, bytes).await {
59
            log::warn!(
60
                "eval channel response write dropped {} bytes (channel closed?)",
61
                remaining.len()
62
            );
63
        }
64
20
    }
65

            
66
1
    async fn finish(&self) {
67
        // OpenSSH exits 255 when a channel closes without an exit status.
68
        let finished = self
69
            .handle
70
            .exit_status_request(self.channel, 0)
71
            .await
72
            .is_ok()
73
            && self.handle.eof(self.channel).await.is_ok()
74
            && self.handle.close(self.channel).await.is_ok();
75
        if !finished {
76
            log::debug!("eval channel session already gone at finish");
77
        }
78
1
    }
79
}
80

            
81
pub struct EvalChannelState {
82
    decoder: FrameDecoder,
83
    bumper: EpochBumper,
84
    /// Generation-counter cancel signal, shared with the worker's
85
    /// `Session`. Bumped together with the epoch on ETX so an
86
    /// interrupt arriving in the worker-pickup / compile window (before
87
    /// the eval enters Wasm, where an epoch bump would be absorbed by
88
    /// the not-yet-set deadline) is still observed by the session's
89
    /// pre-start `check_interrupt`.
90
    interrupt: InterruptHandle,
91
    forms_tx: mpsc::UnboundedSender<String>,
92
    /// Worker task drives `Session::handle_form` sequentially per
93
    /// channel; held so the worker stays alive for the channel's
94
    /// lifetime. Dropping `EvalChannelState` closes `forms_tx`; the
95
    /// worker drains what is queued, finishes the sink, and exits.
96
    _worker: tokio::task::JoinHandle<()>,
97
}
98

            
99
impl EvalChannelState {
100
    /// The caller decides whether the session is guarded; the daemon always
101
    /// passes a guarded one, tests an unguarded one for users with no row.
102
10
    pub fn with_session(mut session: Session, sink: Box<dyn ResponseSink>) -> Self {
103
10
        let bumper = session.epoch_bumper();
104
10
        let interrupt = session.interrupt_handle();
105
10
        let (forms_tx, mut forms_rx) = mpsc::unbounded_channel::<String>();
106
10
        let worker = tokio::spawn(async move {
107
36
            while let Some(frame) = forms_rx.recv().await {
108
27
                let mut response = session.handle_form(&frame).await;
109
27
                response.push('\n');
110
27
                sink.send(response.into_bytes()).await;
111
            }
112
4
            sink.finish().await;
113
4
        });
114
10
        Self {
115
10
            decoder: FrameDecoder::new(),
116
10
            bumper,
117
10
            interrupt,
118
10
            forms_tx,
119
10
            _worker: worker,
120
10
        }
121
10
    }
122

            
123
    /// Feeds raw bytes received on the channel. Scans for the ETX
124
    /// interrupt token synchronously and, on finding one, signals the
125
    /// cancel both ways: it advances the interrupt generation (caught
126
    /// by `handle_form`'s pre-start `check_interrupt` if the eval has
127
    /// not entered Wasm yet) and bumps the engine epoch (traps an eval
128
    /// already running in `call_async`). Wiring both closes the
129
    /// worker-pickup / compile window, where a lone epoch bump would be
130
    /// absorbed by the not-yet-set deadline and the cancel lost. Either
131
    /// path surfaces a `(:code interrupted ...)` envelope. ETX bytes are
132
    /// stripped before the bytes reach the frame decoder.
133
    ///
134
    /// Errors only for non-UTF8 input (the ssh layer should never
135
    /// emit that for an s-expression subsystem).
136
11
    pub fn feed(&mut self, bytes: &[u8]) -> Result<(), FrameError> {
137
11
        let has_interrupt = bytes.contains(&ETX);
138
11
        if has_interrupt {
139
3
            self.interrupt.interrupt();
140
3
            self.bumper.bump();
141
8
        }
142
        let cleaned: Vec<u8>;
143
11
        let payload: &[u8] = if has_interrupt {
144
40
            cleaned = bytes.iter().copied().filter(|b| *b != ETX).collect();
145
3
            &cleaned
146
        } else {
147
8
            bytes
148
        };
149
11
        self.decoder.feed(payload)?;
150
        loop {
151
37
            match self.decoder.next_frame() {
152
10
                None => break,
153
27
                Some(Ok(frame)) => {
154
27
                    if self.forms_tx.send(frame).is_err() {
155
                        // Worker exited (channel drop or session
156
                        // error). Subsequent frames silently noop;
157
                        // the channel will be torn down by the SSH
158
                        // layer when the client notices the missing
159
                        // responses.
160
                        break;
161
27
                    }
162
                }
163
                Some(Err(err)) => {
164
                    return Err(err);
165
                }
166
            }
167
        }
168
10
        Ok(())
169
11
    }
170
}
171

            
172
#[cfg(test)]
173
mod tests;