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