Skip to main content

armdb/armour/
rpc.rs

1use std::{collections::HashMap, path::Path, sync::Arc, thread::JoinHandle};
2
3use armour_rpc::RpcError;
4use armour_rpc::protocol::{
5    OpCode, Request, RequestPayload, read_bound, read_bytes, read_u8, read_u32_be, read_u64_be,
6    read_upsert_key,
7};
8use compio::buf::{IoBuf, IoBufMut};
9use compio::io::framed::codec::{Decoder, Encoder};
10use compio::{
11    io::{
12        AsyncRead, AsyncWrite,
13        framed::{Framed, frame::LengthDelimited},
14    },
15    net::{TcpListener, ToSocketAddrsAsync, UnixListener},
16    runtime::RuntimeBuilder,
17};
18use futures_util::{
19    SinkExt, StreamExt,
20    future::{Either, select},
21    pin_mut,
22};
23
24use super::db::Db;
25use super::handler::RpcHandler;
26
27// ─── Response types (server-side only) ───────────────────────────────────────
28
29#[derive(Debug)]
30enum Response {
31    Ok(ResponsePayload),
32    Err { code: u16, message: String },
33}
34
35#[derive(Debug)]
36pub(crate) struct CollectionMeta {
37    pub name: String,
38    pub hashname: u64,
39    pub typ_hash: u64,
40    pub version: u16,
41    pub count: u64,
42}
43
44#[derive(Debug)]
45enum ResponsePayload {
46    Empty,
47    Bool(bool),
48    OptionalData(Option<Vec<u8>>),
49    OptionalLen(Option<u32>),
50    OptionalKV(Option<(Vec<u8>, Vec<u8>)>),
51    KeyValues(Vec<(Vec<u8>, Vec<u8>)>),
52    Keys(Vec<Vec<u8>>),
53    Key(Vec<u8>),
54    Count(u64),
55    Collections(Vec<CollectionMeta>),
56    SchemaBytes(Vec<u8>),
57}
58
59// ─── Codec ────────────────────────────────────────────────────────────────────
60
61struct RpcCodec;
62
63impl<B: IoBuf> Decoder<Request, B> for RpcCodec {
64    type Error = RpcError;
65
66    fn decode(&mut self, buf: &compio::buf::Slice<B>) -> Result<Request, Self::Error> {
67        let bytes: &[u8] = buf;
68        let mut pos = 0;
69        let op_byte = read_u8(bytes, &mut pos)?;
70        let op = OpCode::from_repr(op_byte)
71            .ok_or_else(|| RpcError::Protocol("unknown opcode".to_string()))?;
72        let hashname = read_u64_be(bytes, &mut pos)?;
73
74        let payload = match op {
75            OpCode::Get | OpCode::Contains => {
76                let key = read_bytes(bytes, &mut pos)?;
77                RequestPayload::Key(key)
78            }
79            OpCode::First | OpCode::Last | OpCode::ListCollections | OpCode::GetSchema => {
80                RequestPayload::Empty
81            }
82            OpCode::Count => {
83                let exact = read_u8(bytes, &mut pos)? != 0;
84                RequestPayload::Count { exact }
85            }
86            OpCode::EntryLen => {
87                let key = read_bytes(bytes, &mut pos)?;
88                RequestPayload::EntryLen { key }
89            }
90            OpCode::Range | OpCode::RangeKeys => {
91                let start = read_bound(bytes, &mut pos)?;
92                let end = read_bound(bytes, &mut pos)?;
93                let limit = read_u32_be(bytes, &mut pos)?;
94                RequestPayload::Range { start, end, limit }
95            }
96            OpCode::Upsert => {
97                let key = read_upsert_key(bytes, &mut pos)?;
98                let flag_byte = read_u8(bytes, &mut pos)?;
99                let flag = match flag_byte {
100                    0 => None,
101                    1 => Some(true),
102                    2 => Some(false),
103                    _ => {
104                        return Err(RpcError::Protocol("invalid upsert flag".to_string()));
105                    }
106                };
107                let value = read_bytes(bytes, &mut pos)?;
108                RequestPayload::Upsert { key, flag, value }
109            }
110            OpCode::Remove => {
111                let key = read_bytes(bytes, &mut pos)?;
112                let soft = read_u8(bytes, &mut pos)? != 0;
113                RequestPayload::Remove { key, soft }
114            }
115            OpCode::Take => {
116                let key = read_bytes(bytes, &mut pos)?;
117                let soft = read_u8(bytes, &mut pos)? != 0;
118                RequestPayload::Take { key, soft }
119            }
120            OpCode::ApplyBatch => {
121                let count = read_u32_be(bytes, &mut pos)? as usize;
122                const MAX_PREALLOC: usize = 64 * 1024;
123                let mut items = Vec::with_capacity(count.min(MAX_PREALLOC));
124                for _ in 0..count {
125                    let key = read_bytes(bytes, &mut pos)?;
126                    let has_val = read_u8(bytes, &mut pos)?;
127                    let val = match has_val {
128                        0 => None,
129                        1 => Some(read_bytes(bytes, &mut pos)?),
130                        _ => {
131                            return Err(RpcError::Protocol("invalid batch value tag".to_string()));
132                        }
133                    };
134                    items.push((key, val));
135                }
136                RequestPayload::Batch(items)
137            }
138        };
139
140        Ok(Request {
141            op,
142            hashname,
143            payload,
144        })
145    }
146}
147
148impl<B: IoBufMut> Encoder<Response, B> for RpcCodec {
149    type Error = RpcError;
150
151    fn encode(&mut self, item: Response, buf: &mut B) -> Result<(), Self::Error> {
152        let mut tmp = Vec::new();
153        match item {
154            Response::Ok(payload) => {
155                tmp.push(0x00);
156                encode_ok_payload(&mut tmp, payload);
157            }
158            Response::Err { code, message } => encode_err_frame(&mut tmp, code, &message),
159        }
160        buf.extend_from_slice(&tmp)
161            .map_err(|e| RpcError::Other(e.to_string()))?;
162        Ok(())
163    }
164}
165
166/// Encode an error frame: `[0x01][code: u16 BE][len: u16 BE][msg bytes]`.
167///
168/// The length prefix is a `u16`, so the message is capped at `u16::MAX` bytes.
169/// Truncate the byte slice to match — otherwise a message ≥ 64 KiB writes a
170/// prefix wrapped mod 65536 while emitting all bytes, leaving the client to read
171/// `len` bytes and misparse the unconsumed remainder of the frame.
172fn encode_err_frame(buf: &mut Vec<u8>, code: u16, message: &str) {
173    buf.push(0x01);
174    buf.extend_from_slice(&code.to_be_bytes());
175    let max = message.len().min(u16::MAX as usize);
176    let end = message.floor_char_boundary(max);
177    let msg = &message.as_bytes()[..end];
178    buf.extend_from_slice(&(msg.len() as u16).to_be_bytes());
179    buf.extend_from_slice(msg);
180}
181
182/// Build a wire error [`Response`] applying the **5xx-opaque policy**.
183///
184/// - **4xx** (client error): the message is forwarded verbatim — the client must
185///   understand what it got wrong so it can fix the request (unknown collection,
186///   invalid op, bad key, schema mismatch, …).
187/// - **5xx** (server error): the real cause is logged server-side via `tracing`,
188///   and the wire message becomes a generic `"internal error"` so internal
189///   details (file paths, corruption state, codec internals) don't leak to RPC
190///   clients. `original` is the verbatim cause to log.
191///
192/// **501 is exempt.** [`DbError::NotImplemented`] is raised only by client input
193/// the engine does not support (`soft = true` on remove/take), its `Display` is
194/// a fixed string that cannot leak anything, and the client needs to see it to
195/// know the request is permanently unsupported rather than transiently broken.
196/// It is therefore forwarded verbatim and logged at `warn` — an `error` here
197/// would raise operational alerts for a plain client mistake.
198///
199/// Note: `ShardMismatch` is a server-side routing bug (the engine mis-routed a
200/// key), so it stays in the `_ => 500` catch-all of [`DbError::status_code`] and
201/// is therefore opaque to the client.
202fn err_response(code: u16, original: &str) -> Response {
203    if code == 501 {
204        tracing::warn!("RPC unsupported request ({code}): {original}");
205        Response::Err {
206            code,
207            message: original.into(),
208        }
209    } else if code >= 500 {
210        tracing::error!("RPC server error ({code}): {original}");
211        Response::Err {
212            code,
213            message: "internal error".into(),
214        }
215    } else {
216        Response::Err {
217            code,
218            message: original.into(),
219        }
220    }
221}
222
223fn encode_ok_payload(buf: &mut Vec<u8>, payload: ResponsePayload) {
224    match payload {
225        ResponsePayload::Empty => {}
226        ResponsePayload::Bool(b) => {
227            buf.push(if b { 1 } else { 0 });
228        }
229        ResponsePayload::OptionalData(opt) => match opt {
230            None => buf.push(0),
231            Some(data) => {
232                buf.push(1);
233                buf.extend_from_slice(&(data.len() as u32).to_be_bytes());
234                buf.extend_from_slice(&data);
235            }
236        },
237        ResponsePayload::OptionalLen(opt) => match opt {
238            None => buf.push(0),
239            Some(len) => {
240                buf.push(1);
241                buf.extend_from_slice(&len.to_be_bytes());
242            }
243        },
244        ResponsePayload::OptionalKV(opt) => match opt {
245            None => buf.push(0),
246            Some((key, val)) => {
247                buf.push(1);
248                buf.extend_from_slice(&(key.len() as u32).to_be_bytes());
249                buf.extend_from_slice(&key);
250                buf.extend_from_slice(&(val.len() as u32).to_be_bytes());
251                buf.extend_from_slice(&val);
252            }
253        },
254        ResponsePayload::KeyValues(pairs) => {
255            buf.extend_from_slice(&(pairs.len() as u32).to_be_bytes());
256            for (key, val) in pairs {
257                buf.extend_from_slice(&(key.len() as u32).to_be_bytes());
258                buf.extend_from_slice(&key);
259                buf.extend_from_slice(&(val.len() as u32).to_be_bytes());
260                buf.extend_from_slice(&val);
261            }
262        }
263        ResponsePayload::Keys(keys) => {
264            buf.extend_from_slice(&(keys.len() as u32).to_be_bytes());
265            for key in keys {
266                buf.extend_from_slice(&(key.len() as u32).to_be_bytes());
267                buf.extend_from_slice(&key);
268            }
269        }
270        ResponsePayload::Key(key) => {
271            buf.extend_from_slice(&(key.len() as u32).to_be_bytes());
272            buf.extend_from_slice(&key);
273        }
274        ResponsePayload::Count(n) => {
275            buf.extend_from_slice(&n.to_be_bytes());
276        }
277        ResponsePayload::SchemaBytes(bytes) => {
278            buf.extend_from_slice(&bytes);
279        }
280        ResponsePayload::Collections(collections) => {
281            buf.extend_from_slice(&(collections.len() as u32).to_be_bytes());
282            for c in collections {
283                let name = c.name.as_bytes();
284                buf.extend_from_slice(&(name.len() as u32).to_be_bytes());
285                buf.extend_from_slice(name);
286                // partition_name = name for armdb
287                buf.extend_from_slice(&(name.len() as u32).to_be_bytes());
288                buf.extend_from_slice(name);
289                buf.extend_from_slice(&c.hashname.to_be_bytes());
290                buf.extend_from_slice(&c.typ_hash.to_be_bytes());
291                buf.extend_from_slice(&c.version.to_be_bytes());
292                buf.extend_from_slice(&c.count.to_be_bytes());
293            }
294        }
295    }
296}
297
298// ─── Server ───────────────────────────────────────────────────────────────────
299
300pub type TreeMap = Arc<HashMap<u64, Arc<dyn RpcHandler>>>;
301
302/// Backoff bounds for the accept loops. On a persistent accept failure (e.g.
303/// `EMFILE` when the fd table is exhausted) an immediate retry spins a hot error
304/// loop; back off between failures and reset on the next successful accept.
305const ACCEPT_BACKOFF_MIN: std::time::Duration = std::time::Duration::from_millis(5);
306const ACCEPT_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_millis(500);
307
308async fn handle_connection<R, W>(
309    reader: R,
310    writer: W,
311    trees: TreeMap,
312    mut stop_rx: async_broadcast::Receiver<()>,
313) where
314    R: AsyncRead + Unpin + 'static,
315    W: AsyncWrite + Unpin + 'static,
316{
317    let mut framed = Framed::new::<Response, Request>(RpcCodec, LengthDelimited::new())
318        .with_reader(reader)
319        .with_writer(writer);
320
321    loop {
322        let next_request = framed.next();
323        let stop_signal = stop_rx.recv();
324        pin_mut!(next_request);
325        pin_mut!(stop_signal);
326
327        match select(next_request, stop_signal).await {
328            Either::Left((Some(result), _)) => {
329                let response = match result {
330                    Ok(request) => dispatch(&trees, request),
331                    // A `Decoder` error (unknown opcode, invalid upsert flag,
332                    // invalid batch tag, truncated frame) is always *client*
333                    // garbage — the request never reached a collection — so it
334                    // is 400, not 500. ("Corrupt data already in the DB" is a
335                    // different path and stays 500 via `DbError::status_code`.)
336                    Err(e) => err_response(400, &e.to_string()),
337                };
338                if framed.send(response).await.is_err() {
339                    break;
340                }
341            }
342            Either::Left((None, _)) => break,
343            Either::Right(_) => break,
344        }
345    }
346}
347
348fn dispatch(trees: &TreeMap, request: Request) -> Response {
349    if request.op == OpCode::ListCollections {
350        return list_collections(trees);
351    }
352    if request.op == OpCode::GetSchema {
353        return get_schema(trees, request.hashname);
354    }
355
356    let handler = match trees.get(&request.hashname) {
357        Some(h) => h,
358        None => {
359            return Response::Err {
360                code: 404,
361                message: format!("tree not found: {:#X}", request.hashname),
362            };
363        }
364    };
365
366    let result = match (request.op, request.payload) {
367        (OpCode::Get, RequestPayload::Key(key)) => handler
368            .get(&key)
369            .map(|v| Response::Ok(ResponsePayload::OptionalData(v))),
370        (OpCode::Contains, RequestPayload::Key(key)) => handler
371            .contains(&key)
372            .map(|b| Response::Ok(ResponsePayload::Bool(b))),
373        (OpCode::First, RequestPayload::Empty) => handler
374            .first()
375            .map(|v| Response::Ok(ResponsePayload::OptionalKV(v))),
376        (OpCode::Last, RequestPayload::Empty) => handler
377            .last()
378            .map(|v| Response::Ok(ResponsePayload::OptionalKV(v))),
379        (OpCode::Range, RequestPayload::Range { start, end, limit }) => handler
380            .range(start, end, limit)
381            .map(|v| Response::Ok(ResponsePayload::KeyValues(v))),
382        (OpCode::RangeKeys, RequestPayload::Range { start, end, limit }) => handler
383            .range_keys(start, end, limit)
384            .map(|v| Response::Ok(ResponsePayload::Keys(v))),
385        (OpCode::Count, RequestPayload::Count { exact }) => handler
386            .count(exact)
387            .map(|n| Response::Ok(ResponsePayload::Count(n))),
388        (OpCode::Upsert, RequestPayload::Upsert { key, flag, value }) => handler
389            .upsert(key, flag, value)
390            .map(|k| Response::Ok(ResponsePayload::Key(k))),
391        (OpCode::Remove, RequestPayload::Remove { key, soft }) => handler
392            .remove(&key, soft)
393            .map(|_| Response::Ok(ResponsePayload::Empty)),
394        (OpCode::Take, RequestPayload::Take { key, soft }) => handler
395            .take(&key, soft)
396            .map(|v| Response::Ok(ResponsePayload::OptionalData(v))),
397        (OpCode::EntryLen, RequestPayload::EntryLen { key }) => handler
398            .entry_len(&key)
399            .map(|v| Response::Ok(ResponsePayload::OptionalLen(v))),
400        (OpCode::ApplyBatch, RequestPayload::Batch(items)) => handler
401            .apply_batch(items)
402            .map(|_| Response::Ok(ResponsePayload::Empty)),
403        _ => {
404            return Response::Err {
405                code: 400,
406                message: "invalid op/payload combination".into(),
407            };
408        }
409    };
410
411    match result {
412        Ok(resp) => resp,
413        Err(e) => err_response(e.status_code(), &e.to_string()),
414    }
415}
416
417fn get_schema(trees: &TreeMap, hashname: u64) -> Response {
418    let handler = match trees.get(&hashname) {
419        Some(h) => h,
420        None => {
421            return Response::Err {
422                code: 404,
423                message: format!("tree not found: {hashname:#X}"),
424            };
425        }
426    };
427    let schema = handler.schema();
428    match serde_json::to_vec(&schema) {
429        Ok(bytes) => Response::Ok(ResponsePayload::SchemaBytes(bytes)),
430        // Server-side serialization failure → 5xx (opaque to the client).
431        Err(e) => err_response(500, &e.to_string()),
432    }
433}
434
435fn list_collections(trees: &TreeMap) -> Response {
436    let collections = trees
437        .iter()
438        .map(|(&hashname, handler)| {
439            let (typ_hash, version) = handler.info();
440            let count = handler.count(false).unwrap_or_else(|e| {
441                tracing::warn!("count() failed for '{}': {e}", handler.name());
442                0
443            });
444            CollectionMeta {
445                name: handler.name().to_string(),
446                hashname,
447                typ_hash,
448                version,
449                count,
450            }
451        })
452        .collect();
453    Response::Ok(ResponsePayload::Collections(collections))
454}
455
456async fn accept_tcp(
457    listener: TcpListener,
458    trees: TreeMap,
459    mut shutdown_rx: async_broadcast::Receiver<()>,
460    mut local_rx: async_broadcast::Receiver<()>,
461) {
462    let mut backoff = ACCEPT_BACKOFF_MIN;
463    loop {
464        let accept_fut = listener.accept();
465        // AI-05: stop on either the Db-global shutdown or this listener's own
466        // handle signal (`RpcHandle::stop`), so a standalone handle can terminate
467        // its accept loop without a global shutdown.
468        let global_stop = shutdown_rx.recv();
469        let local_stop = local_rx.recv();
470        pin_mut!(accept_fut);
471        pin_mut!(global_stop);
472        pin_mut!(local_stop);
473        let stop_fut = select(global_stop, local_stop);
474
475        match select(accept_fut, stop_fut).await {
476            Either::Left((Ok((stream, _addr)), _)) => {
477                backoff = ACCEPT_BACKOFF_MIN;
478                let trees = trees.clone();
479                let (reader, writer) = stream.into_split();
480                let rx = shutdown_rx.clone();
481                compio::runtime::spawn(handle_connection(reader, writer, trees, rx)).detach();
482            }
483            Either::Left((Err(e), _)) => {
484                tracing::error!("tcp accept error: {e}");
485                compio::time::sleep(backoff).await;
486                backoff = (backoff * 2).min(ACCEPT_BACKOFF_MAX);
487            }
488            Either::Right(_) => {
489                tracing::info!("shutting down TCP server");
490                break;
491            }
492        }
493    }
494}
495
496async fn accept_uds(
497    listener: UnixListener,
498    trees: TreeMap,
499    mut shutdown_rx: async_broadcast::Receiver<()>,
500    mut local_rx: async_broadcast::Receiver<()>,
501) {
502    let mut backoff = ACCEPT_BACKOFF_MIN;
503    loop {
504        let accept_fut = listener.accept();
505        let global_stop = shutdown_rx.recv();
506        let local_stop = local_rx.recv();
507        pin_mut!(accept_fut);
508        pin_mut!(global_stop);
509        pin_mut!(local_stop);
510        let stop_fut = select(global_stop, local_stop);
511
512        match select(accept_fut, stop_fut).await {
513            Either::Left((Ok((stream, _addr)), _)) => {
514                backoff = ACCEPT_BACKOFF_MIN;
515                let trees = trees.clone();
516                let (reader, writer) = stream.into_split();
517                let rx = shutdown_rx.clone();
518                compio::runtime::spawn(handle_connection(reader, writer, trees, rx)).detach();
519            }
520            Either::Left((Err(e), _)) => {
521                tracing::error!("uds accept error: {e}");
522                compio::time::sleep(backoff).await;
523                backoff = (backoff * 2).min(ACCEPT_BACKOFF_MAX);
524            }
525            Either::Right(_) => {
526                tracing::info!("shutting down UDS server");
527                break;
528            }
529        }
530    }
531}
532
533// ─── RpcHandle ───────────────────────────────────────────────────────────────
534
535/// Handle for a running RPC listener thread. Dropping it triggers shutdown
536/// and joins the thread.
537pub struct RpcHandle {
538    thread: Option<JoinHandle<()>>,
539    shutdown_tx: async_broadcast::Sender<()>,
540}
541
542impl RpcHandle {
543    pub fn stop(&mut self) {
544        let _ = self.shutdown_tx.try_broadcast(());
545        if let Some(h) = self.thread.take() {
546            let _ = h.join();
547        }
548    }
549}
550
551impl Drop for RpcHandle {
552    fn drop(&mut self) {
553        self.stop();
554    }
555}
556
557// ─── Db listen methods ────────────────────────────────────────────────────
558
559impl Db {
560    /// Start a TCP RPC listener on `addr`.
561    ///
562    /// # Trust model
563    ///
564    /// The RPC server assumes a **trusted network**: peers are not authenticated,
565    /// operations are not authorized, and heavy requests are unbudgeted — a `Range`
566    /// with unbounded bounds and `limit = 0` (or `Count { exact: true }`) scans the
567    /// whole collection, materializes the full result in RAM, and blocks the
568    /// single-thread runtime while it runs. `limit = 0` deliberately means
569    /// "everything"; there is no default server-side clamp (it would be
570    /// behavior-breaking). Bind to loopback / a private interface (or prefer
571    /// [`listen_uds`](Self::listen_uds)) and front untrusted networks with an mTLS
572    /// tunnel — do not expose this listener directly. See the "Network trust model"
573    /// section in `docs/design.md`.
574    pub fn listen_tcp(&self, addr: impl ToSocketAddrsAsync + Send + 'static) {
575        let trees = self.build_tree_map();
576        let shutdown_rx = self.shutdown.subscribe_broadcast();
577        // AI-05: keep the receiver and drive it in the accept loop so
578        // `RpcHandle::stop` can terminate this listener on its own. The sender
579        // clone stored on the handle keeps the channel alive.
580        let (local_tx, local_rx) = async_broadcast::broadcast::<()>(1);
581        let shutdown_tx = local_tx.clone();
582
583        let thread = std::thread::spawn(move || {
584            // AI-04: a build/bind failure must log and return, not panic a
585            // detached thread — a silent panic leaves callers believing the RPC
586            // listener is up when nothing is accepting connections.
587            let runtime = match RuntimeBuilder::new().build() {
588                Ok(rt) => rt,
589                Err(e) => {
590                    tracing::error!("failed to build compio runtime for TCP RPC listener: {e}");
591                    return;
592                }
593            };
594            runtime.block_on(async move {
595                let listener = match TcpListener::bind(addr).await {
596                    Ok(l) => l,
597                    Err(e) => {
598                        tracing::error!("failed to bind TCP RPC listener: {e}");
599                        return;
600                    }
601                };
602                accept_tcp(listener, trees, shutdown_rx, local_rx).await;
603            });
604        });
605        crate::sync::lock(&self.rpc_handles).push(RpcHandle {
606            thread: Some(thread),
607            shutdown_tx,
608        });
609    }
610
611    pub fn listen_uds(&self, path: impl AsRef<Path> + Send + 'static) {
612        let trees = self.build_tree_map();
613        let shutdown_rx = self.shutdown.subscribe_broadcast();
614        let (local_tx, local_rx) = async_broadcast::broadcast::<()>(1);
615        let shutdown_tx = local_tx.clone();
616
617        let thread = std::thread::spawn(move || {
618            let runtime = match RuntimeBuilder::new().build() {
619                Ok(rt) => rt,
620                Err(e) => {
621                    tracing::error!("failed to build compio runtime for UDS RPC listener: {e}");
622                    return;
623                }
624            };
625            runtime.block_on(async move {
626                let _ = std::fs::remove_file(path.as_ref());
627                let listener = match UnixListener::bind(path).await {
628                    Ok(l) => l,
629                    Err(e) => {
630                        tracing::error!("failed to bind UDS RPC listener: {e}");
631                        return;
632                    }
633                };
634                accept_uds(listener, trees, shutdown_rx, local_rx).await;
635            });
636        });
637        crate::sync::lock(&self.rpc_handles).push(RpcHandle {
638            thread: Some(thread),
639            shutdown_tx,
640        });
641    }
642}
643
644#[cfg(test)]
645mod err_frame_tests {
646    use super::encode_err_frame;
647
648    // AI-11: the length prefix is a `u16`; a message ≥ 64 KiB must be truncated so
649    // the prefix equals the bytes actually written. Pre-fix the prefix wrapped mod
650    // 65536 while all bytes were emitted, desyncing the client frame parser.
651    #[test]
652    fn err_frame_len_prefix_matches_written_bytes_when_over_u16() {
653        let big = "x".repeat(70_000);
654
655        // The pre-fix formula (`len as u16`) disagreed with the byte count.
656        let buggy_prefix = big.len() as u16; // 70000 as u16 == 4464
657        assert_ne!(
658            buggy_prefix as usize,
659            big.len(),
660            "sanity: pre-fix u16 prefix disagreed with the byte count"
661        );
662
663        let mut buf = Vec::new();
664        encode_err_frame(&mut buf, 500, &big);
665
666        // Frame layout: [tag:1][code:2][len:2][msg…]
667        let len = u16::from_be_bytes([buf[3], buf[4]]) as usize;
668        let msg_written = buf.len() - 5;
669        assert_eq!(
670            len, msg_written,
671            "len prefix must equal bytes actually written"
672        );
673        assert_eq!(len, u16::MAX as usize, "message capped at u16::MAX");
674    }
675
676    #[test]
677    fn err_frame_small_message_roundtrips() {
678        let mut buf = Vec::new();
679        encode_err_frame(&mut buf, 404, "not found");
680        assert_eq!(buf[0], 0x01);
681        assert_eq!(u16::from_be_bytes([buf[1], buf[2]]), 404);
682        let len = u16::from_be_bytes([buf[3], buf[4]]) as usize;
683        assert_eq!(len, "not found".len());
684        assert_eq!(&buf[5..], b"not found");
685    }
686
687    #[test]
688    fn err_frame_truncates_at_utf8_boundary() {
689        let message = format!("{}€", "a".repeat(u16::MAX as usize - 1));
690        let mut buf = Vec::new();
691        encode_err_frame(&mut buf, 500, &message);
692        let len = u16::from_be_bytes([buf[3], buf[4]]) as usize;
693        let encoded = &buf[5..5 + len];
694        assert_eq!(len, u16::MAX as usize - 1);
695        assert_eq!(std::str::from_utf8(encoded).unwrap(), "a".repeat(len));
696        assert_eq!(buf.len(), 5 + len);
697    }
698}
699
700#[cfg(test)]
701mod rpc_handle_tests {
702    use super::Db;
703    use std::time::Duration;
704
705    // AI-05: `RpcHandle::stop` must be able to terminate its own listener without a
706    // Db-global shutdown. Pre-fix the handle's shutdown sender had no live
707    // receiver, so `stop` broadcast into the void and `join` hung.
708    #[test]
709    fn rpc_handle_stop_terminates_listener_without_global_shutdown() {
710        let dir = tempfile::tempdir().unwrap();
711        let db = Db::open_test(dir.path()).unwrap();
712        let sock = dir.path().join("rpc.sock");
713        db.listen_uds(sock);
714
715        // Let the listener thread bind and enter its accept loop.
716        std::thread::sleep(Duration::from_millis(150));
717
718        // Take the handle out so stopping it does not depend on Db-global shutdown.
719        let handle = crate::sync::lock(&db.rpc_handles)
720            .pop()
721            .expect("one rpc handle");
722
723        // Run stop() with a timeout: a regression (accept loop ignores the local
724        // signal) then fails fast instead of hanging the whole test run.
725        let (tx, rx) = std::sync::mpsc::channel();
726        std::thread::spawn(move || {
727            let mut handle = handle;
728            handle.stop();
729            let _ = tx.send(());
730        });
731
732        let stopped = rx.recv_timeout(Duration::from_secs(5)).is_ok();
733        assert!(
734            stopped,
735            "RpcHandle::stop did not terminate the listener (accept loop ignored the local signal)"
736        );
737    }
738}
739
740#[cfg(test)]
741mod dispatch_status_code_tests {
742    use crate::error::{DbError, SchemaMismatchKind};
743
744    #[test]
745    fn key_not_found_maps_to_404() {
746        assert_eq!(DbError::KeyNotFound.status_code(), 404);
747    }
748    #[test]
749    fn key_exists_maps_to_409() {
750        assert_eq!(DbError::KeyExists.status_code(), 409);
751    }
752    #[test]
753    fn client_error_maps_to_400() {
754        assert_eq!(DbError::Client("bad").status_code(), 400);
755    }
756    #[test]
757    fn not_implemented_maps_to_501() {
758        assert_eq!(DbError::NotImplemented.status_code(), 501);
759    }
760    #[test]
761    fn schema_mismatch_maps_to_422() {
762        let e = DbError::SchemaMismatch {
763            name: "x".into(),
764            kind: SchemaMismatchKind::MissingStep { from: 0 },
765        };
766        assert_eq!(e.status_code(), 422);
767    }
768    #[test]
769    fn tx_conflict_maps_to_409() {
770        // D4: TxConflict is a client-actionable "retry" signal → 409, not 500.
771        assert_eq!(DbError::TxConflict.status_code(), 409);
772    }
773    #[test]
774    fn shard_mismatch_stays_500() {
775        // D4: wrong-shard routing is a server bug → stays opaque 500 (not 4xx).
776        assert_eq!(DbError::ShardMismatch.status_code(), 500);
777    }
778}
779
780#[cfg(test)]
781mod err_response_policy_tests {
782    use super::{Response, err_response};
783
784    fn unpack(r: Response) -> (u16, String) {
785        match r {
786            Response::Err { code, message } => (code, message),
787            Response::Ok(_) => panic!("expected Err, got Ok"),
788        }
789    }
790
791    #[test]
792    fn client_error_4xx_forwards_message_verbatim() {
793        // 4xx is the client's fault: it must see *what* it got wrong.
794        let (code, msg) = unpack(err_response(404, "tree not found: 0xDEAD"));
795        assert_eq!(code, 404);
796        assert_eq!(msg, "tree not found: 0xDEAD");
797    }
798
799    #[test]
800    fn server_error_5xx_sends_opaque_message() {
801        // 5xx leaks nothing: the original (paths, corruption state) must NOT
802        // appear on the wire — only a generic "internal error".
803        let original = "corrupted entry at offset 12345 in /var/data/shard_007/000012.data";
804        let (code, msg) = unpack(err_response(500, original));
805        assert_eq!(code, 500);
806        assert_eq!(msg, "internal error");
807        assert!(
808            !msg.contains("12345") && !msg.contains("/var/data"),
809            "5xx response must not echo internal details"
810        );
811    }
812
813    #[test]
814    fn not_implemented_501_forwards_message() {
815        // 501 is exempt from the opaque policy: `DbError::NotImplemented` is
816        // raised only by unsupported *client* input (`soft = true`), so the
817        // client must see that the request is permanently unsupported rather
818        // than a transient server fault.
819        let (code, msg) = unpack(err_response(501, "not implemented"));
820        assert_eq!(code, 501);
821        assert_eq!(msg, "not implemented");
822    }
823}