1
//! SLYNK server: lets Emacs SLY (`M-x sly-connect`) drive nomiscript through a
2
//! real `rpc::Session`. Speaks the SLYNK wire protocol (length-prefixed s-expr
3
//! RPC) — the exact contract is captured in
4
//! `doc/editor/slynk-protocol-transcript.org`.
5
//!
6
//! Layers: [`frame`] (6-hex length framing), [`sexp`] (the wire s-expr
7
//! subset), [`events`] (outbound event constructors), [`dispatch`] (pure
8
//! request classification + reply mapping), and this server loop. Activated by
9
//! `nms --slynk-port <PORT>`.
10
//!
11
//! Concurrency: per connection a **reader task** owns the socket read half and
12
//! classifies frames; an interrupt frame bumps the engine epoch IMMEDIATELY
13
//! (the reader holds an `EpochBumper` clone) so `C-g` lands even while an eval
14
//! is in flight. Eval requests flow over an mpsc to the **main task**, which
15
//! owns the `Session` and the write half and processes them serially. A
16
//! blocking eval therefore never starves the interrupt path.
17

            
18
mod dispatch;
19
mod events;
20
mod frame;
21
mod sexp;
22

            
23
use anyhow::{Context, Result};
24
use rpc::{ScriptCtx, Session};
25
use tokio::io::{AsyncRead, AsyncWrite};
26
use tokio::net::TcpListener;
27
use tokio::sync::mpsc;
28
use uuid::Uuid;
29

            
30
use dispatch::Inbound;
31

            
32
/// Channel ids: SLY allocates the local channel; we mirror it as the remote id
33
/// and use a fixed thread id (single-session, no real threads).
34
const THREAD_ID: i64 = 1;
35

            
36
/// Inbound message queue depth. SLY pipelines a handful of frames at most; a
37
/// bound caps memory if a client floods `:process` frames behind a slow eval.
38
const INBOUND_QUEUE_DEPTH: usize = 64;
39

            
40
/// Runs the SLYNK server: binds `127.0.0.1:port` and serves connections one at
41
/// a time (a debug REPL is single-session), rebuilding a fresh `Session` per
42
/// connection so a reconnect starts clean. `user_id` selects the DB-backed
43
/// `rpc::Session` user (same as `--rpc-user`).
44
pub async fn serve(port: u16, user_id: Uuid) -> Result<()> {
45
    let listener = TcpListener::bind(("127.0.0.1", port))
46
        .await
47
        .with_context(|| format!("binding SLYNK listener on 127.0.0.1:{port}"))?;
48
    eprintln!("nms SLYNK server listening on 127.0.0.1:{port} (M-x sly-connect)");
49
    loop {
50
        let (sock, peer) = listener.accept().await.context("accepting connection")?;
51
        eprintln!("SLYNK: client connected from {peer}");
52
        let session = Session::new(ScriptCtx::new(user_id))
53
            .map_err(|e| anyhow::anyhow!("rpc::Session::new failed: {e:?}"))?;
54
        if let Err(e) = serve_connection(sock, session).await {
55
            eprintln!("SLYNK: connection ended: {e:#}");
56
        } else {
57
            eprintln!("SLYNK: client disconnected");
58
        }
59
    }
60
}
61

            
62
/// Serves one connection until the peer closes. Splits the socket: the reader
63
/// task feeds classified inbound messages to this task over `rx`; this task
64
/// owns the `Session` + the write half and drives the protocol.
65
async fn serve_connection<S>(sock: S, mut session: Session) -> Result<()>
66
where
67
    S: AsyncRead + AsyncWrite + Send + 'static,
