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}