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}