Skip to main content

thunder/server/
listener.rs

1//! The accept loop, per-connection shape and hot path (SPEC-004 §1/§1b).
2//!
3//! Hot path transplanted from the Synap listener (§7 baseline analysis,
4//! T-027): `set_nodelay(true)` on accept (SRV-008), a dedicated writer task
5//! owning a `BufWriter` over the write half behind an mpsc channel
6//! (SRV-002), and the drain-then-flush pattern — write one response, drain
7//! every already-queued response via `try_recv`, then flush once, so a
8//! pipelined burst coalesces into one syscall (SRV-006, +23% committed
9//! in-family evidence). Exactly one serialization per response: the frame
10//! is encoded once, written, and its length is the out-bytes metric;
11//! request in-bytes come from the decoder's frame size (SRV-007).
12
13use std::future::Future;
14use std::io;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use crate::wire::config::{Config, ErrorConvention, Handshake, HelloStyle, PushPolicy};
20use crate::wire::{encode_frame, read_request_with_limit, Request, Response, Value, PUSH_ID};
21use tokio::io::{AsyncWriteExt, BufReader, BufWriter};
22use tokio::net::{TcpListener, TcpStream};
23use tokio::sync::{mpsc, watch, Semaphore};
24
25use crate::server::dispatch::{AuthError, Credentials, Dispatch};
26use crate::server::errors::{format_bracket_code, format_err, NOAUTH, WRONGPASS};
27use crate::server::metrics::{Metrics, MetricsSnapshot};
28use crate::server::session::{PushSender, Session, WriteJob};
29
30/// Wire protocol version advertised in HELLO replies (WIRE-004: v1,
31/// frozen — adding commands or profiles never bumps it).
32const PROTO_VERSION: i64 = 1;
33
34/// Pre-auth allowlist under `Handshake::AuthCommand` (SRV-011, PRO-001).
35const PRE_AUTH_COMMANDS: &[&str] = &["PING", "HELLO", "AUTH", "QUIT"];
36
37/// Depth of the per-connection writer queue (the family's proven value).
38const WRITER_QUEUE_DEPTH: usize = 64;
39
40/// Server identity used by Thunder-built HELLO replies (SRV-014).
41#[derive(Debug, Clone)]
42pub struct ServerInfo {
43    /// `server` field of the metadata-shape HELLO reply (`HelloStyle::ArgLess`).
44    pub name: String,
45    /// `version` field of the metadata-shape HELLO reply.
46    pub version: String,
47}
48
49/// Listener configuration. Family posture keeps binds loopback/private by
50/// default (SRV-040 guidance).
51#[derive(Debug, Clone)]
52pub struct ListenerConfig {
53    /// Address to bind. Port `0` picks an ephemeral port — read it back
54    /// via [`ListenerHandle::local_addr`].
55    pub addr: SocketAddr,
56    /// Per-read idle timeout (slow-loris resistance, SRV-009). Zero
57    /// disables, matching each product's current posture.
58    pub idle_timeout: Duration,
59    /// Commands slower than this bump `slow_commands_total` (SRV-030).
60    /// Zero disables the counter.
61    pub slow_threshold: Duration,
62    /// Whether this **deployment** enforces credentials (SRV-011).
63    ///
64    /// This is policy, not protocol: the profile fixes the handshake
65    /// *shape* (does the client lead with `HELLO`? does it authenticate via
66    /// `AUTH`?), while this flag decides whether the server actually refuses
67    /// un-credentialed sessions. Both family products that authenticate on
68    /// the RPC path expose exactly this toggle — Nexus's `auth_required` and
69    /// Synap's `require_auth` — and an open Synap deployment is the reason
70    /// it must live here rather than in the profile.
71    ///
72    /// Ignored under [`Handshake::None`], which has no gate at all. Defaults
73    /// to `true`: a deployment opens up only by saying so.
74    pub auth_required: bool,
75    /// Optional TLS (SRV-040 / SPEC-008 CAN-020). `Some` turns TLS on for this
76    /// deployment; `None` (the default) keeps it plaintext. Requires the crate's
77    /// `tls` feature — a listener configured with TLS but built without it fails
78    /// to start rather than silently serving plaintext.
79    pub tls: Option<crate::tls::ServerTls>,
80}
81
82impl ListenerConfig {
83    /// Config for `addr` with the defaults: no idle timeout, 1000 ms slow
84    /// threshold, credentials enforced, plaintext.
85    pub fn new(addr: SocketAddr) -> Self {
86        Self {
87            addr,
88            idle_timeout: Duration::ZERO,
89            slow_threshold: Duration::from_millis(1000),
90            auth_required: true,
91            tls: None,
92        }
93    }
94
95    /// Turn TLS on for this deployment (SRV-040): the listener wraps every
96    /// accepted stream in `tokio-rustls` before the first Thunder frame.
97    pub fn with_tls(mut self, tls: crate::tls::ServerTls) -> Self {
98        self.tls = Some(tls);
99        self
100    }
101
102    /// Serve un-credentialed sessions — the `auth_required = false` /
103    /// `require_auth = false` posture (e.g. an open Synap deployment).
104    ///
105    /// The handshake shape is unchanged: a client may still send `AUTH`, and
106    /// it still succeeds or fails on its own merits; nothing is *required*.
107    pub fn open(mut self) -> Self {
108        self.auth_required = false;
109        self
110    }
111}
112
113impl Default for ListenerConfig {
114    /// Loopback on an ephemeral port with the standard defaults.
115    fn default() -> Self {
116        Self::new(SocketAddr::from(([127, 0, 0, 1], 0)))
117    }
118}
119
120/// Everything a connection task needs, shared once per listener.
121struct ConnShared<D> {
122    dispatch: Arc<D>,
123    profile: Config,
124    info: ServerInfo,
125    idle_timeout: Duration,
126    slow_threshold: Duration,
127    auth_required: bool,
128    metrics: Arc<Metrics>,
129    /// Built once from `ListenerConfig.tls`; every accepted stream is wrapped
130    /// through it before the first frame (SRV-040).
131    #[cfg(feature = "tls")]
132    acceptor: Option<tokio_rustls::TlsAcceptor>,
133}
134
135/// Handle to a running listener (SRV-001).
136///
137/// [`stop`](Self::stop) performs the graceful shutdown: the accept loop
138/// ends, every connection finishes its in-flight requests, drains its
139/// writer and closes; `stop` resolves when the last connection is gone.
140/// Dropping the handle signals the same shutdown without waiting.
141#[derive(Debug)]
142pub struct ListenerHandle {
143    local_addr: SocketAddr,
144    shutdown: watch::Sender<bool>,
145    metrics: Arc<Metrics>,
146    done: Option<mpsc::Receiver<()>>,
147}
148
149impl ListenerHandle {
150    /// The bound address (resolves port `0` binds).
151    pub fn local_addr(&self) -> SocketAddr {
152        self.local_addr
153    }
154
155    /// Point-in-time metrics (SRV-030).
156    pub fn snapshot(&self) -> MetricsSnapshot {
157        self.metrics.snapshot()
158    }
159
160    /// Graceful shutdown (SRV-001): stop accepting, let every connection
161    /// drain its in-flight responses, and resolve once all of them closed.
162    pub async fn stop(mut self) {
163        let _ = self.shutdown.send(true);
164        if let Some(mut done) = self.done.take() {
165            // `recv` yields `None` once the accept loop and every
166            // connection task dropped their guard senders.
167            let _ = done.recv().await;
168        }
169    }
170}
171
172impl Drop for ListenerHandle {
173    fn drop(&mut self) {
174        // Fire-and-forget shutdown; `stop()` is the waiting variant.
175        let _ = self.shutdown.send(true);
176    }
177}
178
179/// Bind `config.addr` and run the accept loop: one task per connection,
180/// graceful shutdown through the returned handle (SRV-001).
181pub async fn spawn_listener<D: Dispatch>(
182    dispatch: Arc<D>,
183    profile: Config,
184    info: ServerInfo,
185    config: ListenerConfig,
186) -> io::Result<ListenerHandle> {
187    // SRV-040: build the TLS acceptor before binding so a misconfigured cert
188    // fails fast. A TLS config without the `tls` feature is a hard error, not a
189    // silent downgrade to plaintext.
190    #[cfg(feature = "tls")]
191    let acceptor = match &config.tls {
192        Some(tls) => Some(
193            crate::tls::build_acceptor(tls)
194                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?,
195        ),
196        None => None,
197    };
198    #[cfg(not(feature = "tls"))]
199    if config.tls.is_some() {
200        return Err(io::Error::new(
201            io::ErrorKind::Unsupported,
202            "TLS is configured but the crate was built without the `tls` feature",
203        ));
204    }
205
206    let listener = TcpListener::bind(config.addr).await?;
207    let local_addr = listener.local_addr()?;
208    let metrics = Arc::new(Metrics::default());
209    let (shutdown_tx, shutdown_rx) = watch::channel(false);
210    let (done_tx, done_rx) = mpsc::channel::<()>(1);
211
212    let shared = Arc::new(ConnShared {
213        dispatch,
214        profile,
215        info,
216        idle_timeout: config.idle_timeout,
217        slow_threshold: config.slow_threshold,
218        auth_required: config.auth_required,
219        metrics: Arc::clone(&metrics),
220        #[cfg(feature = "tls")]
221        acceptor,
222    });
223
224    tokio::spawn(accept_loop(listener, shared, shutdown_rx, done_tx));
225
226    Ok(ListenerHandle {
227        local_addr,
228        shutdown: shutdown_tx,
229        metrics,
230        done: Some(done_rx),
231    })
232}
233
234/// Accept until shutdown; each connection runs in its own task (SRV-001).
235/// Accept errors are transient — they never end the loop (SRV-004 spirit:
236/// nothing a single socket does may kill the listener).
237async fn accept_loop<D: Dispatch>(
238    listener: TcpListener,
239    shared: Arc<ConnShared<D>>,
240    shutdown: watch::Receiver<bool>,
241    done: mpsc::Sender<()>,
242) {
243    let mut accept_shutdown = shutdown.clone();
244    let mut next_conn_id: u64 = 1;
245    loop {
246        let accepted = tokio::select! {
247            _ = accept_shutdown.wait_for(|stop| *stop) => break,
248            accepted = listener.accept() => accepted,
249        };
250        let Ok((stream, _peer)) = accepted else {
251            continue;
252        };
253        let conn_id = next_conn_id;
254        next_conn_id = next_conn_id.wrapping_add(1);
255        let ctx = Arc::clone(&shared);
256        let conn_shutdown = shutdown.clone();
257        let done_guard = done.clone();
258        ctx.metrics.connection_opened();
259        tokio::spawn(async move {
260            handle_connection(stream, &ctx, conn_id, conn_shutdown).await;
261            ctx.metrics.connection_closed();
262            drop(done_guard);
263        });
264    }
265    // Dropping `listener` stops new connections; dropping `done` lets
266    // `stop()` resolve once every connection guard is gone.
267}
268
269/// One connection: split socket, writer task behind an mpsc channel
270/// (SRV-002), sequential read loop spawning one dispatch task per request
271/// bounded by the profile's `max_in_flight` semaphore (SRV-003).
272async fn handle_connection<D: Dispatch>(
273    stream: TcpStream,
274    ctx: &ConnShared<D>,
275    conn_id: u64,
276    shutdown: watch::Receiver<bool>,
277) {
278    // SRV-008: disable Nagle so length-prefixed replies are not held ~40 ms
279    // by the delayed-ACK interaction documented in the Synap listener.
280    let _ = stream.set_nodelay(true);
281
282    // SRV-040: when this deployment configured TLS, complete the TLS handshake
283    // before any Thunder frame. A TLS failure ends this connection only, never
284    // the listener (SRV-004). The plaintext path keeps the lock-free
285    // `into_split`; only the encrypted path pays `tokio::io::split`.
286    #[cfg(feature = "tls")]
287    if let Some(acceptor) = &ctx.acceptor {
288        // A TLS handshake failure ends this connection only (SRV-004).
289        if let Ok(tls) = acceptor.accept(stream).await {
290            let (read_half, write_half) = tokio::io::split(tls);
291            run_connection(
292                BufReader::new(read_half),
293                write_half,
294                ctx,
295                conn_id,
296                shutdown,
297            )
298            .await;
299        }
300        return;
301    }
302
303    let (read_half, write_half) = stream.into_split();
304    run_connection(
305        BufReader::new(read_half),
306        write_half,
307        ctx,
308        conn_id,
309        shutdown,
310    )
311    .await;
312}
313
314/// The connection loop over already-split, transport-agnostic halves — one
315/// monomorphization for plaintext (`OwnedReadHalf`/`OwnedWriteHalf`) and one for
316/// TLS. Splitting here keeps the hot plaintext path byte-identical and
317/// lock-free (SRV-002/003).
318async fn run_connection<D, R, W>(
319    mut reader: BufReader<R>,
320    write_half: W,
321    ctx: &ConnShared<D>,
322    conn_id: u64,
323    mut shutdown: watch::Receiver<bool>,
324) where
325    D: Dispatch,
326    R: tokio::io::AsyncRead + Unpin + Send,
327    W: tokio::io::AsyncWrite + Unpin + Send + 'static,
328{
329    let (tx, rx) = mpsc::channel::<WriteJob>(WRITER_QUEUE_DEPTH);
330    let write_task = tokio::spawn(writer_task(
331        BufWriter::new(write_half),
332        rx,
333        Arc::clone(&ctx.metrics),
334        ctx.slow_threshold,
335    ));
336
337    // SRV-013 / PRO-031: the typed push channel exists only under
338    // `push = Enabled`; `Reserved` profiles can never emit.
339    let push = match ctx.profile.push {
340        PushPolicy::Enabled => Some(PushSender::new(tx.clone())),
341        PushPolicy::Reserved => None,
342    };
343    // SRV-011: a session starts ungated when the profile has no handshake
344    // at all, or when this deployment does not require credentials
345    // (`auth_required = false` — Nexus's `auth_required`, Synap's
346    // `require_auth`). Shape is the profile's; enforcement is the
347    // deployment's, and conflating them is what left the `synap` profile
348    // unable to authenticate (BN-023).
349    let starts_authenticated =
350        matches!(ctx.profile.handshake, Handshake::None) || !ctx.auth_required;
351    let session = Arc::new(Session::new(conn_id, starts_authenticated, push));
352
353    let permits = ctx.profile.max_in_flight.clamp(1, u32::MAX as usize) as u32;
354    let in_flight = Arc::new(Semaphore::new(permits as usize));
355
356    let mut first_frame = true;
357    loop {
358        let read = tokio::select! {
359            _ = shutdown.wait_for(|stop| *stop) => break,
360            read = read_next(&mut reader, ctx.profile.max_frame_bytes, ctx.idle_timeout) => read,
361        };
362        // SRV-004: EOF, a decode error, an oversized length prefix
363        // (WIRE-020, rejected before any body allocation) or the idle
364        // timeout (SRV-009) ends this read loop — this connection only,
365        // never the listener.
366        let Ok((req, in_bytes)) = read else { break };
367
368        // SRV-013 / WIRE-005: client frames carrying PUSH_ID get a
369        // dedicated refusal; the connection stays usable.
370        if req.id == PUSH_ID {
371            let response = Response::err(req.id, push_refusal_error(&ctx.profile));
372            if !send_inline(&tx, response, in_bytes).await {
373                break;
374            }
375            continue;
376        }
377
378        // SRV-011 / PRO-030: `HelloMandatory` rejects a non-HELLO first
379        // frame with the profile's error convention and closes.
380        if first_frame {
381            first_frame = false;
382            // SPEC-008 handshake adoption signal: did this connection lead
383            // with a canonical HELLO, or a legacy first frame?
384            if req.command != "HELLO" {
385                ctx.metrics.record_non_hello_first_frame();
386            }
387            if matches!(ctx.profile.handshake, Handshake::HelloMandatory) && req.command != "HELLO"
388            {
389                let response = Response::err(req.id, hello_required_error(&ctx.profile));
390                let _ = send_inline(&tx, response, in_bytes).await;
391                break;
392            }
393        }
394
395        // Built-ins Thunder owns, handled inline so the auth flag is set
396        // before the next frame's gate check (the donor listeners
397        // serialize AUTH ahead of request tasks for the same reason).
398        match req.command.as_str() {
399            // SRV-014: HELLO replies are constructed by Thunder.
400            "HELLO" if !matches!(ctx.profile.hello_style, HelloStyle::NotUsed) => {
401                let response = handle_hello(ctx, &session, req.id, &req.args).await;
402                if !send_inline(&tx, response, in_bytes).await {
403                    break;
404                }
405                continue;
406            }
407            // SRV-012: Thunder parses, the product validates.
408            "AUTH" if matches!(ctx.profile.handshake, Handshake::AuthCommand) => {
409                let response = handle_auth(ctx, &session, req.id, &req.args).await;
410                if !send_inline(&tx, response, in_bytes).await {
411                    break;
412                }
413                continue;
414            }
415            // SRV-011 allowlist: PING answers pre-auth without product
416            // involvement; post-auth PING belongs to the product dispatch.
417            "PING" if !session.is_authenticated() => {
418                let response = builtin_ping(req.id, &req.args);
419                if !send_inline(&tx, response, in_bytes).await {
420                    break;
421                }
422                continue;
423            }
424            // AuthCommand semantics: acknowledge, then close after the write.
425            "QUIT" if matches!(ctx.profile.handshake, Handshake::AuthCommand) => {
426                let response = Response::ok(req.id, Value::Str("OK".to_owned()));
427                let _ = send_inline(&tx, response, in_bytes).await;
428                break;
429            }
430            _ => {}
431        }
432
433        // SRV-011: pre-auth gate per profile.
434        if !session.is_authenticated() {
435            match ctx.profile.handshake {
436                Handshake::None => {}
437                Handshake::AuthCommand => {
438                    if !PRE_AUTH_COMMANDS.contains(&req.command.as_str()) {
439                        let response = Response::err(req.id, NOAUTH);
440                        if !send_inline(&tx, response, in_bytes).await {
441                            break;
442                        }
443                        continue;
444                    }
445                    // Allowlisted command with no built-in under this
446                    // profile combination — falls through to dispatch.
447                }
448                Handshake::HelloMandatory => {
449                    let response = Response::err(req.id, hello_required_error(&ctx.profile));
450                    if !send_inline(&tx, response, in_bytes).await {
451                        break;
452                    }
453                    continue;
454                }
455            }
456        }
457
458        // SRV-003: every request takes an in-flight permit — excess requests
459        // wait right here (backpressure on the read loop), never refused. The
460        // permit bounds the *spawned* path below; on the fast path it is
461        // released as soon as the response is in hand.
462        let Ok(permit) = Arc::clone(&in_flight).acquire_owned().await else {
463            break;
464        };
465        let dispatch = Arc::clone(&ctx.dispatch);
466        let session = Arc::clone(&session);
467        let started = Instant::now();
468        let Request { id, command, args } = req;
469        // One dispatch future, owned so it can either finish inline or move to
470        // a task — `Box::pin` gives a single 'static Send future for both.
471        let mut fut = Box::pin(async move {
472            match dispatch.dispatch(&session, &command, args).await {
473                Ok(value) => Response::ok(id, value),
474                // SRV-005/021: the error string travels verbatim and the
475                // connection stays usable.
476                Err(message) => Response::err(id, message),
477            }
478        });
479        // SRV-006 fast path: poll the dispatch once. A handler that resolves
480        // without suspending — a cache hit, an in-memory read, the peers'
481        // inline model — is answered on the read loop with no task, erasing
482        // the spawn + scheduler cost that dominates small-payload throughput
483        // under concurrency (measured: +76% on point-echo depth16/4conns).
484        // A handler that suspends on real I/O returns Pending and is moved to
485        // a task, so it still never head-of-line blocks its connection
486        // (SRV-004). A synchronous panic is isolated exactly as the runtime
487        // isolates a task panic (SRV-005): the request is dropped, the
488        // connection lives.
489        // Scope the poll so the non-`Send` `Context` is dropped before any
490        // `.await` below — otherwise this connection future would not be
491        // `Send` and could not be spawned. `Poll<Response>` is `Send`.
492        let polled = {
493            let waker = std::task::Waker::noop();
494            let mut cx = std::task::Context::from_waker(waker);
495            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fut.as_mut().poll(&mut cx)))
496        };
497        match polled {
498            Ok(std::task::Poll::Ready(response)) => {
499                drop(fut);
500                drop(permit);
501                if !send_response(&tx, response, in_bytes, started.elapsed()).await {
502                    break;
503                }
504            }
505            Ok(std::task::Poll::Pending) => {
506                let tx = tx.clone();
507                tokio::spawn(async move {
508                    let response = fut.await;
509                    let _ = tx
510                        .send(WriteJob::Response {
511                            response,
512                            in_bytes,
513                            duration: started.elapsed(),
514                        })
515                        .await;
516                    // Released after the send: the drain below can rely on the
517                    // queue holding every response once all permits are back.
518                    drop(permit);
519                });
520            }
521            // Synchronous panic in the handler: drop the request, keep the
522            // connection — the spawned path's runtime-swallowed panic, mirrored.
523            Err(_panic) => {
524                drop(fut);
525                drop(permit);
526            }
527        }
528    }
529
530    // Graceful drain (SRV-001/004): every dispatch task enqueues its
531    // response before releasing its permit, so once all permits return the
532    // writer's queue holds every outstanding response. The Shutdown job
533    // then stops the writer even while product-held PushSender clones (and
534    // the session's own) keep the channel open.
535    let _ = in_flight.acquire_many(permits).await;
536    let _ = tx.send(WriteJob::Shutdown).await;
537    drop(tx);
538    let _ = write_task.await;
539}
540
541/// Read one request bounded by the profile's cap (WIRE-020) and the
542/// optional per-read idle timeout (SRV-009; zero disables). The returned
543/// frame size comes from the decoder's length prefix (SRV-007).
544async fn read_next<R: tokio::io::AsyncRead + Unpin>(
545    reader: &mut BufReader<R>,
546    max_frame_bytes: usize,
547    idle_timeout: Duration,
548) -> io::Result<(Request, usize)> {
549    if idle_timeout.is_zero() {
550        read_request_with_limit(reader, max_frame_bytes).await
551    } else {
552        match tokio::time::timeout(
553            idle_timeout,
554            read_request_with_limit(reader, max_frame_bytes),
555        )
556        .await
557        {
558            Ok(read) => read,
559            Err(_) => Err(io::Error::new(io::ErrorKind::TimedOut, "idle timeout")),
560        }
561    }
562}
563
564/// Enqueue a read-loop response (built-ins and gate errors carry no
565/// dispatch duration). Returns `false` when the writer is gone and the
566/// connection should close.
567async fn send_inline(tx: &mpsc::Sender<WriteJob>, response: Response, in_bytes: usize) -> bool {
568    send_response(tx, response, in_bytes, Duration::ZERO).await
569}
570
571/// Enqueue a dispatched response with its measured duration (SRV-030). Used
572/// by the fast path, where the response is produced inline on the read loop
573/// rather than in a task. Returns `false` when the writer is gone.
574async fn send_response(
575    tx: &mpsc::Sender<WriteJob>,
576    response: Response,
577    in_bytes: usize,
578    duration: Duration,
579) -> bool {
580    tx.send(WriteJob::Response {
581        response,
582        in_bytes,
583        duration,
584    })
585    .await
586    .is_ok()
587}
588
589/// The connection's writer (SRV-002/006): owns the buffered write half.
590/// After writing one job it drains every already-queued job via `try_recv`
591/// before a single `flush()` — the Synap drain-then-flush pattern that
592/// coalesces a pipelined burst into one syscall (SRV-006).
593async fn writer_task<W: tokio::io::AsyncWrite + Unpin>(
594    mut writer: BufWriter<W>,
595    mut rx: mpsc::Receiver<WriteJob>,
596    metrics: Arc<Metrics>,
597    slow_threshold: Duration,
598) {
599    'outer: while let Some(job) = rx.recv().await {
600        if !write_job(&mut writer, job, &metrics, slow_threshold).await {
601            break;
602        }
603        while let Ok(job) = rx.try_recv() {
604            if !write_job(&mut writer, job, &metrics, slow_threshold).await {
605                break 'outer;
606            }
607        }
608        if writer.flush().await.is_err() {
609            break;
610        }
611    }
612    // Cover the Shutdown exit paths with frames still buffered.
613    let _ = writer.flush().await;
614}
615
616/// Encode exactly once, write, record after the successful write
617/// (SRV-007/030). Returns `false` when the writer must stop (write error
618/// or shutdown).
619async fn write_job<W: tokio::io::AsyncWrite + Unpin>(
620    writer: &mut BufWriter<W>,
621    job: WriteJob,
622    metrics: &Metrics,
623    slow_threshold: Duration,
624) -> bool {
625    let (response, in_bytes, duration) = match job {
626        WriteJob::Shutdown => return false,
627        WriteJob::Push(response) => {
628            let Ok(frame) = encode_frame(&response) else {
629                return true;
630            };
631            if writer.write_all(&frame).await.is_err() {
632                return false;
633            }
634            metrics.record_push(frame.len());
635            return true;
636        }
637        WriteJob::Response {
638            response,
639            in_bytes,
640            duration,
641        } => (response, in_bytes, duration),
642    };
643    let is_error = response.result.is_err();
644    // SRV-007: the one serialization — this buffer is written and its
645    // length is the out-bytes metric. Re-encoding for metrics is banned.
646    let Ok(frame) = encode_frame(&response) else {
647        // Unencodable response: skip the frame, keep the connection (the
648        // donor listeners do the same).
649        return true;
650    };
651    if writer.write_all(&frame).await.is_err() {
652        return false;
653    }
654    // SRV-030: metrics record after the successful socket write.
655    metrics.record_command(in_bytes, frame.len(), duration, is_error, slow_threshold);
656    true
657}
658
659// ── Built-ins (SRV-011/012/014) ──────────────────────────────────────────────
660
661/// Build the HELLO reply from `ServerInfo` + config + the
662/// `authenticate`/`capabilities` hooks — Thunder's job, never application
663/// code (SRV-014). Covers both shapes pinned by the corpus handshake
664/// group: the metadata shape `{server, version, proto, id, authenticated}`
665/// (`HelloStyle::ArgLess`) and the capabilities shape
666/// `{protocol_version, capabilities}` (`HelloStyle::MapPayload`).
667async fn handle_hello<D: Dispatch>(
668    ctx: &ConnShared<D>,
669    session: &Session,
670    req_id: u32,
671    args: &[Value],
672) -> Response {
673    match ctx.profile.hello_style {
674        // Guarded by the caller; kept total for safety.
675        HelloStyle::NotUsed => {
676            Response::err(req_id, format_err("HELLO is not part of this profile"))
677        }
678        // Metadata shape: arg-less request, metadata-only reply —
679        // credentials travel via AUTH.
680        HelloStyle::ArgLess => Response::ok(
681            req_id,
682            Value::Map(vec![
683                (
684                    Value::Str("server".to_owned()),
685                    Value::Str(ctx.info.name.clone()),
686                ),
687                (
688                    Value::Str("version".to_owned()),
689                    Value::Str(ctx.info.version.clone()),
690                ),
691                (Value::Str("proto".to_owned()), Value::Int(PROTO_VERSION)),
692                (
693                    Value::Str("id".to_owned()),
694                    Value::Int(session.connection_id() as i64),
695                ),
696                (
697                    Value::Str("authenticated".to_owned()),
698                    Value::Bool(session.is_authenticated()),
699                ),
700            ]),
701        ),
702        // Capabilities shape: credentials ride in the map (SRV-012).
703        HelloStyle::MapPayload => {
704            let creds = match parse_hello_credentials(args) {
705                Ok(creds) => creds,
706                Err(message) => return Response::err(req_id, message),
707            };
708            match ctx.dispatch.authenticate(creds).await {
709                Ok(principal) => {
710                    let capabilities = ctx.dispatch.capabilities(&principal);
711                    session.set_principal(principal);
712                    Response::ok(
713                        req_id,
714                        Value::Map(vec![
715                            (
716                                Value::Str("protocol_version".to_owned()),
717                                Value::Int(PROTO_VERSION),
718                            ),
719                            (
720                                Value::Str("capabilities".to_owned()),
721                                Value::Array(capabilities.into_iter().map(Value::Str).collect()),
722                            ),
723                        ]),
724                    )
725                }
726                // A failed HELLO leaves the connection open and gated —
727                // the client may retry with better credentials.
728                Err(err) => Response::err(req_id, auth_error_string(&ctx.profile, err)),
729            }
730        }
731    }
732}
733
734/// `AUTH <api_key>` / `AUTH <user> <pass>` under `AuthCommand` (SRV-012):
735/// Thunder parses, the product validates, the session flips (SRV-010).
736async fn handle_auth<D: Dispatch>(
737    ctx: &ConnShared<D>,
738    session: &Session,
739    req_id: u32,
740    args: &[Value],
741) -> Response {
742    let creds = match args {
743        [key] => value_str(key).map(Credentials::ApiKey),
744        [user, pass] => value_str(user)
745            .zip(value_str(pass))
746            .map(|(user, pass)| Credentials::UserPass(user, pass)),
747        _ => None,
748    };
749    let Some(creds) = creds else {
750        return Response::err(req_id, format_err("invalid arguments for 'AUTH'"));
751    };
752    match ctx.dispatch.authenticate(creds).await {
753        Ok(principal) => {
754            session.set_principal(principal);
755            Response::ok(req_id, Value::Str("OK".to_owned()))
756        }
757        Err(err) => Response::err(req_id, auth_error_string(&ctx.profile, err)),
758    }
759}
760
761/// Built-in pre-auth `PING` (SRV-011 allowlist), family-pinned echo shape:
762/// bare `PING` → `"PONG"`, one string/bytes argument echoes back.
763fn builtin_ping(req_id: u32, args: &[Value]) -> Response {
764    match args {
765        [] => Response::ok(req_id, Value::Str("PONG".to_owned())),
766        [Value::Str(payload)] => Response::ok(req_id, Value::Str(payload.clone())),
767        [Value::Bytes(payload)] => Response::ok(req_id, Value::Bytes(payload.clone())),
768        [_] => Response::err(
769            req_id,
770            format_err("PING argument must be a string or bytes"),
771        ),
772        args => Response::err(
773            req_id,
774            format_err(&format!(
775                "wrong number of arguments for 'PING' ({})",
776                args.len()
777            )),
778        ),
779    }
780}
781
782/// Parse the `MapPayload` HELLO argument — a map with `version`,
783/// `token` | `api_key`, `client_name` (PRO-001). Missing credentials
784/// become [`Credentials::None`]: products with auth disabled accept them.
785fn parse_hello_credentials(args: &[Value]) -> Result<Credentials, String> {
786    let map = match args.first() {
787        None => return Ok(Credentials::None),
788        Some(map @ Value::Map(_)) => map,
789        Some(_) => return Err(format_err("HELLO expects a Map argument")),
790    };
791    if let Some(token) = map.map_get("token").and_then(value_str) {
792        Ok(Credentials::Token(token))
793    } else if let Some(key) = map.map_get("api_key").and_then(value_str) {
794        Ok(Credentials::ApiKey(key))
795    } else {
796        Ok(Credentials::None)
797    }
798}
799
800/// Extract a UTF-8 string from a credential argument (`Str`, or `Bytes`
801/// holding UTF-8 — the family's tolerant form).
802fn value_str(value: &Value) -> Option<String> {
803    match value {
804        Value::Str(text) => Some(text.clone()),
805        Value::Bytes(bytes) => String::from_utf8(bytes.clone()).ok(),
806        _ => None,
807    }
808}
809
810// ── Config-convention error strings (SRV-021, PRO-014) ─────────────────────
811
812/// Map an [`AuthError`] to the profile's convention; product-supplied
813/// messages travel verbatim (WIRE-040).
814fn auth_error_string(profile: &Config, err: AuthError) -> String {
815    match err {
816        AuthError::Message(message) => message,
817        AuthError::InvalidCredentials => match profile.error_codes {
818            ErrorConvention::BracketCode | ErrorConvention::Both => {
819                format_bracket_code("unauthorized", "invalid credentials")
820            }
821            _ => WRONGPASS.to_owned(),
822        },
823    }
824}
825
826/// The gate error for `HelloMandatory` profiles (SRV-011).
827fn hello_required_error(profile: &Config) -> String {
828    match profile.error_codes {
829        ErrorConvention::BracketCode | ErrorConvention::Both => {
830            format_bracket_code("unauthorized", "authentication required: send HELLO first")
831        }
832        _ => NOAUTH.to_owned(),
833    }
834}
835
836/// The dedicated PUSH_ID refusal (SRV-013, WIRE-005).
837fn push_refusal_error(profile: &Config) -> String {
838    const MESSAGE: &str = "request id u32::MAX is reserved for server push frames";
839    match profile.error_codes {
840        ErrorConvention::BracketCode | ErrorConvention::Both => {
841            format_bracket_code("reserved_frame_id", MESSAGE)
842        }
843        _ => format_err(MESSAGE),
844    }
845}