68
{
69
    let (read_half, mut write_half) = tokio::io::split(sock);
70
    let bumper = session.epoch_bumper();
71
    let interrupt = session.interrupt_handle();
72
    // Bounded so a client can't enqueue unbounded large `:process` frames while
73
    // a slow eval runs (memory-DoS guard); full → the reader closes the conn.
74
    let (tx, mut rx) = mpsc::channel::<Inbound>(INBOUND_QUEUE_DEPTH);
75

            
76
    // Reader task: decode frames, classify, forward. Interrupts are acted on
77
    // here so they don't queue behind an in-flight eval: bump the engine epoch
78
    // (cancels a running Wasm call) AND arm the interrupt latch (catches an
79
    // interrupt that lands during cold compile / before the call starts).
80
    let reader = tokio::spawn(read_loop(read_half, tx, bumper, interrupt));
81

            
82
    let mut remote_channel = 1i64;
83
    while let Some(msg) = rx.recv().await {
84
        match msg {
85
            Inbound::ConnectionInfo { id } => {
86
                let pid = i64::from(std::process::id());
87
                send(
88
                    &mut write_half,
89
                    &events::return_ok(events::connection_info(pid), id),
90
                )
91
                .await?;
92
            }
93
            Inbound::AddLoadPaths { id } => {
94
                send(&mut write_half, &events::return_ok(sexp::Sexp::nil(), id)).await?;
95
            }
96
            Inbound::SlynkRequire { id } => {
97
                send(
98
                    &mut write_half,
99
                    &events::return_ok(events::require_modules(), id),
100
                )
101
                .await?;
102
            }
103
            Inbound::CreateMrepl { id, local_channel } => {
104
                remote_channel = local_channel;
105
                send(
106
                    &mut write_half,
107
                    &events::return_ok(events::create_mrepl_ok(remote_channel, THREAD_ID), id),
108
                )
109
                .await?;
110
                // The first prompt is pushed unsolicited to the local channel
111
                // (transcript-confirmed).
112
                send(&mut write_half, &events::prompt(local_channel)).await?;
113
            }
114
            Inbound::Process { channel, source } => {
115
                let outcome = session.handle_request(&source).await;
116
                for frame in dispatch::eval_reply(channel, &outcome) {
117
                    send(&mut write_half, &frame).await?;
118
                }
119
            }
120
            Inbound::LoadFile { id, path } => {
121
                // `slynk:load-file` is a plain rex (no mREPL channel), and SLY
122
                // has no top-level `:write-string` event — `sly-load-file`
123
                // renders the `:ok` value in its transcript. So captured output
124
                // is folded into the returned summary by `load_reply`, not sent
125
                // as a separate frame.
126
                let outcome = session.handle_file(&path).await;
127
                send(&mut write_half, &dispatch::load_reply(id, &outcome)).await?;
128
            }
129
            Inbound::Completions { id, prefix, flex } => {
130
                let names = session.completions(&prefix);
131
                send(
132
                    &mut write_half,
133
                    &dispatch::completion_reply(id, &prefix, flex, &names),
134
                )
135
                .await?;
136
            }
137
            Inbound::Ping { thread, tag } => {
138
                send(&mut write_half, &events::emacs_pong(thread, tag)).await?;
139
            }
140
            Inbound::Interrupt => {
141
                // Already acted on in the reader (epoch bump); nothing to send.
142
            }
143
            Inbound::AbortRex { id } => {
144
                send(&mut write_half, &events::return_abort("unimplemented", id)).await?;
145
            }
146
            Inbound::Ignore => {}
147
        }
148
    }
149
    let _ = remote_channel;
150
    reader.await.ok();
151
    Ok(())
152
}
153

            
154
/// Reads + classifies frames until EOF/error. Forwards each message to the
155
/// eval task; an `:emacs-interrupt` additionally bumps the epoch right here so
156
/// an in-flight eval is cancelled without waiting for the channel to drain.
157
async fn read_loop<R>(
158
    mut read_half: R,
159
    tx: mpsc::Sender<Inbound>,
160
    bumper: rpc::EpochBumper,
161
    interrupt: rpc::InterruptHandle,
162
) where
163
    R: AsyncRead + Unpin,
164
{
165
    loop {
166
        match frame::read_frame(&mut read_half).await {
167
            Ok(Some(raw)) => {
168
                let Ok(parsed) = sexp::parse(&raw) else {
169
                    // Malformed frame: skip it rather than tearing down the
170
                    // connection (matches SLY's tolerance; never panics).
171
                    continue;
172
                };
173
                let msg = dispatch::classify(&parsed);
174
                if matches!(msg, Inbound::Interrupt) {
175
                    // Cancel a running Wasm call (epoch) AND latch for an eval
176
                    // still in its pre-call compile/link phase (the `evaluate`
177
                    // start + pre-`call_async` re-check consume the latch).
178
                    bumper.bump();
179
                    interrupt.interrupt();
180
                }
181
                if tx.send(msg).await.is_err() {
182
                    break; // eval task gone
183
                }
184
            }
185
            Ok(None) => break, // clean EOF
186
            Err(_) => break,   // malformed framing / read error: end connection
187
        }
188
    }
189
}
190

            
191
async fn send<W>(writer: &mut W, payload: &str) -> Result<()>
192
where
193
    W: AsyncWrite + Unpin,
194
{
195
    frame::write_frame(writer, payload)
196
        .await
197
        .context("writing SLYNK frame")
198
}