Skip to main content

netget/server/redis/
mod.rs

1//! Redis server implementation with RESP protocol
2pub mod actions;
3pub mod cluster;
4pub mod pubsub;
5pub mod resp3;
6pub mod resp_value;
7
8use crate::llm::action_helper::call_llm;
9use crate::llm::actions::protocol_trait::ActionResult;
10use crate::llm::ollama_client::OllamaClient;
11use crate::logging::emit::Log;
12use crate::protocol::Event;
13use crate::server::connection::ConnectionId;
14use crate::state::app_state::AppState;
15use crate::utils::redis_keys::{key_hash_slot, slot_keys};
16use crate::utils::resp::{scan_resp2_frame, RespLength, RespLimits, RespScan};
17use actions::{
18    encode_error, encode_integer, RedisProtocol, ACCEPT_SUBSCRIPTION, DELIVER_MESSAGE,
19    REDIS_COMMAND_EVENT, REDIS_PUBLISH_EVENT, REDIS_SUBSCRIBE_EVENT,
20};
21use anyhow::Result;
22use pubsub::{Kind, Outbox, OutboxReceiver, PubSubHub};
23use redis_protocol::resp2::decode::decode;
24use redis_protocol::resp2::types::OwnedFrame as Frame;
25use resp_value::Resp;
26use std::net::SocketAddr;
27use std::sync::atomic::Ordering;
28use std::sync::Arc;
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio::net::{TcpListener, TcpStream};
31use tokio::sync::mpsc;
32use tracing::{debug, error, trace, warn};
33
34/// Maximum bytes we will buffer for a single, still-incomplete RESP frame.
35///
36/// `decode()` returns `Ok(None)` while a frame is incomplete, so without this cap a client
37/// that announces a huge bulk string (`$2000000000\r\n`) and then stalls would make the
38/// connection buffer grow without bound. Real Redis caps a bulk string at 512 MB; 64 MB is
39/// far more than any LLM-authored command needs.
40pub const MAX_PENDING_FRAME_BYTES: usize = 64 * 1024 * 1024;
41
42/// What a frame may declare before `decode` is allowed to see it: [`crate::utils::resp`]'s
43/// depth and element bounds, and a bulk string no longer than the buffer that must hold it —
44/// a `$N` past [`MAX_PENDING_FRAME_BYTES`] can never complete, so it is refused on its header
45/// line instead of after the peer has filled 64 MiB.
46const RESP_LIMITS: RespLimits = RespLimits::with_max_bulk_len(MAX_PENDING_FRAME_BYTES);
47
48/// How long to wait for the first byte of the first command from a peer that has only
49/// connected.
50///
51/// **This was 30 seconds, and the argument for 30 was about the wrong peer.** It read: every
52/// real RESP client speaks immediately — `redis-cli` sends `COMMAND DOCS`, `redis-rs` sends
53/// `PING` or the `HELLO`/`AUTH` it was configured with — so a peer that has connected and said
54/// nothing has made no claim on the server. That is true of every *third-party* client and
55/// false of the one this server most often has.
56///
57/// NetGet's own Redis client (`src/client/redis/mod.rs`) is a bare `TcpStream::connect` and
58/// nothing else: it puts no byte on the wire until an action says to. The dashboard offers
59/// `[ + redis client ]` under a server's peers, and a client created there is routed
60/// `<proto>_connected` → static-with-no-actions and then `*` → manual
61/// (`src/tui/modal/form.rs`), so it connects, is answered with nothing, and waits for a person
62/// to type into `[ send message ]`. The same is true from this side: `run` registers a peer
63/// channel before the first read precisely so `[ message ]` reaches a peer that has not spoken
64/// yet. With a 30-second bound the server dropped that peer while the operator was still
65/// looking at it — the same defect `src/server/tcp/mod.rs` had, found there by
66/// `tests/mcp_stdio_test.rs::client_tools_manage_a_real_connection` reporting `Disconnected`.
67///
68/// 300 seconds is the window a `manual` rule gives a human to answer one event
69/// (`src/state/intercepts.rs`), which is the number this product already uses for how long
70/// someone might take, and it is also this server's own
71/// [`IDLE_BETWEEN_COMMANDS_TIMEOUT`] — so a hand-driven session is now bounded the same way
72/// before its first command as after it.
73///
74/// What it costs: a stranger holding a socket, a task and an `AppState` row while saying
75/// nothing now gets 300 seconds rather than 30. That is still bounded, still capped at
76/// [`MAX_CONNECTIONS`], and still answered above that cap with real Redis's own
77/// `-ERR max number of clients reached`; the change is a tenfold rise in how long one idle
78/// slot is held, not a removal of the bound. A listener genuinely exposed to strangers should
79/// set `first_byte_timeout_secs` low — 30 is the old value and remains a sound choice for one.
80const FIRST_COMMAND_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
81
82/// How long to wait for a *further* command once one has been answered.
83///
84/// Much longer than the first wait, and deliberately so: real Redis ships `timeout 0` — it
85/// never closes an idle client — and every pooling client in the ecosystem (`redis-rs`'s
86/// `ConnectionManager`, `deadpool-redis`, an interactive `redis-cli`) depends on holding an
87/// established connection unused between bursts. Closing those at 30s would break correct
88/// clients to fix a problem they are not causing. Five minutes still bounds the hold, and a
89/// client that reconnects after it reconnects transparently.
90///
91/// Overridable per server with `idle_timeout_secs`, for the same reason the first bound is:
92/// the right value is a property of who is on the other end, and only the operator knows that.
93const IDLE_BETWEEN_COMMANDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
94
95/// Concurrent connections this server admits.
96///
97/// [`accept_bounded::DEFAULT_MAX_CONNECTIONS`]; nothing about RESP makes a connection more or
98/// less expensive than the default assumes.
99const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
100
101/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
102///
103/// Byte-for-byte what real Redis sends in the same situation, so every RESP client already
104/// knows it: `redis-rs` surfaces it as `ResponseError`, `redis-cli` prints it. A simple error
105/// can never be mistaken for data, which is what makes it safe to send unprompted — the peer
106/// has not spoken yet, so this arrives before its first command rather than as a reply to one.
107const CONNECTION_CAP_REFUSAL: &[u8] = b"-ERR max number of clients reached\r\n";
108
109/// What one server's connections share: the pub/sub registry, and — in cluster mode — the
110/// topology. Registered with [`AppState::register_server_handle`] so the model's async actions
111/// (`redis_publish_message`, `redis_cluster_set_slot`) reach the running server.
112pub struct RedisShared {
113    pub hub: PubSubHub,
114    pub cluster: Option<std::sync::Mutex<cluster::ClusterState>>,
115}
116
117impl RedisShared {
118    fn cluster(&self) -> Option<std::sync::MutexGuard<'_, cluster::ClusterState>> {
119        self.cluster
120            .as_ref()
121            .map(|c| c.lock().unwrap_or_else(|p| p.into_inner()))
122    }
123}
124
125/// The per-server knobs `spawn` reads from the startup parameters.
126#[derive(Debug, Default, Clone)]
127pub struct RedisServerOptions {
128    pub first_byte_timeout_secs: Option<u64>,
129    pub idle_timeout_secs: Option<u64>,
130    /// The `cluster_nodes` topology; `Some` puts the server in cluster mode.
131    pub cluster_nodes: Option<serde_json::Value>,
132}
133
134/// Redis server implementation
135pub struct RedisServer {
136    llm_client: OllamaClient,
137    app_state: Arc<AppState>,
138    #[allow(dead_code)]
139    status_tx: mpsc::UnboundedSender<String>,
140    server_id: Option<crate::state::ServerId>,
141}
142
143impl RedisServer {
144    /// Create a new Redis server
145    pub fn new(
146        llm_client: OllamaClient,
147        app_state: Arc<AppState>,
148        status_tx: mpsc::UnboundedSender<String>,
149        server_id: Option<crate::state::ServerId>,
150    ) -> Self {
151        Self {
152            llm_client,
153            app_state,
154            status_tx,
155            server_id,
156        }
157    }
158
159    /// Spawn Redis server with LLM integration
160    pub async fn spawn_with_llm_actions(
161        listen_addr: SocketAddr,
162        llm_client: OllamaClient,
163        app_state: Arc<AppState>,
164        status_tx: mpsc::UnboundedSender<String>,
165        _send_first: bool,
166        server_id: crate::state::ServerId,
167        options: RedisServerOptions,
168    ) -> Result<SocketAddr> {
169        // Both bounds are tunable because their right value is a property of who is on the
170        // other end, which only the operator knows. The defaults serve NetGet's own client
171        // waiting on a human; a listener exposed to strangers wants the first one much lower.
172        let first_byte_timeout = options
173            .first_byte_timeout_secs
174            .map(std::time::Duration::from_secs)
175            .unwrap_or(FIRST_COMMAND_READ_TIMEOUT);
176        let idle_timeout = options
177            .idle_timeout_secs
178            .map(std::time::Duration::from_secs)
179            .unwrap_or(IDLE_BETWEEN_COMMANDS_TIMEOUT);
180        let listener = TcpListener::bind(listen_addr).await?;
181        let actual_addr = listener.local_addr()?;
182
183        // The topology is validated against the address actually bound (a `myself` node
184        // without a port is this listener), and a bad one refuses to start rather than serving
185        // a cluster nobody can navigate.
186        let cluster = options
187            .cluster_nodes
188            .as_ref()
189            .map(|nodes| cluster::ClusterState::from_param(nodes, actual_addr))
190            .transpose()
191            .map_err(|e| anyhow::anyhow!("invalid cluster_nodes: {e:#}"))?;
192        let shared = Arc::new(RedisShared {
193            hub: PubSubHub::default(),
194            cluster: cluster.map(std::sync::Mutex::new),
195        });
196        app_state
197            .register_server_handle(server_id, shared.clone())
198            .await;
199
200        let log = Log::new(Some(&status_tx));
201        match shared.cluster() {
202            Some(c) => log.info(format!(
203                "Redis server listening on {} (cluster mode: {} of {} nodes)",
204                actual_addr,
205                c.me().addr(),
206                c.nodes.len()
207            )),
208            None => log.info(format!("Redis server listening on {}", actual_addr)),
209        }
210
211        let server = Arc::new(RedisServer::new(
212            llm_client,
213            app_state.clone(),
214            status_tx.clone(),
215            Some(server_id),
216        ));
217
218        let status_tx_clone = status_tx.clone();
219        let task_registrar = app_state.clone();
220
221        // Spawn the accept loop
222        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
223        let accept_handle = tokio::spawn(async move {
224            loop {
225                match crate::server::accept_bounded::accept_bounded(
226                    &listener,
227                    &limiter,
228                    CONNECTION_CAP_REFUSAL,
229                    "Redis",
230                    Some(&status_tx),
231                )
232                .await
233                {
234                    Ok((stream, addr, permit)) => {
235                        Log::new(Some(&status_tx)).info(format!("Redis connection from {}", addr));
236
237                        let connection_id =
238                            ConnectionId::new(app_state.get_next_unified_id().await);
239                        let local_addr_conn = stream.local_addr().unwrap_or(actual_addr);
240
241                        // Track the connection
242                        if let Some(server_id) = server.server_id {
243                            use crate::state::server::{
244                                ConnectionState as ServerConnectionState, ConnectionStatus,
245                                ProtocolConnectionInfo,
246                            };
247                            let now = crate::utils::clock::Instant::now();
248                            let conn_state = ServerConnectionState {
249                                id: connection_id,
250                                remote_addr: addr,
251                                local_addr: local_addr_conn,
252                                bytes_sent: 0,
253                                bytes_received: 0,
254                                packets_sent: 0,
255                                packets_received: 0,
256                                last_activity: now,
257                                status: ConnectionStatus::Active,
258                                status_changed_at: now,
259                                protocol_info: ProtocolConnectionInfo::empty(),
260                            };
261                            server
262                                .app_state
263                                .add_connection_to_server(server_id, conn_state)
264                                .await;
265                        }
266
267                        let handler = RedisHandler {
268                            connection_id,
269                            llm_client: server.llm_client.clone(),
270                            app_state: server.app_state.clone(),
271                            status_tx: status_tx.clone(),
272                            server_id: server.server_id,
273                            first_byte_timeout,
274                            idle_timeout,
275                            shared: shared.clone(),
276                        };
277
278                        let conn_handle = tokio::spawn(async move {
279                            // The permit is released when this task ends, which is what makes
280                            // `MAX_CONNECTIONS` a cap on live connections rather than on the
281                            // accept rate.
282                            let _permit = permit;
283                            if let Err(e) = handler.handle_connection(stream).await {
284                                error!("Redis connection error: {:?}", e);
285                            }
286                        });
287
288                        // Register the per-connection task too, not just the accept loop:
289                        // aborting the accept loop releases the port but leaves every
290                        // in-flight session running, so `stop_server` did not actually stop
291                        // the server. `register_server_task` prunes finished handles on each
292                        // call, so this cannot grow without bound.
293                        if let Some(server_id) = server.server_id {
294                            server
295                                .app_state
296                                .register_server_task(server_id, conn_handle)
297                                .await;
298                        }
299                    }
300                    Err(e) => {
301                        Log::new(Some(&status_tx)).error(format!("Redis accept error: {}", e));
302                    }
303                }
304            }
305        });
306
307        // Register the accept loop so stop_server can abort it and release the port.
308        task_registrar
309            .register_server_task(server_id, accept_handle)
310            .await;
311
312        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
313        Ok(actual_addr)
314    }
315}
316
317/// Redis connection handler
318struct RedisHandler {
319    connection_id: ConnectionId,
320    llm_client: OllamaClient,
321    app_state: Arc<AppState>,
322    status_tx: mpsc::UnboundedSender<String>,
323    server_id: Option<crate::state::ServerId>,
324    /// [`FIRST_COMMAND_READ_TIMEOUT`], or the value of this server's `first_byte_timeout_secs`.
325    first_byte_timeout: std::time::Duration,
326    /// [`IDLE_BETWEEN_COMMANDS_TIMEOUT`], or the value of this server's `idle_timeout_secs`.
327    idle_timeout: std::time::Duration,
328    shared: Arc<RedisShared>,
329}
330
331/// Aborts the connection's writer task however the read loop ends. The writer may be parked in
332/// a write to a peer that stopped reading — the slow-consumer case — and only an abort ends
333/// that.
334struct AbortWriter(tokio::task::AbortHandle);
335
336impl Drop for AbortWriter {
337    fn drop(&mut self) {
338        self.0.abort();
339    }
340}
341
342/// What the model answered, folded out of its action results.
343#[derive(Default)]
344struct ModelAnswer {
345    /// RESP bytes the reply verbs produced, or the fail-closed error.
346    output: Vec<u8>,
347    close: bool,
348    /// `redis_accept_subscription`.
349    accept: bool,
350    /// `redis_deliver_message`.
351    deliver: bool,
352    /// The model call itself failed; `output` holds the error.
353    failed: bool,
354}
355
356/// Everything the per-command handling needs from the connection.
357struct Conn<'a> {
358    protocol: Arc<RedisProtocol>,
359    outbox: Outbox,
360    write_half: Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
361    log: Log<'a>,
362    /// The one-shot `ASKING` flag of cluster mode.
363    asking: bool,
364}
365
366/// What to do after one command.
367enum Flow {
368    Continue,
369    Close,
370}
371
372/// Commands a RESP2 connection may send while it holds a subscription (Redis's own list).
373const SUBSCRIBED_MODE_COMMANDS: &[&str] = &[
374    "SUBSCRIBE",
375    "PSUBSCRIBE",
376    "SSUBSCRIBE",
377    "UNSUBSCRIBE",
378    "PUNSUBSCRIBE",
379    "SUNSUBSCRIBE",
380    "PING",
381    "QUIT",
382    "RESET",
383];
384
385fn wrong_arity(name: &str) -> String {
386    format!(
387        "ERR wrong number of arguments for '{}' command",
388        name.to_ascii_lowercase()
389    )
390}
391
392impl RedisHandler {
393    /// Run the connection, then always mark it closed in `AppState`.
394    ///
395    /// Without this the connection stays `Active` forever in the server's connection map,
396    /// which is what `src/server/redis/CLAUDE.md` already claimed happened but did not.
397    async fn handle_connection(self, stream: TcpStream) -> Result<()> {
398        let server_id = self.server_id;
399        let connection_id = self.connection_id;
400        let app_state = self.app_state.clone();
401        let status_tx = self.status_tx.clone();
402        let shared = self.shared.clone();
403
404        let result = self.run(stream).await;
405
406        // A subscription is a fact about this socket; it goes with it.
407        shared.hub.remove_connection(connection_id.as_u32());
408
409        // Every exit path - EOF, read error, decode error, close_this_connection, frame
410        // cap, slow consumer - lands here, so the dashboard's peer rows go away with the
411        // connection.
412        if let Some(server_id) = server_id {
413            app_state
414                .remove_peer_handle(server_id, connection_id.as_u32())
415                .await;
416            app_state
417                .close_connection_on_server(server_id, connection_id)
418                .await;
419            let _ = status_tx.send("__UPDATE_UI__".to_string());
420        }
421
422        result
423    }
424
425    /// Write one fixed RESP error, then half-close: the refusal for a frame this server will
426    /// not decode. The text is a constant chosen by the caller, never anything derived from
427    /// the peer's bytes or an internal error.
428    async fn refuse(&self, conn: &Conn<'_>, message: &str) {
429        let _ = conn.outbox.send_reply(encode_error(message)).await;
430        let _ = conn.write_half.lock().await.shutdown().await;
431    }
432
433    /// Write one reply. It goes through the connection's outbox so it keeps its place among
434    /// delivered pub/sub messages, and this waits until it is on the socket.
435    async fn write_reply(&self, conn: &Conn<'_>, reply: Vec<u8>) -> Result<()> {
436        conn.outbox.send_reply(reply).await?;
437        Ok(())
438    }
439
440    /// The single writer of this connection: replies and delivered messages, in queue order.
441    async fn writer_task(
442        mut out: OutboxReceiver,
443        outbox: Outbox,
444        write_half: Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
445        app_state: Arc<AppState>,
446        server_id: Option<crate::state::ServerId>,
447        connection_id: ConnectionId,
448    ) {
449        while let Some(frame) = out.rx.recv().await {
450            let len = frame.bytes.len();
451            let result = if len == 0 {
452                // A barrier: the read loop waits on it to know that everything queued before
453                // it (subscription confirmations) has been written.
454                Ok(())
455            } else {
456                let mut writer = write_half.lock().await;
457                match writer.write_all(&frame.bytes).await {
458                    Ok(()) => writer.flush().await,
459                    Err(e) => Err(e),
460                }
461            };
462            if frame.counted {
463                out.queued.fetch_sub(len, Ordering::AcqRel);
464            }
465            let failed = result.is_err();
466            if !failed && len > 0 {
467                if let Some(server_id) = server_id {
468                    app_state
469                        .update_connection_stats(
470                            server_id,
471                            connection_id,
472                            None,
473                            Some(len as u64),
474                            None,
475                            Some(1),
476                        )
477                        .await;
478                }
479            }
480            if let Some(ack) = frame.ack {
481                let _ = ack.send(result);
482            }
483            if failed {
484                outbox.mark_killed();
485                break;
486            }
487        }
488    }
489
490    async fn run(self, stream: TcpStream) -> Result<()> {
491        // Copied out before `self` is partially moved into the tasks below.
492        let first_byte_timeout = self.first_byte_timeout;
493        let idle_timeout = self.idle_timeout;
494        let protocol = Arc::new(RedisProtocol::for_connection(
495            self.connection_id,
496            self.app_state.clone(),
497            self.status_tx.clone(),
498            self.shared.clone(),
499        ));
500
501        // The write half is shared with the peer command task below, so the dashboard can
502        // write into this connection while the read loop is parked in `read()`.
503        let (mut read_half, write_half) = tokio::io::split(stream);
504        let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
505
506        // Peer messaging: "message this peer" / "disconnect this peer" inject actions into
507        // THIS connection through the same executor the LLM path uses. Registered before the
508        // first read, so a manual `*` rule parking the first command still leaves the
509        // operator able to reach the connection. Every reply verb returns
510        // `ActionResult::Output` with the RESP bytes pre-encoded (see actions.rs), so the
511        // generic peer task writes injected replies to the wire; `close_connection`
512        // half-closes.
513        if let Some(server_id) = self.server_id {
514            let peer_rx = crate::server::peer_support::register_peer_channel(
515                &self.app_state,
516                server_id,
517                self.connection_id.as_u32(),
518            )
519            .await;
520            crate::server::peer_support::spawn_peer_command_task(
521                peer_rx,
522                protocol.clone(),
523                self.app_state.clone(),
524                server_id,
525                self.connection_id.as_u32(),
526                write_half.clone(),
527                self.status_tx.clone(),
528            );
529            let _ = self.status_tx.send("__UPDATE_UI__".to_string());
530        }
531
532        // One writer owns the socket, so a reply and a delivered message never interleave
533        // mid-frame and a subscription's confirmation is written before its first message.
534        let (outbox, outbox_rx) = Outbox::new();
535        let writer = tokio::spawn(Self::writer_task(
536            outbox_rx,
537            outbox.clone(),
538            write_half.clone(),
539            self.app_state.clone(),
540            self.server_id,
541            self.connection_id,
542        ));
543        let _writer_guard = AbortWriter(writer.abort_handle());
544        if let Some(server_id) = self.server_id {
545            self.app_state.register_server_task(server_id, writer).await;
546        }
547
548        let mut conn = Conn {
549            protocol,
550            outbox,
551            write_half,
552            log: Log::new(Some(&self.status_tx)),
553            asking: false,
554        };
555
556        let mut buffer = Vec::new();
557        let mut answered_one = false;
558
559        loop {
560            // Read data from the stream.
561            //
562            // The deadline wraps the `read()` alone and nothing else in this loop. That is the
563            // whole design: an LLM round-trip, and a `manual` rule parking a command for a
564            // human (`src/state/intercepts.rs`, 300s by default), both happen *below*, after
565            // the read has already returned — so neither can be timed out from under itself.
566            // What is bounded is only the time a peer may hold this connection while sending
567            // nothing, which is the resource an idle attacker is actually consuming.
568            let read_timeout = if answered_one {
569                idle_timeout
570            } else {
571                first_byte_timeout
572            };
573            let mut chunk = vec![0u8; 4096];
574            let read = tokio::select! {
575                read = tokio::time::timeout(read_timeout, read_half.read(&mut chunk)) => read,
576                // A slow consumer the hub disconnected, or a write that failed.
577                _ = conn.outbox.closing() => {
578                    conn.log.warn(format!(
579                        "Redis connection {} closed: it stopped reading while {} bytes of \
580                         pub/sub messages were queued for it",
581                        self.connection_id,
582                        conn.outbox.queued_bytes()
583                    ));
584                    return Ok(());
585                }
586            };
587            let read = match read {
588                Ok(read) => read,
589                Err(_) => {
590                    conn.log.info(format!(
591                        "Redis client {} sent nothing for {}s; closing idle connection",
592                        self.connection_id,
593                        read_timeout.as_secs()
594                    ));
595                    return Ok(());
596                }
597            };
598            let n = match read {
599                Ok(0) => {
600                    debug!("Redis client disconnected");
601                    return Ok(());
602                }
603                Ok(n) => n,
604                Err(e) => {
605                    error!("Redis read error: {}", e);
606                    return Err(e.into());
607                }
608            };
609
610            buffer.extend_from_slice(&chunk[..n]);
611
612            if let Some(server_id) = self.server_id {
613                self.app_state
614                    .update_connection_stats(
615                        server_id,
616                        self.connection_id,
617                        Some(n as u64),
618                        None,
619                        Some(1),
620                        None,
621                    )
622                    .await;
623            }
624
625            if buffer.len() > MAX_PENDING_FRAME_BYTES {
626                conn.log.error(format!(
627                    "Redis client {} exceeded the {} byte frame buffer limit without completing a frame; closing",
628                    self.connection_id, MAX_PENDING_FRAME_BYTES
629                ));
630                self.refuse(&conn, "ERR Protocol error: invalid multibulk length")
631                    .await;
632                return Ok(());
633            }
634
635            // Try to decode RESP frames
636            let mut offset = 0;
637            while offset < buffer.len() {
638                // The frame's shape is walked iteratively before `decode` sees it, because
639                // `decode` recurses once per nested array with no limit and a stack overflow
640                // aborts the whole process (`src/utils/resp.rs`). Only a frame the scan has
641                // seen end is decoded.
642                match scan_resp2_frame(&buffer[offset..], &RESP_LIMITS) {
643                    RespScan::Complete { .. } => {}
644                    RespScan::Incomplete => break,
645                    RespScan::TooDeep { limit } => {
646                        conn.log.warn(format!(
647                            "Redis connection {} decision=fail_closed_resp_too_deep: frame nests \
648                             arrays deeper than {} levels; refusing and closing",
649                            self.connection_id, limit
650                        ));
651                        self.refuse(&conn, "ERR Protocol error: nesting too deep")
652                            .await;
653                        return Ok(());
654                    }
655                    RespScan::TooLong(kind) => {
656                        let (what, reply) = match kind {
657                            RespLength::Aggregate => (
658                                "array length",
659                                "ERR Protocol error: invalid multibulk length",
660                            ),
661                            RespLength::Bulk => {
662                                ("bulk length", "ERR Protocol error: invalid bulk length")
663                            }
664                        };
665                        conn.log.warn(format!(
666                            "Redis connection {} decision=fail_closed_resp_too_long: declared {} \
667                             exceeds the frame limit; refusing and closing",
668                            self.connection_id, what
669                        ));
670                        self.refuse(&conn, reply).await;
671                        return Ok(());
672                    }
673                    RespScan::Malformed => {
674                        // `decode` would reject the same byte; closed without a reply, as an
675                        // undecodable frame always is.
676                        error!(
677                            "Redis connection {} sent a frame that is not RESP2; closing",
678                            self.connection_id
679                        );
680                        return Err(anyhow::anyhow!("undecodable RESP frame"));
681                    }
682                }
683                match decode(&buffer[offset..]) {
684                    Ok(Some((frame, consumed))) => {
685                        // From here on the peer is an established client, so the longer idle
686                        // bound applies to every later read.
687                        answered_one = true;
688                        trace!("Redis frame: {:?}", frame);
689                        offset += consumed;
690                        match self.handle_frame(&mut conn, &frame).await? {
691                            Flow::Continue => {}
692                            Flow::Close => return Ok(()),
693                        }
694                    }
695                    Ok(None) => {
696                        // Need more data
697                        break;
698                    }
699                    Err(e) => {
700                        // Invalid frame
701                        error!("Redis decode error: {:?}", e);
702                        return Err(e.into());
703                    }
704                }
705            }
706
707            // Remove processed bytes from buffer
708            buffer.drain(..offset);
709        }
710    }
711
712    /// One decoded command, from routing to the reply.
713    ///
714    /// The order is Redis's own (`processCommand`): a cluster redirection first, then the
715    /// subscribed-mode restriction, then the commands this server answers itself — protocol
716    /// negotiation, connection state, pub/sub mechanics, cluster introspection — and
717    /// everything else to the model.
718    async fn handle_frame(&self, conn: &mut Conn<'_>, frame: &Frame) -> Result<Flow> {
719        let args = frame_args(frame);
720        let name = args
721            .first()
722            .map(|a| String::from_utf8_lossy(a).to_ascii_uppercase())
723            .unwrap_or_default();
724        let conn_key = self.connection_id.as_u32();
725        let proto = conn.protocol.protocol_version();
726        // `ASKING` holds for exactly the next command.
727        let asking = std::mem::take(&mut conn.asking);
728
729        // Cluster redirection, before anything else looks at the command.
730        // The topology lock is a std mutex and is released before anything is awaited.
731        let route = self.shared.cluster().map(|cluster| {
732            let slots: Vec<u16> = slot_keys(&args).iter().map(|k| key_hash_slot(k)).collect();
733            cluster.route(&slots, asking)
734        });
735        if let Some(route) = route {
736            if let Some(error) = route.error() {
737                debug!(
738                    "Redis connection {} decision=cluster_redirect command={} {}",
739                    self.connection_id, name, error
740                );
741                self.write_reply(conn, encode_error(&error)).await?;
742                return Ok(Flow::Continue);
743            }
744        }
745
746        // RESP2 subscribed mode: only the pub/sub family, PING, QUIT and RESET.
747        if proto == 2
748            && self.shared.hub.subscription_count(conn_key) > 0
749            && !SUBSCRIBED_MODE_COMMANDS.contains(&name.as_str())
750        {
751            self.write_reply(
752                conn,
753                encode_error(&format!(
754                    "ERR Can't execute '{}': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context",
755                    name.to_ascii_lowercase()
756                )),
757            )
758            .await?;
759            return Ok(Flow::Continue);
760        }
761
762        let command_str = frame_to_command_string(frame);
763        // Truncated for the log and the status channel only - the event below still carries
764        // the whole command, because that is what the model has to answer. A single 64 MB bulk
765        // string would otherwise be copied verbatim into the *unbounded* status channel on
766        // every frame.
767        let command_for_log = crate::utils::truncate_for_log(&command_str, 512);
768        conn.log
769            .debug(format!("Redis command: {}", command_for_log));
770        let subscribed_resp2 = proto == 2 && self.shared.hub.subscription_count(conn_key) > 0;
771
772        match name.as_str() {
773            "HELLO" => {
774                return self
775                    .hello(conn, frame, &command_str, &command_for_log)
776                    .await
777            }
778            "RESET" => {
779                // Connection state back to a fresh connection's: no subscriptions (dropped
780                // without confirmations, as Redis does), RESP2, no ASKING.
781                self.shared.hub.remove_connection(conn_key);
782                conn.protocol.set_protocol_version(2);
783                self.write_reply(conn, b"+RESET\r\n".to_vec()).await?;
784                return Ok(Flow::Continue);
785            }
786            "PING" if subscribed_resp2 => {
787                if args.len() > 2 {
788                    self.write_reply(conn, encode_error(&wrong_arity("ping")))
789                        .await?;
790                } else {
791                    let payload = args.get(1).cloned().unwrap_or_default();
792                    let reply = Resp::Array(vec![Resp::bulk("pong"), Resp::Bulk(payload)]);
793                    self.write_reply(conn, reply.encode(2)).await?;
794                }
795                return Ok(Flow::Continue);
796            }
797            "QUIT" if subscribed_resp2 => {
798                self.write_reply(conn, b"+OK\r\n".to_vec()).await?;
799                let _ = conn.write_half.lock().await.shutdown().await;
800                return Ok(Flow::Close);
801            }
802            "SUBSCRIBE" | "PSUBSCRIBE" | "SSUBSCRIBE" => {
803                let kind = match name.as_str() {
804                    "SUBSCRIBE" => Kind::Channel,
805                    "PSUBSCRIBE" => Kind::Pattern,
806                    _ => Kind::Shard,
807                };
808                return self
809                    .subscribe(conn, kind, &name, &args, &command_for_log)
810                    .await;
811            }
812            "UNSUBSCRIBE" | "PUNSUBSCRIBE" | "SUNSUBSCRIBE" => {
813                let kind = match name.as_str() {
814                    "UNSUBSCRIBE" => Kind::Channel,
815                    "PUNSUBSCRIBE" => Kind::Pattern,
816                    _ => Kind::Shard,
817                };
818                self.shared
819                    .hub
820                    .unsubscribe(conn_key, &conn.outbox, proto, kind, &args[1..]);
821                // A barrier: wait until the confirmations are on the socket.
822                self.write_reply(conn, Vec::new()).await?;
823                return Ok(Flow::Continue);
824            }
825            "PUBLISH" | "SPUBLISH" => {
826                return self.publish(conn, &name, &args, &command_for_log).await;
827            }
828            "PUBSUB" => {
829                let reply = self.pubsub_command(&args, proto);
830                self.write_reply(conn, reply).await?;
831                return Ok(Flow::Continue);
832            }
833            _ => {}
834        }
835
836        // Cluster-mode commands this server answers from its topology.
837        if self.shared.cluster.is_some() {
838            if let Some(reply) = self.cluster_command(conn, &name, &args, proto) {
839                self.write_reply(conn, reply).await?;
840                return Ok(Flow::Continue);
841            }
842        }
843
844        let event = Event::new(
845            &REDIS_COMMAND_EVENT,
846            serde_json::json!({
847                "command": command_str,
848                "protocol": proto,
849            }),
850        );
851        let answer = self.ask_model(conn, &event, &command_for_log).await;
852        if answer.accept || answer.deliver {
853            warn!(
854                "Redis connection {}: redis_accept_subscription / redis_deliver_message only \
855                 answer redis_subscribe / redis_publish events; ignored for '{}'",
856                self.connection_id, command_for_log
857            );
858        }
859        let mut response = answer.output;
860        if response.is_empty() && !answer.close {
861            // Redis is strictly request/response: a command with no reply hangs the client
862            // until its own timeout. `decision=` tags mirror `src/server/radius/`: on the wire
863            // a refusal, an outage and a model that answered with nothing usable all look like
864            // "-ERR something", so only the log can tell them apart.
865            warn!(
866                "Redis connection {} decision=fail_closed_no_action command='{}': \
867                 no response action produced, replying with an error",
868                self.connection_id, command_for_log
869            );
870            response = encode_error("ERR no response produced for this command");
871        }
872        if !response.is_empty() {
873            self.write_reply(conn, response).await?;
874        }
875        if answer.close {
876            // The peer command task holds a clone of the write half until its channel
877            // closes; half-close now so the client sees EOF at once.
878            let _ = conn.write_half.lock().await.shutdown().await;
879            return Ok(Flow::Close);
880        }
881        Ok(Flow::Continue)
882    }
883
884    /// Ask the model about one event and fold its answer. A failed call is answered here, fail
885    /// closed: `-LOADING` when the backend is saturated, `-ERR` otherwise, never a fabricated
886    /// success.
887    async fn ask_model(
888        &self,
889        conn: &Conn<'_>,
890        event: &Event,
891        command_for_log: &str,
892    ) -> ModelAnswer {
893        let server_id = self
894            .server_id
895            .unwrap_or_else(|| crate::state::ServerId::new(0));
896        let llm_result = call_llm(
897            &self.llm_client,
898            &self.app_state,
899            server_id,
900            Some(self.connection_id),
901            event,
902            conn.protocol.as_ref(),
903        )
904        .await;
905
906        let mut answer = ModelAnswer::default();
907        match llm_result {
908            Ok(execution_result) => {
909                // The actions arrive already RESP-encoded as `ActionResult::Output` — the
910                // encoding lives once, in `actions.rs::execute_action`, shared with peer
911                // injection.
912                let mut stack: Vec<ActionResult> = execution_result.protocol_results;
913                stack.reverse();
914                while let Some(result) = stack.pop() {
915                    match result {
916                        ActionResult::Output(bytes) => answer.output.extend_from_slice(&bytes),
917                        ActionResult::Multiple(items) => {
918                            for inner in items.into_iter().rev() {
919                                stack.push(inner);
920                            }
921                        }
922                        ActionResult::CloseConnection => {
923                            debug!("Redis closing connection");
924                            answer.close = true;
925                        }
926                        ActionResult::Custom { name, .. } if name == ACCEPT_SUBSCRIPTION => {
927                            answer.accept = true;
928                        }
929                        ActionResult::Custom { name, .. } if name == DELIVER_MESSAGE => {
930                            answer.deliver = true;
931                        }
932                        ActionResult::Custom { name, .. } => {
933                            // No other Redis action returns Custom; one reappearing would
934                            // silently write nothing, so make that loud.
935                            warn!(
936                                "Redis: action result '{}' is Custom, not Output; client will receive nothing for it",
937                                name
938                            );
939                        }
940                        _ => {
941                            // Other action results are informational
942                        }
943                    }
944                }
945            }
946            Err(e) => {
947                // A RESP error is the only thing a client can do anything with here, and
948                // `redis-rs`, `jedis` and `redis-py` all surface it as an exception rather
949                // than a value - so it cannot be mistaken for a reply to the command.
950                //
951                // `-LOADING` when the backend is merely saturated: clients already know that
952                // prefix means "not ready yet, retry", and several retry it automatically.
953                // Anything else is `-ERR`.
954                let overloaded = crate::llm::is_overload_error(&e);
955                let message = redis_error_message(&e, overloaded);
956                let decision = if overloaded {
957                    "fail_closed_llm_overloaded"
958                } else {
959                    "fail_closed_llm_error"
960                };
961                conn.log.warn(format!(
962                    "Redis connection {} decision={} command='{}' replying -{}: {}",
963                    self.connection_id, decision, command_for_log, message, e
964                ));
965                answer.output = encode_error(&message);
966                answer.failed = true;
967            }
968        }
969        answer
970    }
971
972    /// HELLO is protocol negotiation and is answered here. With AUTH it also carries
973    /// credentials, which only the handler can judge, so it goes to the model like AUTH does
974    /// and the switch waits on its answer: anything but an error accepts.
975    async fn hello(
976        &self,
977        conn: &mut Conn<'_>,
978        frame: &Frame,
979        command_str: &str,
980        command_for_log: &str,
981    ) -> Result<Flow> {
982        let args = hello_args(frame).unwrap_or_default();
983        let mode = if self.shared.cluster.is_some() {
984            "cluster"
985        } else {
986            "standalone"
987        };
988        let id = u64::from(self.connection_id.as_u32());
989        let hello = match resp3::parse_hello(&args) {
990            Err(message) => {
991                self.write_reply(conn, encode_error(&message)).await?;
992                return Ok(Flow::Continue);
993            }
994            Ok(hello) => hello,
995        };
996        let target = hello.protover.unwrap_or(conn.protocol.protocol_version());
997        if hello.auth.is_none() {
998            conn.protocol.set_protocol_version(target);
999            conn.log.info(format!(
1000                "Redis connection {} speaks RESP{} (HELLO)",
1001                self.connection_id, target
1002            ));
1003            self.write_reply(conn, resp3::hello_reply(target, id, mode))
1004                .await?;
1005            return Ok(Flow::Continue);
1006        }
1007        let event = Event::new(
1008            &REDIS_COMMAND_EVENT,
1009            serde_json::json!({
1010                "command": command_str,
1011                "protocol": conn.protocol.protocol_version(),
1012            }),
1013        );
1014        let answer = self.ask_model(conn, &event, command_for_log).await;
1015        let response = if answer.output.starts_with(b"-") {
1016            conn.log.info(format!(
1017                "Redis connection {} decision=model_reject: HELLO with AUTH refused; staying on \
1018                 RESP{}",
1019                self.connection_id,
1020                conn.protocol.protocol_version()
1021            ));
1022            answer.output
1023        } else if answer.output.is_empty() && !answer.close {
1024            warn!(
1025                "Redis connection {} decision=fail_closed_no_action command='{}': no answer to \
1026                 HELLO with AUTH; refusing",
1027                self.connection_id, command_for_log
1028            );
1029            encode_error("ERR no response produced for this command")
1030        } else {
1031            conn.protocol.set_protocol_version(target);
1032            resp3::hello_reply(target, id, mode)
1033        };
1034        self.write_reply(conn, response).await?;
1035        if answer.close {
1036            let _ = conn.write_half.lock().await.shutdown().await;
1037            return Ok(Flow::Close);
1038        }
1039        Ok(Flow::Continue)
1040    }
1041
1042    /// SUBSCRIBE, PSUBSCRIBE, SSUBSCRIBE: bounds in Rust, then the model decides whether the
1043    /// subscription is allowed, then Rust registers it and confirms each channel.
1044    async fn subscribe(
1045        &self,
1046        conn: &mut Conn<'_>,
1047        kind: Kind,
1048        name: &str,
1049        args: &[Vec<u8>],
1050        command_for_log: &str,
1051    ) -> Result<Flow> {
1052        let conn_key = self.connection_id.as_u32();
1053        if args.len() < 2 {
1054            self.write_reply(conn, encode_error(&wrong_arity(name)))
1055                .await?;
1056            return Ok(Flow::Continue);
1057        }
1058        let names = &args[1..];
1059        // A request that can never be honoured costs no model call.
1060        if let Err(message) = self.shared.hub.check_subscribe(conn_key, kind, names) {
1061            conn.log.warn(format!(
1062                "Redis connection {} decision=fail_closed_subscription_bound: {}",
1063                self.connection_id, message
1064            ));
1065            self.write_reply(conn, encode_error(&message)).await?;
1066            return Ok(Flow::Continue);
1067        }
1068        let event = Event::new(
1069            &REDIS_SUBSCRIBE_EVENT,
1070            serde_json::json!({
1071                "command": name,
1072                "channels": names.iter().map(|n| String::from_utf8_lossy(n).to_string()).collect::<Vec<_>>(),
1073                "subscriptions": self.shared.hub.subscription_count(conn_key),
1074                "protocol": conn.protocol.protocol_version(),
1075            }),
1076        );
1077        let answer = self.ask_model(conn, &event, command_for_log).await;
1078        if answer.accept && !answer.failed {
1079            if !answer.output.is_empty() {
1080                warn!(
1081                    "Redis connection {}: the subscription was accepted, so the reply verbs \
1082                     sent alongside redis_accept_subscription are dropped (the confirmations \
1083                     are the reply)",
1084                    self.connection_id
1085                );
1086            }
1087            match self.shared.hub.subscribe(
1088                conn_key,
1089                &conn.outbox,
1090                &conn.protocol.protover_cell(),
1091                kind,
1092                names,
1093            ) {
1094                Ok(()) => {
1095                    debug!(
1096                        "Redis connection {} decision=model_accept command='{}'",
1097                        self.connection_id, command_for_log
1098                    );
1099                    // A barrier: the confirmations are on the socket before the next command.
1100                    self.write_reply(conn, Vec::new()).await?;
1101                }
1102                Err(message) => {
1103                    conn.log.warn(format!(
1104                        "Redis connection {} decision=fail_closed_subscription_bound: {}",
1105                        self.connection_id, message
1106                    ));
1107                    self.write_reply(conn, encode_error(&message)).await?;
1108                }
1109            }
1110        } else if !answer.output.is_empty() {
1111            if !answer.failed {
1112                debug!(
1113                    "Redis connection {} decision=model_reject command='{}': not subscribed",
1114                    self.connection_id, command_for_log
1115                );
1116            }
1117            self.write_reply(conn, answer.output).await?;
1118        } else if !answer.close {
1119            warn!(
1120                "Redis connection {} decision=fail_closed_no_action command='{}': no decision \
1121                 on the subscription; refusing it",
1122                self.connection_id, command_for_log
1123            );
1124            self.write_reply(
1125                conn,
1126                encode_error("ERR no response produced for this command"),
1127            )
1128            .await?;
1129        }
1130        if answer.close {
1131            let _ = conn.write_half.lock().await.shutdown().await;
1132            return Ok(Flow::Close);
1133        }
1134        Ok(Flow::Continue)
1135    }
1136
1137    /// PUBLISH, SPUBLISH: the bound in Rust, then the model decides whether the message is
1138    /// delivered and what the publisher is told.
1139    async fn publish(
1140        &self,
1141        conn: &mut Conn<'_>,
1142        name: &str,
1143        args: &[Vec<u8>],
1144        command_for_log: &str,
1145    ) -> Result<Flow> {
1146        if args.len() != 3 {
1147            self.write_reply(conn, encode_error(&wrong_arity(name)))
1148                .await?;
1149            return Ok(Flow::Continue);
1150        }
1151        let shard = name == "SPUBLISH";
1152        let (channel, message) = (&args[1], &args[2]);
1153        let kind = if shard { Kind::Shard } else { Kind::Channel };
1154        if let Err(error) =
1155            pubsub::check_name(kind, channel).and_then(|()| pubsub::check_message(message))
1156        {
1157            conn.log.warn(format!(
1158                "Redis connection {} decision=fail_closed_message_bound: {}",
1159                self.connection_id, error
1160            ));
1161            self.write_reply(conn, encode_error(&error)).await?;
1162            return Ok(Flow::Continue);
1163        }
1164        let event = Event::new(
1165            &REDIS_PUBLISH_EVENT,
1166            serde_json::json!({
1167                "command": name,
1168                "channel": String::from_utf8_lossy(channel),
1169                "message": String::from_utf8_lossy(message),
1170                "receivers": self.shared.hub.receivers(channel, shard),
1171                "protocol": conn.protocol.protocol_version(),
1172            }),
1173        );
1174        let answer = self.ask_model(conn, &event, command_for_log).await;
1175        if answer.deliver && !answer.failed {
1176            if !answer.output.is_empty() {
1177                warn!(
1178                    "Redis connection {}: the message was delivered, so the reply verbs sent \
1179                     alongside redis_deliver_message are dropped (the receiver count is the \
1180                     reply)",
1181                    self.connection_id
1182                );
1183            }
1184            let receivers = self.shared.hub.publish(channel, message, shard);
1185            debug!(
1186                "Redis connection {} decision=model_deliver command='{}' receivers={}",
1187                self.connection_id, command_for_log, receivers
1188            );
1189            self.write_reply(conn, encode_integer(receivers as i64))
1190                .await?;
1191        } else if !answer.output.is_empty() {
1192            if !answer.failed {
1193                debug!(
1194                    "Redis connection {} decision=model_withhold command='{}': answered without \
1195                     delivering",
1196                    self.connection_id, command_for_log
1197                );
1198            }
1199            self.write_reply(conn, answer.output).await?;
1200        } else if !answer.close {
1201            warn!(
1202                "Redis connection {} decision=fail_closed_no_action command='{}': no decision \
1203                 on the message; nothing delivered",
1204                self.connection_id, command_for_log
1205            );
1206            self.write_reply(
1207                conn,
1208                encode_error("ERR no response produced for this command"),
1209            )
1210            .await?;
1211        }
1212        if answer.close {
1213            let _ = conn.write_half.lock().await.shutdown().await;
1214            return Ok(Flow::Close);
1215        }
1216        Ok(Flow::Continue)
1217    }
1218
1219    /// `PUBSUB CHANNELS | NUMSUB | NUMPAT | SHARDCHANNELS | SHARDNUMSUB`, from the hub.
1220    fn pubsub_command(&self, args: &[Vec<u8>], proto: u8) -> Vec<u8> {
1221        let sub = args
1222            .get(1)
1223            .map(|a| String::from_utf8_lossy(a).to_ascii_uppercase())
1224            .unwrap_or_default();
1225        let hub = &self.shared.hub;
1226        let channels = |shard: bool| -> Vec<u8> {
1227            if args.len() > 3 {
1228                return encode_error(&wrong_arity(&format!("pubsub|{}", sub.to_lowercase())));
1229            }
1230            let pattern = args.get(2).map(|p| p.as_slice());
1231            if let Some(p) = pattern {
1232                if let Err(e) = pubsub::check_name(Kind::Pattern, p) {
1233                    return encode_error(&e);
1234                }
1235            }
1236            Resp::Array(
1237                hub.active_channels(shard, pattern)
1238                    .into_iter()
1239                    .map(Resp::Bulk)
1240                    .collect(),
1241            )
1242            .encode(proto)
1243        };
1244        let numsub = |shard: bool| -> Vec<u8> {
1245            Resp::Map(
1246                hub.numsub(shard, &args[2..])
1247                    .into_iter()
1248                    .map(|(c, n)| (Resp::Bulk(c), Resp::Int(n as i64)))
1249                    .collect(),
1250            )
1251            .encode(proto)
1252        };
1253        match sub.as_str() {
1254            "CHANNELS" => channels(false),
1255            "SHARDCHANNELS" => channels(true),
1256            "NUMSUB" => numsub(false),
1257            "SHARDNUMSUB" => numsub(true),
1258            "NUMPAT" if args.len() == 2 => encode_integer(hub.numpat() as i64),
1259            "NUMPAT" => encode_error(&wrong_arity("pubsub|numpat")),
1260            "" => encode_error(&wrong_arity("pubsub")),
1261            other => encode_error(&format!(
1262                "ERR unknown subcommand '{}'. Try PUBSUB HELP.",
1263                crate::utils::truncate_for_log(&other.to_lowercase(), 64)
1264            )),
1265        }
1266    }
1267
1268    /// The cluster commands answered from the topology. `None` sends the command on to the
1269    /// model (`CLUSTER SETSLOT`, `COUNTKEYSINSLOT`, `GETKEYSINSLOT` and the rest need policy
1270    /// or a keyspace this server does not have).
1271    fn cluster_command(
1272        &self,
1273        conn: &mut Conn<'_>,
1274        name: &str,
1275        args: &[Vec<u8>],
1276        proto: u8,
1277    ) -> Option<Vec<u8>> {
1278        match name {
1279            "ASKING" => {
1280                conn.asking = true;
1281                return Some(b"+OK\r\n".to_vec());
1282            }
1283            "READONLY" | "READWRITE" => return Some(b"+OK\r\n".to_vec()),
1284            "CLUSTER" => {}
1285            _ => return None,
1286        }
1287        let sub = args
1288            .get(1)
1289            .map(|a| String::from_utf8_lossy(a).to_ascii_uppercase())
1290            .unwrap_or_default();
1291        let cluster = self.shared.cluster()?;
1292        let reply = match (sub.as_str(), args.len()) {
1293            ("SLOTS", 2) => cluster.slots_reply().encode(proto),
1294            ("SHARDS", 2) => cluster.shards_reply().encode(proto),
1295            ("NODES", 2) => cluster.nodes_reply().encode(proto),
1296            ("INFO", 2) => cluster.info_reply().encode(proto),
1297            ("MYID", 2) => Resp::bulk(&cluster.me().id).encode(proto),
1298            ("KEYSLOT", 3) => encode_integer(i64::from(key_hash_slot(&args[2]))),
1299            ("SLOTS" | "SHARDS" | "NODES" | "INFO" | "MYID" | "KEYSLOT", _) => encode_error(
1300                &wrong_arity(&format!("cluster|{}", sub.to_ascii_lowercase())),
1301            ),
1302            _ => return None,
1303        };
1304        Some(reply)
1305    }
1306}
1307
1308/// Every argument of a command frame as raw bytes (the command name first).
1309fn frame_args(frame: &Frame) -> Vec<Vec<u8>> {
1310    match frame {
1311        Frame::Array(parts) => parts
1312            .iter()
1313            .map(|f| match f {
1314                Frame::BulkString(b) | Frame::SimpleString(b) => b.clone(),
1315                Frame::Integer(i) => i.to_string().into_bytes(),
1316                other => format!("{other:?}").into_bytes(),
1317            })
1318            .collect(),
1319        other => vec![format!("{other:?}").into_bytes()],
1320    }
1321}
1322
1323/// The arguments of a `HELLO` command, or `None` for any other command.
1324fn hello_args(frame: &Frame) -> Option<Vec<String>> {
1325    let Frame::Array(parts) = frame else {
1326        return None;
1327    };
1328    let text = |f: &Frame| match f {
1329        Frame::BulkString(b) | Frame::SimpleString(b) => String::from_utf8_lossy(b).to_string(),
1330        Frame::Integer(i) => i.to_string(),
1331        other => format!("{other:?}"),
1332    };
1333    let first = parts.first().map(text)?;
1334    first
1335        .eq_ignore_ascii_case("HELLO")
1336        .then(|| parts[1..].iter().map(text).collect())
1337}
1338
1339/// Convert RESP frame to command string for display
1340fn frame_to_command_string(frame: &Frame) -> String {
1341    match frame {
1342        Frame::Array(frames) => {
1343            let parts: Vec<String> = frames
1344                .iter()
1345                .map(|f| match f {
1346                    Frame::BulkString(bytes) => String::from_utf8_lossy(bytes).to_string(),
1347                    Frame::SimpleString(bytes) => String::from_utf8_lossy(bytes).to_string(),
1348                    Frame::Integer(i) => i.to_string(),
1349                    _ => format!("{:?}", f),
1350                })
1351                .collect();
1352            parts.join(" ")
1353        }
1354        _ => format!("{:?}", frame),
1355    }
1356}
1357
1358/// The RESP simple-error payload to send when the LLM backend fails.
1359///
1360/// The text is a category, never the error itself (`crate::utils::wire_failure`). That also
1361/// settles a framing hazard: a RESP simple error is CRLF-terminated with no length prefix, so
1362/// a newline anywhere in the text ends the frame early and the remainder is parsed as the
1363/// *next* reply - and backend error strings are routinely multi-line (a timeout with a URL, a
1364/// serde error with a snippet), so it would have desynchronised the connection for good.
1365fn redis_error_message(err: &anyhow::Error, overloaded: bool) -> String {
1366    let text = crate::utils::WireFailure::classify(err).prefixed_text();
1367    if overloaded {
1368        format!("LOADING {text}")
1369    } else {
1370        format!("ERR {text}")
1371    }
1372}