Skip to main content

vgi_rpc/
server.rs

1//! RPC server dispatch — reads requests, invokes handlers, writes responses.
2
3use std::collections::HashMap;
4use std::io::{Read, Write};
5use std::sync::{Arc, Mutex};
6
7use arrow_array::RecordBatch;
8use arrow_cast::cast_with_options;
9use arrow_schema::{Schema, SchemaRef};
10
11use crate::errors::{Result, RpcError};
12use crate::log::{LogLevel, LogMessage};
13#[cfg(feature = "shm")]
14use crate::metadata::SHM_SEGMENT_SIZE_KEY;
15use crate::metadata::{
16    CANCEL_KEY, LOG_EXTRA_KEY, LOG_LEVEL_KEY, LOG_MESSAGE_KEY, REQUEST_ID_KEY, REQUEST_VERSION,
17    REQUEST_VERSION_KEY, RPC_METHOD_KEY, SERVER_ID_KEY, SHM_OFFSET_KEY, SHM_SEGMENT_NAME_KEY,
18};
19#[cfg(feature = "shm")]
20use crate::shm::{is_shm_pointer_batch, maybe_write_to_shm, resolve_shm_batch, ShmSegment};
21
22/// Feature-off stand-in so dispatch signatures stay uniform.
23#[cfg(not(feature = "shm"))]
24pub(crate) struct ShmSegment;
25
26/// Attach to a client-advertised SHM segment named in request metadata.
27/// `track = false` since the client owns the lifecycle.
28#[cfg(feature = "shm")]
29fn maybe_attach_shm(req_md: &Metadata) -> Option<ShmSegment> {
30    let name = req_md.get(SHM_SEGMENT_NAME_KEY)?;
31    let size: usize = req_md.get(SHM_SEGMENT_SIZE_KEY)?.parse().ok()?;
32    match ShmSegment::attach(name, size, false) {
33        Ok(seg) => Some(seg),
34        Err(e) => {
35            tracing::warn!(target: "vgi_rpc.shm", "ignoring malformed SHM metadata ({e})");
36            None
37        }
38    }
39}
40
41#[cfg(not(feature = "shm"))]
42#[inline]
43fn maybe_attach_shm(_req_md: &Metadata) -> Option<ShmSegment> {
44    None
45}
46
47/// Per-connection cache of a client-advertised SHM segment.
48///
49/// A client names its segment once — in an early request's metadata — then
50/// routes later batches (request *and* data) through it carrying only an
51/// offset/length, no name (the C++ extension does this; the Python client
52/// happens to re-advertise the name on every request, so it never needed
53/// the cache). The worker attaches on first sight and reuses the
54/// attachment for the connection's lifetime; [`RpcServer::serve`] owns the
55/// cache, and dropping it detaches without unlinking (the client owns the
56/// OS object). Without this, a later offset-only pointer request can't be
57/// resolved and trips the "Expected 1 row in request batch" guard.
58///
59/// Mirrors Python `vgi_rpc.rpc._server._ConnectionShm`.
60#[derive(Default)]
61pub(crate) struct ConnectionShm {
62    #[cfg(feature = "shm")]
63    name: Option<String>,
64    #[cfg(feature = "shm")]
65    segment: Option<ShmSegment>,
66}
67
68#[cfg(feature = "shm")]
69impl ConnectionShm {
70    /// Attach and cache the segment named in `req_md` when it first
71    /// appears or changes.
72    fn refresh(&mut self, req_md: &Metadata) {
73        let Some(name) = req_md.get(SHM_SEGMENT_NAME_KEY) else {
74            return;
75        };
76        if self.name.as_deref() == Some(name.as_str()) {
77            return;
78        }
79        let Some(new) = maybe_attach_shm(req_md) else {
80            return;
81        };
82        self.segment = Some(new);
83        self.name = Some(name.clone());
84    }
85
86    fn segment(&self) -> Option<&ShmSegment> {
87        self.segment.as_ref()
88    }
89}
90
91#[cfg(not(feature = "shm"))]
92impl ConnectionShm {
93    // No `refresh` counterpart: both call sites live inside the
94    // `#[cfg(feature = "shm")]` arm of `read_request`, so a no-op stub here
95    // is dead code — and CI builds with `-D warnings`, which `--all-features`
96    // structurally cannot catch.
97    #[inline]
98    fn segment(&self) -> Option<&ShmSegment> {
99        None
100    }
101}
102use crate::stream::{empty_schema, Emitted, OutputCollector, StreamResult, StreamStateKind};
103use crate::wire::{empty_batch, md_get, Metadata, StreamReader, StreamWriter};
104
105/// Serialize a parsed request batch back to a self-contained Arrow IPC
106/// stream (one schema message + one record batch + EOS) for inclusion in
107/// access-log `request_data`.
108pub(crate) fn serialize_request_batch(batch: &RecordBatch) -> std::io::Result<Vec<u8>> {
109    let mut buf = Vec::new();
110    {
111        let mut w = arrow_ipc::writer::StreamWriter::try_new(&mut buf, batch.schema_ref())
112            .map_err(|e| std::io::Error::other(e.to_string()))?;
113        w.write(batch)
114            .map_err(|e| std::io::Error::other(e.to_string()))?;
115        w.finish()
116            .map_err(|e| std::io::Error::other(e.to_string()))?;
117    }
118    Ok(buf)
119}
120
121/// Lock a mutex, recovering the guard even if a previous holder
122/// panicked. Handler code is arbitrary and *will* panic eventually; a
123/// poisoned lock must not turn that into a process abort on the next
124/// `.lock()`. The panic itself is surfaced to the client as an
125/// `RpcError` by the `catch_unwind` wrappers in the dispatch path.
126fn lock_ok<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
127    m.lock().unwrap_or_else(|e| e.into_inner())
128}
129
130/// Invoke a handler closure, converting a panic into an `RpcError`
131/// instead of unwinding through the serve loop (which on stdio/pipe
132/// would kill the whole process). The panic message is intentionally
133/// not echoed to the client.
134pub(crate) fn call_guard<T>(f: impl FnOnce() -> T) -> Result<T> {
135    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
136        .map_err(|_| RpcError::new("RuntimeError", "handler panicked"))
137}
138
139/// Context supplied to each handler invocation.
140#[derive(Clone)]
141pub struct CallContext {
142    pub server_id: String,
143    pub method: String,
144    pub request_id: String,
145    pub transport_metadata: Arc<Metadata>,
146    /// Authentication state, or [`crate::AuthContext::anonymous`] when
147    /// no authenticator is configured (e.g. pipe/unix transports).
148    pub auth: crate::auth::AuthContext,
149    /// HTTP request cookies (empty for pipe/unix). Name → value.
150    pub cookies: std::collections::BTreeMap<String, String>,
151    /// Coarse identifier of the bound transport. `None` until the
152    /// framework has observed the transport (i.e. before the first
153    /// [`RpcServer::notify_transport`] call).
154    pub kind: Option<crate::transport::TransportKind>,
155    pub(crate) log_sink: Arc<Mutex<Vec<LogMessage>>>,
156    /// Per-tick input-batch custom metadata (updated each producer/exchange
157    /// iteration). Carries e.g. `vgi_pushdown_filters` for dynamic filters.
158    pub(crate) tick_metadata: Arc<Mutex<Metadata>>,
159    /// Sticky-session bridge, installed by the HTTP transport when the
160    /// server is sticky-enabled. `None` on pipe/unix/subprocess and on
161    /// HTTP servers without sticky support — [`CallContext::open_session`]
162    /// then raises a clear "not available on this transport" error.
163    pub(crate) sticky: Option<Arc<dyn StickySink>>,
164}
165
166/// Bridge between [`CallContext`]'s sticky-session API and the HTTP
167/// transport's per-worker session registry. Implemented by the HTTP layer
168/// (see `crate::sticky`); the trait lives here so [`CallContext`] carries
169/// no compile-time dependency on the `http` feature.
170pub trait StickySink: Send + Sync {
171    /// Whether the client opted in via `VGI-Session-Accept: true`.
172    fn accept_opens(&self) -> bool;
173    /// The live session state bound to this request, if any.
174    fn current_state(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>>;
175    /// The opaque hex session id bound to this request, if any.
176    fn current_session_id(&self) -> Option<String>;
177    /// Register a session holding `state`; mints + stashes the response token.
178    fn open(
179        &self,
180        state: Arc<dyn std::any::Any + Send + Sync>,
181        ttl: Option<std::time::Duration>,
182    ) -> Result<()>;
183    /// Close the session bound to this request. Returns whether one was live.
184    fn close(&self) -> Result<bool>;
185}
186
187impl CallContext {
188    pub fn client_log(&self, level: LogLevel, message: impl Into<String>) {
189        lock_ok(&self.log_sink).push(LogMessage::new(level, message));
190    }
191
192    pub fn client_log_with(&self, msg: LogMessage) {
193        lock_ok(&self.log_sink).push(msg);
194    }
195
196    pub(crate) fn drain_logs(&self) -> Vec<LogMessage> {
197        std::mem::take(&mut *lock_ok(&self.log_sink))
198    }
199
200    /// Per-tick input-batch custom metadata value (e.g. `vgi_pushdown_filters`),
201    /// set by the producer/exchange loop for the current iteration.
202    pub fn tick_metadata(&self, key: &str) -> Option<String> {
203        lock_ok(&self.tick_metadata).get(key).cloned()
204    }
205
206    /// Replace the per-tick input-batch metadata for the current iteration.
207    /// Used by the HTTP transport, where a producer's first turn folds into
208    /// the `/init` request (see `run_producer`). HTTP-only, so gated to
209    /// avoid a dead-code `-D warnings` failure in non-`http` builds.
210    #[cfg(feature = "http")]
211    pub(crate) fn set_tick_metadata(&self, md: Metadata) {
212        *lock_ok(&self.tick_metadata) = md;
213    }
214
215    /// Build a call context for `server` serving `req`. Defaults to
216    /// anonymous auth with no cookies — callers on authenticated
217    /// transports (HTTP) override the two after construction or use
218    /// [`CallContext::with_auth_cookies`].
219    pub(crate) fn for_request(server: &RpcServer, req: &Request) -> Self {
220        Self {
221            server_id: server.server_id.clone(),
222            method: req.method.clone(),
223            request_id: req.request_id.clone(),
224            transport_metadata: req.metadata.clone(),
225            auth: crate::auth::AuthContext::anonymous(),
226            cookies: std::collections::BTreeMap::new(),
227            kind: server.transport_kind(),
228            log_sink: Arc::new(Mutex::new(Vec::new())),
229            tick_metadata: Arc::new(Mutex::new(Metadata::default())),
230            sticky: None,
231        }
232    }
233
234    /// Build a call context with an explicit auth context + cookie map.
235    /// Only the HTTP transport constructs contexts this way; gated so the
236    /// method isn't dead code (a `-D warnings` build failure) when `vgi-rpc`
237    /// is compiled without the `http` feature (e.g. from `vgi-rpc-client`).
238    #[cfg(feature = "http")]
239    pub(crate) fn with_auth_cookies(
240        server: &RpcServer,
241        req: &Request,
242        auth: crate::auth::AuthContext,
243        cookies: std::collections::BTreeMap<String, String>,
244    ) -> Self {
245        Self {
246            server_id: server.server_id.clone(),
247            method: req.method.clone(),
248            request_id: req.request_id.clone(),
249            transport_metadata: req.metadata.clone(),
250            auth,
251            cookies,
252            kind: server.transport_kind(),
253            log_sink: Arc::new(Mutex::new(Vec::new())),
254            tick_metadata: Arc::new(Mutex::new(Metadata::default())),
255            sticky: None,
256        }
257    }
258
259    /// Attach a sticky-session sink (HTTP transport only). No-op semantics
260    /// for callers: the session API simply reports "not available" when
261    /// this is never set. HTTP-only, so gated to avoid a dead-code
262    /// `-D warnings` failure in non-`http` builds.
263    #[cfg(feature = "http")]
264    pub(crate) fn set_sticky(&mut self, sink: Arc<dyn StickySink>) {
265        self.sticky = Some(sink);
266    }
267
268    // --- Sticky sessions (HTTP-only) -----------------------------------
269
270    /// The live session state object, downcast to `T`, or `None` when no
271    /// session is bound to this request (or it is not a `T`).
272    ///
273    /// Sticky sessions are HTTP-only; on other transports this is always
274    /// `None`. Mirrors Python's `ctx.session`.
275    pub fn session<T: std::any::Any + Send + Sync>(&self) -> Option<Arc<T>> {
276        let state = self.sticky.as_ref()?.current_state()?;
277        state.downcast::<T>().ok()
278    }
279
280    /// The opaque hex session id bound to this request, or `None`.
281    /// Survives [`CallContext::close_session`] within the same request.
282    pub fn session_id(&self) -> Option<String> {
283        self.sticky.as_ref()?.current_session_id()
284    }
285
286    /// Register a sticky session holding `state` for subsequent requests.
287    ///
288    /// The framework mints a signed `VGI-Session` token and attaches it to
289    /// the response; a client inside a `with_session_token()` block echoes
290    /// it on subsequent requests, and the framework restores `state` as
291    /// [`CallContext::session`]. `ttl` overrides the server default.
292    ///
293    /// Mirrors Python's `ctx.open_session`. Errors when sticky is
294    /// unavailable on this transport, the client did not opt in, or a
295    /// session is already bound to this request.
296    pub fn open_session(
297        &self,
298        state: Arc<dyn std::any::Any + Send + Sync>,
299        ttl: Option<std::time::Duration>,
300    ) -> Result<()> {
301        let sink = self.sticky.as_ref().ok_or_else(|| {
302            RpcError::runtime_error("sticky sessions not available on this transport")
303        })?;
304        if !sink.accept_opens() {
305            return Err(RpcError::runtime_error(
306                "client did not opt in to sticky sessions \
307                 (missing VGI-Session-Accept: true header — open the call inside \
308                 an HttpConnection.with_session_token() block)",
309            ));
310        }
311        if sink.current_state().is_some() {
312            return Err(RpcError::runtime_error(
313                "a sticky session is already active for this request",
314            ));
315        }
316        sink.open(state, ttl)
317    }
318
319    /// Invalidate the sticky session bound to this request. Idempotent;
320    /// mirrors Python's `ctx.close_session`.
321    pub fn close_session(&self) -> Result<()> {
322        let sink = self.sticky.as_ref().ok_or_else(|| {
323            RpcError::runtime_error("sticky sessions not available on this transport")
324        })?;
325        sink.close()?;
326        Ok(())
327    }
328}
329
330/// A request batch parsed from the wire.
331pub struct Request {
332    pub method: String,
333    pub request_id: String,
334    pub batch: RecordBatch,
335    /// Request-level custom metadata. Held behind an `Arc` so the dispatch
336    /// path can hand a shared, cheap-to-clone reference to the `CallContext`
337    /// and (when enabled) the `DispatchInfo` without deep-copying the map.
338    pub metadata: Arc<Metadata>,
339}
340
341impl Request {
342    pub fn column(&self, name: &str) -> Option<&dyn arrow_array::Array> {
343        let idx = self.batch.schema().index_of(name).ok()?;
344        Some(self.batch.column(idx).as_ref())
345    }
346
347    /// Build a `Request` from a record batch carrying its own
348    /// `custom_metadata`, validating the `vgi_rpc.method` /
349    /// `vgi_rpc.request_version` metadata.
350    ///
351    /// `require_method` controls whether a missing `vgi_rpc.method` key is
352    /// an error (pipe/unix transports require it; HTTP already derives the
353    /// method from the URL path and may leave the key absent).
354    pub(crate) fn from_read_batch(
355        batch: RecordBatch,
356        metadata: Metadata,
357        require_method: bool,
358    ) -> Result<Self> {
359        let method = if require_method {
360            md_get(&metadata, RPC_METHOD_KEY)
361                .ok_or_else(|| {
362                    RpcError::protocol_error(
363                        "Missing 'vgi_rpc.method' in request batch custom_metadata.",
364                    )
365                })?
366                .to_string()
367        } else {
368            md_get(&metadata, RPC_METHOD_KEY).unwrap_or("").to_string()
369        };
370        let version = md_get(&metadata, REQUEST_VERSION_KEY).ok_or_else(|| {
371            RpcError::version_error(format!(
372                "Missing 'vgi_rpc.request_version' in request batch custom_metadata. Set it to {:?}.",
373                REQUEST_VERSION
374            ))
375        })?;
376        if version != REQUEST_VERSION {
377            return Err(RpcError::version_error(format!(
378                "Unsupported request version {:?}, expected {:?}.",
379                version, REQUEST_VERSION
380            )));
381        }
382        if require_method && !batch.schema().fields().is_empty() && batch.num_rows() != 1 {
383            return Err(RpcError::protocol_error(format!(
384                "Expected 1 row in request batch, got {}",
385                batch.num_rows()
386            )));
387        }
388        let request_id = md_get(&metadata, REQUEST_ID_KEY).unwrap_or("").to_string();
389        Ok(Request {
390            method,
391            request_id,
392            batch,
393            metadata: Arc::new(metadata),
394        })
395    }
396}
397
398/// Identifies the dispatch kind of a registered method.
399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
400pub enum MethodType {
401    Unary,
402    Producer,
403    Exchange,
404    /// State kind determined at runtime by handler return value.
405    Dynamic,
406}
407
408/// A handler function for a unary RPC method.
409pub type UnaryHandler =
410    Arc<dyn Fn(&Request, &CallContext) -> Result<Option<RecordBatch>> + Send + Sync>;
411
412/// A handler function for a streaming RPC method.
413pub type StreamHandler = Arc<dyn Fn(&Request, &CallContext) -> Result<StreamResult> + Send + Sync>;
414
415/// Fluent builder for [`RpcServer`] with describe/identity/version knobs.
416#[derive(Default)]
417pub struct RpcServerBuilder {
418    server_id: Option<String>,
419    server_version: Option<String>,
420    protocol_name: Option<String>,
421    protocol_version: Option<String>,
422    enable_describe: bool,
423    dispatch_hook: Option<Arc<dyn crate::hooks::DispatchHook>>,
424    on_serve_start: Option<crate::transport::ServeStartHook>,
425    #[cfg(feature = "http")]
426    external_config: Option<Arc<crate::external::ExternalLocationConfig>>,
427}
428
429impl RpcServerBuilder {
430    pub fn server_id(mut self, id: impl Into<String>) -> Self {
431        self.server_id = Some(id.into());
432        self
433    }
434
435    pub fn server_version(mut self, v: impl Into<String>) -> Self {
436        self.server_version = Some(v.into());
437        self
438    }
439
440    pub fn protocol_name(mut self, name: impl Into<String>) -> Self {
441        self.protocol_name = Some(name.into());
442        self
443    }
444
445    /// Operator-supplied free-form protocol-contract version label, reported
446    /// in access-log records as ``protocol_version``. Complementary to
447    /// (build) ``server_version``.
448    pub fn protocol_version(mut self, v: impl Into<String>) -> Self {
449        self.protocol_version = Some(v.into());
450        self
451    }
452
453    pub fn enable_describe(mut self, enabled: bool) -> Self {
454        self.enable_describe = enabled;
455        self
456    }
457
458    pub fn with_hook(mut self, hook: Arc<dyn crate::hooks::DispatchHook>) -> Self {
459        self.dispatch_hook = Some(hook);
460        self
461    }
462
463    /// Register a one-shot lifecycle hook fired before the first
464    /// request is dispatched on each (kind, capabilities) combination.
465    /// Mirrors Python's `on_serve_start` duck-typed protocol.
466    ///
467    /// The hook runs synchronously on the thread that first observes
468    /// the transport binding. Subsequent calls to
469    /// [`RpcServer::notify_transport`] with the same `(kind, caps)`
470    /// are no-ops; calls with a different combination re-fire the hook
471    /// (matches Python's behaviour for test paths that re-bind).
472    pub fn on_serve_start(mut self, hook: crate::transport::ServeStartHook) -> Self {
473        self.on_serve_start = Some(hook);
474        self
475    }
476
477    /// Enable automatic externalization of large unary results and stream
478    /// output batches. Feature-gated on `http` (where the compression +
479    /// fetcher deps already live).
480    #[cfg(feature = "http")]
481    pub fn with_external_location(mut self, cfg: crate::external::ExternalLocationConfig) -> Self {
482        self.external_config = Some(Arc::new(cfg));
483        self
484    }
485
486    pub fn build(self) -> RpcServer {
487        RpcServer {
488            methods: HashMap::new(),
489            server_id: self.server_id.unwrap_or_else(crate::util::short_random_id),
490            server_version: self.server_version.unwrap_or_default(),
491            protocol_name: self.protocol_name.unwrap_or_default(),
492            protocol_version: self.protocol_version.unwrap_or_default(),
493            protocol_hash: std::sync::OnceLock::new(),
494            describe_enabled: self.enable_describe,
495            dispatch_hook: self.dispatch_hook,
496            on_serve_start: self.on_serve_start,
497            transport_state: Mutex::new(None),
498            #[cfg(feature = "http")]
499            external_config: self.external_config,
500        }
501    }
502}
503
504/// Describes one RPC method — the metadata required both for dispatch and
505/// introspection via `__describe__`.
506///
507/// Build via [`MethodInfo::unary`] / [`MethodInfo::stream`] and attach
508/// additional describe-time metadata through the builder helpers
509/// (`.doc`, `.param_type`, `.param_default`, `.param_doc`, `.header_schema`).
510pub struct MethodInfo {
511    pub name: String,
512    pub method_type: MethodType,
513    /// Schema of the request parameters (one row).
514    pub params_schema: SchemaRef,
515    /// Schema of the unary result; empty for streams.
516    pub result_schema: SchemaRef,
517    /// For streams that emit a typed header, the header batch schema.
518    pub header_schema: Option<SchemaRef>,
519    /// Method-level docstring (the first line of Python's docstring).
520    pub doc: Option<String>,
521    /// Parameter type names in source order, matching the Python describe
522    /// wire format ("str", "int", "list[str]", "Point", "str | None").
523    pub param_types: Vec<(String, String)>,
524    /// Parameter defaults; values are anything JSON-serializable.
525    pub param_defaults: Vec<(String, serde_json::Value)>,
526    /// Per-parameter documentation (matches the Python `param_docs_json`).
527    pub param_docs: Vec<(String, String)>,
528    /// Whether the method has a non-void return. `false` for streams/void.
529    pub has_return: bool,
530    pub unary: Option<UnaryHandler>,
531    pub stream: Option<StreamHandler>,
532    /// Decoder that reconstructs the method's `StreamStateKind` from a byte
533    /// slice produced by `ProducerState::encode_state` /
534    /// `ExchangeState::encode_state`. Required for HTTP streaming (the
535    /// stateless-worker model); `None` for unary methods and for streams
536    /// that will only ever run over pipe/unix.
537    pub state_decoder: Option<StateDecoder>,
538}
539
540/// Decoder that reconstructs a concrete streaming state from its
541/// serialized bytes, used by the HTTP transport on continuation requests.
542pub type StateDecoder = Arc<dyn Fn(&[u8]) -> Result<crate::stream::StreamStateKind> + Send + Sync>;
543
544impl MethodInfo {
545    /// Start building a unary method registration.
546    pub fn unary(
547        name: impl Into<String>,
548        params_schema: SchemaRef,
549        result_schema: SchemaRef,
550        handler: impl Fn(&Request, &CallContext) -> Result<Option<RecordBatch>> + Send + Sync + 'static,
551    ) -> Self {
552        let has_return = !result_schema.fields().is_empty();
553        Self {
554            name: name.into(),
555            method_type: MethodType::Unary,
556            params_schema,
557            result_schema,
558            header_schema: None,
559            doc: None,
560            param_types: Vec::new(),
561            param_defaults: Vec::new(),
562            param_docs: Vec::new(),
563            has_return,
564            unary: Some(Arc::new(handler)),
565            stream: None,
566            state_decoder: None,
567        }
568    }
569
570    /// Start building a streaming method registration.
571    ///
572    /// **Note:** this form registers the method without a state decoder,
573    /// so it will work for pipe/unix transports but HTTP continuation
574    /// requests will fail. Attach a decoder via
575    /// [`MethodInfo::with_state_decoder`] with
576    /// [`producer_decoder`](crate::stream::producer_decoder) /
577    /// [`exchange_decoder`](crate::stream::exchange_decoder) when HTTP is
578    /// enabled.
579    pub fn stream(
580        name: impl Into<String>,
581        method_type: MethodType,
582        params_schema: SchemaRef,
583        handler: impl Fn(&Request, &CallContext) -> Result<StreamResult> + Send + Sync + 'static,
584    ) -> Self {
585        assert!(
586            matches!(
587                method_type,
588                MethodType::Producer | MethodType::Exchange | MethodType::Dynamic
589            ),
590            "stream methods must be Producer / Exchange / Dynamic"
591        );
592        Self {
593            name: name.into(),
594            method_type,
595            params_schema,
596            result_schema: empty_schema(),
597            header_schema: None,
598            doc: None,
599            param_types: Vec::new(),
600            param_defaults: Vec::new(),
601            param_docs: Vec::new(),
602            has_return: false,
603            unary: None,
604            stream: Some(Arc::new(handler)),
605            state_decoder: None,
606        }
607    }
608
609    /// Attach a state decoder function. See [`StateDecoder`].
610    pub fn with_state_decoder(mut self, decoder: StateDecoder) -> Self {
611        self.state_decoder = Some(decoder);
612        self
613    }
614
615    pub fn doc(mut self, s: impl Into<String>) -> Self {
616        self.doc = Some(s.into());
617        self
618    }
619
620    pub fn param_type(mut self, param: impl Into<String>, ty: impl Into<String>) -> Self {
621        self.param_types.push((param.into(), ty.into()));
622        self
623    }
624
625    pub fn param_default(mut self, param: impl Into<String>, value: serde_json::Value) -> Self {
626        self.param_defaults.push((param.into(), value));
627        self
628    }
629
630    pub fn param_doc(mut self, param: impl Into<String>, doc: impl Into<String>) -> Self {
631        self.param_docs.push((param.into(), doc.into()));
632        self
633    }
634
635    pub fn header_schema(mut self, schema: SchemaRef) -> Self {
636        self.header_schema = Some(schema);
637        self
638    }
639}
640
641/// The RPC server — holds method registrations and dispatches requests.
642pub struct RpcServer {
643    methods: HashMap<String, MethodInfo>,
644    pub server_id: String,
645    pub(crate) server_version: String,
646    pub(crate) protocol_name: String,
647    pub(crate) protocol_version: String,
648    pub(crate) protocol_hash: std::sync::OnceLock<String>,
649    pub(crate) describe_enabled: bool,
650    pub(crate) dispatch_hook: Option<Arc<dyn crate::hooks::DispatchHook>>,
651    /// Optional one-shot lifecycle hook fired on the first
652    /// [`notify_transport`](Self::notify_transport) per (kind, caps).
653    on_serve_start: Option<crate::transport::ServeStartHook>,
654    /// Coarse identifier of the bound transport, populated by
655    /// [`notify_transport`](Self::notify_transport).
656    transport_state: Mutex<
657        Option<(
658            crate::transport::TransportKind,
659            crate::transport::TransportCapabilities,
660        )>,
661    >,
662    #[cfg(feature = "http")]
663    pub(crate) external_config: Option<Arc<crate::external::ExternalLocationConfig>>,
664}
665
666impl RpcServer {
667    /// Create a new `RpcServer`. For richer configuration, use [`RpcServer::builder`].
668    pub fn new(server_id: impl Into<String>) -> Self {
669        Self::builder().server_id(server_id).build()
670    }
671
672    /// Create a new builder.
673    pub fn builder() -> RpcServerBuilder {
674        RpcServerBuilder::default()
675    }
676
677    pub fn protocol_name(&self) -> &str {
678        &self.protocol_name
679    }
680
681    pub fn describe_enabled(&self) -> bool {
682        self.describe_enabled
683    }
684
685    pub fn server_version(&self) -> &str {
686        &self.server_version
687    }
688
689    pub fn protocol_version(&self) -> &str {
690        &self.protocol_version
691    }
692
693    /// SHA-256 hex digest of the canonical __describe__ payload. Computed
694    /// lazily on first call and cached.
695    pub fn protocol_hash(&self) -> &str {
696        self.protocol_hash.get_or_init(|| {
697            match crate::introspect::build_describe(
698                &self.protocol_name,
699                &self.methods,
700                &self.server_id,
701                &self.protocol_version,
702            ) {
703                Ok((_, md)) => md
704                    .get(crate::metadata::PROTOCOL_HASH_KEY)
705                    .cloned()
706                    .unwrap_or_default(),
707                Err(_) => String::new(),
708            }
709        })
710    }
711
712    #[cfg(feature = "http")]
713    pub fn external_config(&self) -> Option<&Arc<crate::external::ExternalLocationConfig>> {
714        self.external_config.as_ref()
715    }
716
717    /// Currently-bound [`TransportKind`](crate::transport::TransportKind),
718    /// or `None` before the framework has observed a transport. Set by
719    /// [`notify_transport`](Self::notify_transport).
720    pub fn transport_kind(&self) -> Option<crate::transport::TransportKind> {
721        lock_ok(&self.transport_state).as_ref().map(|(k, _)| *k)
722    }
723
724    /// Currently-advertised [`TransportCapabilities`](crate::transport::TransportCapabilities).
725    /// Empty (all-false) before a transport is bound and for transports
726    /// without extra capabilities.
727    pub fn transport_capabilities(&self) -> crate::transport::TransportCapabilities {
728        lock_ok(&self.transport_state)
729            .as_ref()
730            .map(|(_, c)| *c)
731            .unwrap_or_default()
732    }
733
734    /// Bind the server to a transport, firing `on_serve_start` once per
735    /// `(kind, caps)` combination. Subsequent calls with the same
736    /// combination are cheap no-ops (the common case where transport
737    /// glue invokes this on every request). A different combination
738    /// updates the bound state and re-fires the hook — matches the
739    /// Python `_notify_transport` contract.
740    ///
741    /// Call this from each transport entry point:
742    /// - stdio / pipe `main`: once before [`serve`](Self::serve)
743    /// - Unix accept loop: once per process
744    /// - HTTP request handler: every request (idempotent)
745    pub fn notify_transport(
746        &self,
747        kind: crate::transport::TransportKind,
748        caps: crate::transport::TransportCapabilities,
749    ) {
750        let hook = {
751            let mut guard = lock_ok(&self.transport_state);
752            if let Some((cur_kind, cur_caps)) = guard.as_ref() {
753                if *cur_kind == kind && *cur_caps == caps {
754                    return;
755                }
756            }
757            *guard = Some((kind, caps));
758            self.on_serve_start.clone()
759        };
760        if let Some(h) = hook {
761            h(kind, &caps);
762        }
763    }
764
765    /// Register a method described by a [`MethodInfo`].
766    pub fn register(&mut self, info: MethodInfo) {
767        self.methods.insert(info.name.clone(), info);
768    }
769
770    /// Convenience wrapper for the old positional API — equivalent to
771    /// `register(MethodInfo::unary(name, empty_schema(), result_schema, handler))`.
772    /// Prefer [`MethodInfo::unary`] + [`RpcServer::register`] for new code.
773    pub fn register_unary(
774        &mut self,
775        name: impl Into<String>,
776        result_schema: SchemaRef,
777        handler: impl Fn(&Request, &CallContext) -> Result<Option<RecordBatch>> + Send + Sync + 'static,
778    ) {
779        self.register(MethodInfo::unary(
780            name,
781            empty_schema(),
782            result_schema,
783            handler,
784        ));
785    }
786
787    /// Convenience wrapper for the old positional API — equivalent to
788    /// `register(MethodInfo::stream(name, method_type, empty_schema(), handler))`.
789    /// Prefer [`MethodInfo::stream`] + [`RpcServer::register`] for new code.
790    pub fn register_stream(
791        &mut self,
792        name: impl Into<String>,
793        method_type: MethodType,
794        handler: impl Fn(&Request, &CallContext) -> Result<StreamResult> + Send + Sync + 'static,
795    ) {
796        self.register(MethodInfo::stream(
797            name,
798            method_type,
799            empty_schema(),
800            handler,
801        ));
802    }
803
804    pub fn method(&self, name: &str) -> Option<&MethodInfo> {
805        self.methods.get(name)
806    }
807
808    pub fn methods(&self) -> &HashMap<String, MethodInfo> {
809        &self.methods
810    }
811
812    pub fn method_names(&self) -> Vec<&str> {
813        self.sorted_method_names()
814    }
815
816    /// Method names sorted alphabetically. Preferred over `methods().keys()`
817    /// when order matters (introspection / describe / HTML rendering).
818    pub fn sorted_method_names(&self) -> Vec<&str> {
819        let mut names: Vec<_> = self.methods.keys().map(String::as_str).collect();
820        names.sort();
821        names
822    }
823
824    /// Run the serve loop over a single reader/writer pair (pipe or socket).
825    ///
826    /// Reads are **blocking with no timeout** — a peer that opens the
827    /// connection and then stalls pins this thread until it sends data,
828    /// EOFs, or resets. stdio/pipe has no timeout API, so that transport
829    /// is trusted-peer-only (see also the SHM module docs). On a socket
830    /// transport, the caller owns the stream and **should** set a read
831    /// timeout (e.g. `UnixStream::set_read_timeout`) before handing it
832    /// here; a `TimedOut`/`WouldBlock` error then cleanly ends the
833    /// connection via the error path below.
834    pub fn serve<R: Read, W: Write>(&self, mut r: R, mut w: W) {
835        // Cache the client's dynamically-advertised SHM segment for the
836        // life of the connection so later offset-only request/data batches
837        // resolve against it (see [`ConnectionShm`]).
838        let mut conn_shm = ConnectionShm::default();
839        loop {
840            match self.serve_one_conn(&mut r, &mut w, Some(&mut conn_shm)) {
841                Ok(keep_going) => {
842                    if !keep_going {
843                        return;
844                    }
845                }
846                Err(e) => {
847                    // A frame-level error (malformed request, IO error,
848                    // peer reset) ends the connection. Log it so a
849                    // daemonized listener has diagnostics — silently
850                    // returning made transient and hostile-input
851                    // failures indistinguishable from a clean EOF.
852                    tracing::warn!(
853                        target: "vgi_rpc.server",
854                        error = %e,
855                        "serve loop terminating connection on error"
856                    );
857                    return;
858                }
859            }
860        }
861    }
862
863    /// Like [`Self::serve`], but checks `shutdown` between requests and exits
864    /// cleanly when it returns `true`. Useful for daemonized pipe/unix
865    /// listeners that want to drain the in-flight request before exiting
866    /// on SIGTERM. Blocking reads still must terminate via EOF/peer-close
867    /// — this is an *advisory* signal checked at request boundaries.
868    pub fn serve_with_shutdown<R, W, F>(&self, mut r: R, mut w: W, shutdown: F)
869    where
870        R: Read,
871        W: Write,
872        F: Fn() -> bool,
873    {
874        let mut conn_shm = ConnectionShm::default();
875        loop {
876            if shutdown() {
877                return;
878            }
879            match self.serve_one_conn(&mut r, &mut w, Some(&mut conn_shm)) {
880                Ok(true) => {}
881                _ => return,
882            }
883        }
884    }
885
886    /// Handle one request. Returns `Ok(true)` to continue, `Ok(false)` on EOS/EOF.
887    ///
888    /// With no per-connection SHM cache wired (direct `serve_one` calls), a
889    /// client-advertised segment is attached and detached per call instead
890    /// of being cached across requests (the [`serve`](Self::serve) loop
891    /// supplies the cache).
892    pub fn serve_one<R: Read, W: Write>(&self, r: &mut R, w: &mut W) -> Result<bool> {
893        self.serve_one_conn(r, w, None)
894    }
895
896    fn serve_one_conn<R: Read, W: Write>(
897        &self,
898        r: &mut R,
899        w: &mut W,
900        shm_cache: Option<&mut ConnectionShm>,
901    ) -> Result<bool> {
902        let result = self._serve_one(r, w, shm_cache);
903        let _ = w.flush();
904        result
905    }
906
907    fn _serve_one<R: Read, W: Write>(
908        &self,
909        r: &mut R,
910        w: &mut W,
911        mut shm_cache: Option<&mut ConnectionShm>,
912    ) -> Result<bool> {
913        let (req, request_used_shm) = match self.read_request(r, shm_cache.as_deref_mut())? {
914            Some(rq) => rq,
915            None => return Ok(false),
916        };
917
918        // __transport_options__ — framework transport-capability handshake,
919        // handled before method dispatch (not a registered method, so it never
920        // appears in `methods` / `__describe__`, and doesn't perturb the
921        // protocol hash). Capabilities ride as response metadata; the response
922        // batch is empty. Always available, including to version-mismatched
923        // clients, since it is the negotiation they perform before `init`.
924        if req.method == crate::transport_options::TRANSPORT_OPTIONS_METHOD_NAME {
925            let mut md = crate::transport_options::worker_transport_metadata();
926            md.insert(REQUEST_VERSION_KEY.to_string(), REQUEST_VERSION.to_string());
927            md.insert(SERVER_ID_KEY.to_string(), self.server_id.clone());
928            let schema = empty_schema();
929            let batch = empty_batch(&schema)?;
930            let mut sw = StreamWriter::new(w, &schema)?;
931            sw.write(&batch, Some(&md))?;
932            sw.finish()?;
933            return Ok(true);
934        }
935
936        // Enforce application protocol-version compatibility: the client sends
937        // its `vgi_rpc.protocol_version`; if its MAJOR differs from the
938        // server's enforced version, reject (mirrors the Python framework).
939        if !self.protocol_version.is_empty() {
940            if let Some(client_v) = md_get(&req.metadata, crate::metadata::PROTOCOL_VERSION_KEY) {
941                let major = |v: &str| v.split('.').next().unwrap_or("").to_string();
942                if major(client_v) != major(&self.protocol_version) {
943                    let err = RpcError::version_error(format!(
944                        "protocol_version mismatch: client {:?} is incompatible with server {:?}",
945                        client_v, self.protocol_version
946                    ));
947                    write_error_stream(w, &empty_schema(), &err, &self.server_id, &req.request_id)?;
948                    return Ok(true);
949                }
950            }
951        }
952
953        let ctx = CallContext::for_request(self, &req);
954
955        let stats = Arc::new(Mutex::new(crate::hooks::CallStatistics::default()));
956        // Record the unary request batch as input stats (one row).
957        {
958            let mut s = lock_ok(&stats);
959            s.input_batches = 1;
960            s.input_rows = req.batch.num_rows() as u64;
961        }
962
963        // Built-in __describe__ introspection.
964        if self.describe_enabled && req.method == crate::introspect::DESCRIBE_METHOD_NAME {
965            match crate::introspect::build_describe(
966                &self.protocol_name,
967                &self.methods,
968                &self.server_id,
969                &self.protocol_version,
970            ) {
971                Ok((batch, md)) => {
972                    crate::introspect::write_describe_response(w, &batch, &md)?;
973                }
974                Err(err) => {
975                    write_error_stream(w, &empty_schema(), &err, &self.server_id, &req.request_id)?;
976                }
977            }
978            return Ok(true);
979        }
980
981        let Some(info) = self.methods.get(&req.method) else {
982            let names = self.sorted_method_names();
983            let msg = format!(
984                "Unknown method: '{}'. Available methods: {:?}",
985                req.method, names
986            );
987            write_error_stream(
988                w,
989                &empty_schema(),
990                &RpcError::attribute_error(msg),
991                &self.server_id,
992                &req.request_id,
993            )?;
994            return Ok(true);
995        };
996
997        let method_type = match info.method_type {
998            MethodType::Unary => "unary",
999            _ => "stream",
1000        };
1001        // The `DispatchInfo` — a large struct with many owned clones — plus
1002        // the request re-serialization below are needed *only* when a
1003        // dispatch hook is registered. Build nothing on the hookless path.
1004        // `mut` is needed only by the http-gated externalized-bytes stamp
1005        // further down; without that feature nothing mutates this.
1006        #[cfg_attr(not(feature = "http"), allow(unused_mut))]
1007        let mut dispatch_info = self.dispatch_hook.as_ref().map(|_| {
1008            let mut di =
1009                crate::hooks::DispatchInfo::from_request(self, &req, method_type, &ctx.auth);
1010            // Best-effort capture of self-contained Arrow IPC bytes of the
1011            // request batch for access-log `request_data`. Failures here must
1012            // not abort dispatch — observability is non-essential.
1013            if let Ok(bytes) = serialize_request_batch(&req.batch) {
1014                di.request_data = bytes;
1015            }
1016            if method_type == "stream" {
1017                di.stream_id = crate::access_log::random_stream_id();
1018            }
1019            di
1020        });
1021        let hook_token = match (self.dispatch_hook.as_ref(), dispatch_info.as_ref()) {
1022            (Some(h), Some(di)) => Some(h.on_dispatch_start(di)),
1023            _ => None,
1024        };
1025
1026        let mut app_err: Option<RpcError> = None;
1027        // Determine the SHM segment for this call's data plane (resolving
1028        // input batches, routing output/response batches). Crucially, only
1029        // route the *response* through shm when the client signalled shm for
1030        // THIS exchange — it sent the request via a shm pointer
1031        // (SHM_OFFSET_KEY) or advertised the segment (SHM_SEGMENT_NAME_KEY).
1032        // The C++ client resolves shm responses only for those methods; for
1033        // plain inline requests (bind, catalog_*, transaction_*) it expects
1034        // an inline response and reports a shm-routed one as empty. With no
1035        // cache wired (a direct `serve_one` call) fall back to the original
1036        // per-call attach, which only succeeds when this request names the
1037        // segment.
1038        let dynamic_shm: Option<ShmSegment>;
1039        let shm_ref: Option<&ShmSegment> = match shm_cache {
1040            Some(cache) => {
1041                if request_used_shm {
1042                    cache.segment()
1043                } else {
1044                    None
1045                }
1046            }
1047            None => {
1048                dynamic_shm = maybe_attach_shm(&req.metadata);
1049                dynamic_shm.as_ref()
1050            }
1051        };
1052        // Externalised uploads leave only a pointer batch on the wire, so
1053        // they have to be counted where they happen rather than where the
1054        // response is framed. Scoped to this dispatch; the serve loop is
1055        // synchronous on one thread per connection.
1056        #[cfg(feature = "http")]
1057        let externalized = crate::external::ExternalizedScope::new();
1058        match info.method_type {
1059            MethodType::Unary => {
1060                self.serve_unary(w, &req, info, &ctx, &stats, &mut app_err, shm_ref)?
1061            }
1062            MethodType::Producer | MethodType::Exchange | MethodType::Dynamic => {
1063                self.serve_stream(r, w, &req, info, &ctx, &stats, &mut app_err, shm_ref)?
1064            }
1065        }
1066        #[cfg(feature = "http")]
1067        let externalized_bytes = externalized.finish();
1068        #[cfg(feature = "http")]
1069        if let Some(di) = dispatch_info.as_mut() {
1070            di.externalized_bytes = externalized_bytes;
1071        }
1072        // A per-call `dynamic_shm` (if any) is dropped here, releasing our
1073        // mmap of the client-owned segment without unlinking it; a cached
1074        // segment lives until the connection ends.
1075
1076        if let (Some(hook), Some(di)) = (self.dispatch_hook.as_ref(), dispatch_info.as_ref()) {
1077            let token = hook_token.unwrap_or(0);
1078            let final_stats = lock_ok(&stats).clone();
1079            hook.on_dispatch_end(token, di, app_err.as_ref(), &final_stats);
1080        }
1081        Ok(true)
1082    }
1083
1084    /// Read one request off the wire. Returns the parsed request plus
1085    /// whether the client signalled SHM for this exchange (the request was
1086    /// a shm pointer, or advertised a segment) — which gates whether the
1087    /// response/data plane may route through shm.
1088    fn read_request<R: Read>(
1089        &self,
1090        r: &mut R,
1091        shm_cache: Option<&mut ConnectionShm>,
1092    ) -> Result<Option<(Request, bool)>> {
1093        let mut reader = match StreamReader::new(r) {
1094            Ok(r) => r,
1095            Err(e) => {
1096                // EOF at request boundary is normal
1097                let msg = e.message.to_lowercase();
1098                if msg.contains("empty ipc stream") || msg.contains("eof") {
1099                    return Ok(None);
1100                }
1101                return Err(e);
1102            }
1103        };
1104        let (batch, metadata) = match reader.read_next()? {
1105            Some(b) => b,
1106            None => return Ok(None),
1107        };
1108        reader.drain()?;
1109        // Computed on the wire metadata, before pointer resolution strips
1110        // the offset key.
1111        let request_used_shm =
1112            metadata.contains_key(SHM_OFFSET_KEY) || metadata.contains_key(SHM_SEGMENT_NAME_KEY);
1113        // A client (e.g. the C++ extension) may route the single-row request
1114        // batch through the shm side channel above a size threshold, so the
1115        // inline batch arrives as a 0-row pointer; resolve it back to its
1116        // real columns before the single-row guard in `from_read_batch`.
1117        // The segment comes from the connection cache — refreshed from any
1118        // name this request's own metadata advertises — or, with no cache
1119        // wired, a one-shot attach released before returning.
1120        #[cfg(feature = "shm")]
1121        let (batch, metadata) = if is_shm_pointer_batch(&batch, &metadata) {
1122            let one_shot: Option<ShmSegment>;
1123            let seg: Option<&ShmSegment> = match shm_cache {
1124                Some(cache) => {
1125                    cache.refresh(&metadata);
1126                    cache.segment()
1127                }
1128                None => {
1129                    one_shot = maybe_attach_shm(&metadata);
1130                    one_shot.as_ref()
1131                }
1132            };
1133            let resolved = resolve_shm_batch(batch, metadata, seg)?;
1134            // The resolved batch copies the region bytes out, so the slot
1135            // is dead once materialized — free it for the client.
1136            if let (Some(off), Some(seg)) = (resolved.release_offset, seg) {
1137                let _ = seg.free(off);
1138            }
1139            (resolved.batch, resolved.metadata)
1140        } else {
1141            if let Some(cache) = shm_cache {
1142                cache.refresh(&metadata);
1143            }
1144            (batch, metadata)
1145        };
1146        #[cfg(not(feature = "shm"))]
1147        let _ = shm_cache;
1148        Ok(Some((
1149            Request::from_read_batch(batch, metadata, true)?,
1150            request_used_shm,
1151        )))
1152    }
1153
1154    #[allow(clippy::too_many_arguments)]
1155    fn serve_unary<W: Write>(
1156        &self,
1157        w: &mut W,
1158        req: &Request,
1159        info: &MethodInfo,
1160        ctx: &CallContext,
1161        stats: &Arc<Mutex<crate::hooks::CallStatistics>>,
1162        app_err: &mut Option<RpcError>,
1163        #[cfg_attr(not(feature = "shm"), allow(unused_variables))] shm: Option<&ShmSegment>,
1164    ) -> Result<()> {
1165        // A panic in handler code is converted to an `RpcError` and
1166        // flows into the error-envelope path below, rather than
1167        // unwinding through the serve loop.
1168        let result = call_guard(|| (info.unary.as_ref().unwrap())(req, ctx)).and_then(|r| r);
1169        let logs = ctx.drain_logs();
1170        let mut envelope = EnvelopeMeta::new(&self.server_id, &req.request_id);
1171        match result {
1172            Ok(maybe_batch) => {
1173                let mut sw = StreamWriter::new(w, &info.result_schema)?;
1174                for log in logs {
1175                    let md = envelope.log(&log);
1176                    sw.write(&empty_batch(&info.result_schema)?, Some(md))?;
1177                }
1178                let out_batch = match maybe_batch {
1179                    Some(b) => b,
1180                    None => empty_batch(&info.result_schema)?,
1181                };
1182                {
1183                    let mut s = lock_ok(stats);
1184                    s.output_batches = 1;
1185                    s.output_rows = out_batch.num_rows() as u64;
1186                }
1187                #[cfg(feature = "shm")]
1188                if let Some(seg) = shm {
1189                    let (written, written_md) =
1190                        maybe_write_to_shm(out_batch.clone(), Metadata::new(), Some(seg))?;
1191                    if written_md.contains_key(crate::metadata::SHM_OFFSET_KEY) {
1192                        sw.write(&written, Some(&written_md))?;
1193                        sw.finish()?;
1194                        return Ok(());
1195                    }
1196                }
1197                #[cfg(feature = "http")]
1198                if let Some(cfg) = self.external_config.as_ref() {
1199                    // Declare the response stream's schema on the payload,
1200                    // not the batch's: `StreamWriter::write` never reconciles
1201                    // the two, so a cosmetic difference is invisible inline and
1202                    // a hard client-side mismatch once externalised.
1203                    if let Ok(Some((ptr, md))) = crate::external::maybe_externalize_batch(
1204                        &out_batch,
1205                        &info.result_schema,
1206                        None,
1207                        cfg,
1208                    ) {
1209                        sw.write(&ptr, Some(&md))?;
1210                        sw.finish()?;
1211                        return Ok(());
1212                    }
1213                }
1214                #[cfg(not(feature = "shm"))]
1215                let _ = shm;
1216                sw.write(&out_batch, None)?;
1217                sw.finish()?;
1218            }
1219            Err(err) => {
1220                let mut sw = StreamWriter::new(w, &info.result_schema)?;
1221                for log in logs {
1222                    let md = envelope.log(&log);
1223                    sw.write(&empty_batch(&info.result_schema)?, Some(md))?;
1224                }
1225                let md = envelope.error(&err);
1226                sw.write(&empty_batch(&info.result_schema)?, Some(md))?;
1227                sw.finish()?;
1228                *app_err = Some(err);
1229            }
1230        }
1231        Ok(())
1232    }
1233
1234    #[allow(clippy::too_many_arguments)]
1235    #[allow(clippy::too_many_arguments)]
1236    fn serve_stream<R: Read, W: Write>(
1237        &self,
1238        r: &mut R,
1239        w: &mut W,
1240        req: &Request,
1241        info: &MethodInfo,
1242        ctx: &CallContext,
1243        stats: &Arc<Mutex<crate::hooks::CallStatistics>>,
1244        app_err: &mut Option<RpcError>,
1245        #[cfg_attr(not(feature = "shm"), allow(unused_variables))] shm: Option<&ShmSegment>,
1246    ) -> Result<()> {
1247        let init_result = call_guard(|| (info.stream.as_ref().unwrap())(req, ctx)).and_then(|r| r);
1248        let init_logs = ctx.drain_logs();
1249        let stream = match init_result {
1250            Ok(s) => s,
1251            Err(err) => {
1252                // Init error: write as unary-style error stream.
1253                let output_schema = info.result_schema.clone();
1254                let mut sw = StreamWriter::new(w, &output_schema)?;
1255                let mut envelope = EnvelopeMeta::new(&self.server_id, &req.request_id);
1256                for log in init_logs {
1257                    let md = envelope.log(&log);
1258                    sw.write(&empty_batch(&output_schema)?, Some(md))?;
1259                }
1260                let md = envelope.error(&err);
1261                sw.write(&empty_batch(&output_schema)?, Some(md))?;
1262                sw.finish()?;
1263                // Drain any client input (ticks / exchange batches) so the transport
1264                // is clean for the next request.
1265                let _ = drain_input(r);
1266                *app_err = Some(err);
1267                return Ok(());
1268            }
1269        };
1270
1271        let StreamResult {
1272            output_schema,
1273            input_schema,
1274            state,
1275            header,
1276            header_metadata,
1277        } = stream;
1278
1279        // Reused across every log/error envelope in this stream: the stable
1280        // server_id/request_id entries are allocated once and each envelope
1281        // only overwrites the transient level/message/extra values.
1282        let mut envelope = EnvelopeMeta::new(&self.server_id, &req.request_id);
1283
1284        // Write header as its own IPC stream if present.
1285        let wrote_header = header.is_some();
1286        if let Some(header_batch) = header {
1287            let mut hw = StreamWriter::new(&mut *w, header_batch.schema().as_ref())?;
1288            for log in &init_logs {
1289                let md = envelope.log(log);
1290                hw.write(&empty_batch(header_batch.schema().as_ref())?, Some(md))?;
1291            }
1292            hw.write(&header_batch, header_metadata.as_ref())?;
1293            hw.finish()?;
1294        }
1295        let _ = w.flush();
1296
1297        // Open the output stream first — the client opens the output reader
1298        // before the next tick is read back here, so we must make the schema
1299        // available without waiting on input.
1300        let mut out_writer = StreamWriter::new(&mut *w, output_schema.as_ref())?;
1301        out_writer.flush()?;
1302
1303        // Open the input stream (ticks for producer, real batches for exchange).
1304        let mut input_reader = StreamReader::new(&mut *r)?;
1305
1306        // A zero-row batch on the output schema, reused for every log/error
1307        // envelope in this stream (it's immutable and identical each time)
1308        // instead of rebuilding it per log line / per tick.
1309        let empty_out = empty_batch(output_schema.as_ref())?;
1310
1311        // If we didn't already write init logs into a header stream, write them now.
1312        if !wrote_header {
1313            for log in &init_logs {
1314                let md = envelope.log(log);
1315                out_writer.write(&empty_out, Some(md))?;
1316            }
1317        }
1318        let _ = header_metadata;
1319
1320        let mut state = state;
1321        let mut cancelled = false;
1322
1323        'lockstep: loop {
1324            let read = match input_reader.read_next() {
1325                Ok(x) => x,
1326                Err(_) => break,
1327            };
1328            let Some((input_batch, input_md)) = read else {
1329                break;
1330            };
1331
1332            // Resolve SHM pointer batches before anything else — the
1333            // schema cast / cancel check / handler all expect the real
1334            // batch. Free the region as soon as it's been deserialized
1335            // (we copy on read, so no live borrow remains).
1336            #[cfg(feature = "shm")]
1337            let (input_batch, input_md) = {
1338                let resolved = resolve_shm_batch(input_batch, input_md, shm)?;
1339                if let (Some(off), Some(seg)) = (resolved.release_offset, shm) {
1340                    let _ = seg.free(off);
1341                }
1342                (resolved.batch, resolved.metadata)
1343            };
1344
1345            {
1346                let mut s = lock_ok(stats);
1347                s.input_batches += 1;
1348                s.input_rows += input_batch.num_rows() as u64;
1349            }
1350
1351            // Read the cancel flag before moving `input_md` into the context.
1352            let is_cancel = md_get(&input_md, CANCEL_KEY).is_some();
1353
1354            // Surface this tick's input metadata (e.g. dynamic pushdown
1355            // filters) to the producer/exchange handler via the context.
1356            // `input_md` is not used past this point, so move it in rather
1357            // than deep-clone the map every tick.
1358            *lock_ok(&ctx.tick_metadata) = input_md;
1359
1360            // Cancellation signal.
1361            if is_cancel {
1362                cancelled = true;
1363                match &mut state {
1364                    StreamStateKind::Producer(p) => p.on_cancel(ctx),
1365                    StreamStateKind::Exchange(e) => e.on_cancel(ctx),
1366                }
1367                break;
1368            }
1369
1370            // Cast input schema to expected schema when required.
1371            let casted = match &input_schema {
1372                Some(expected) if input_batch.schema() != *expected => {
1373                    match cast_batch(&input_batch, expected) {
1374                        Ok(b) => b,
1375                        Err(e) => {
1376                            let md = envelope.error(&e);
1377                            out_writer.write(&empty_out, Some(md))?;
1378                            break 'lockstep;
1379                        }
1380                    }
1381                }
1382                _ => input_batch,
1383            };
1384
1385            let mut out = OutputCollector::new(output_schema.clone(), input_schema.is_none());
1386
1387            let iter_result = call_guard(|| match &mut state {
1388                StreamStateKind::Producer(p) => p.produce(&mut out, ctx),
1389                StreamStateKind::Exchange(e) => e.exchange(&casted, &mut out, ctx),
1390            })
1391            .and_then(|r| r);
1392
1393            // Flush any iteration-level logs first (logs appended during produce/exchange).
1394            let iter_logs = ctx.drain_logs();
1395            for log in iter_logs {
1396                let md = envelope.log(&log);
1397                out_writer.write(&empty_out, Some(md))?;
1398            }
1399
1400            if let Err(err) = iter_result {
1401                let md = envelope.error(&err);
1402                out_writer.write(&empty_out, Some(md))?;
1403                *app_err = Some(err);
1404                break;
1405            }
1406
1407            let finished = out.finished();
1408
1409            // Flush collected emitted items (logs added via OutputCollector, then batches).
1410            for item in out.items.drain(..) {
1411                match item {
1412                    Emitted::Log(log) => {
1413                        let md = envelope.log(&log);
1414                        out_writer.write(&empty_out, Some(md))?;
1415                    }
1416                    Emitted::Batch { batch, metadata } => {
1417                        {
1418                            let mut s = lock_ok(stats);
1419                            s.output_batches += 1;
1420                            s.output_rows += batch.num_rows() as u64;
1421                        }
1422                        #[cfg(feature = "shm")]
1423                        if let Some(seg) = shm {
1424                            let md_in = metadata.clone().unwrap_or_default();
1425                            let (written, written_md) =
1426                                maybe_write_to_shm(batch.clone(), md_in, Some(seg))?;
1427                            if written_md.contains_key(crate::metadata::SHM_OFFSET_KEY) {
1428                                out_writer.write(&written, Some(&written_md))?;
1429                                continue;
1430                            }
1431                        }
1432                        #[cfg(feature = "http")]
1433                        if let Some(cfg) = self.external_config.as_ref() {
1434                            match crate::external::maybe_externalize_batch(
1435                                &batch,
1436                                output_schema.as_ref(),
1437                                metadata.as_ref(),
1438                                cfg,
1439                            ) {
1440                                Ok(Some((ptr, md))) => {
1441                                    out_writer.write(&ptr, Some(&md))?;
1442                                    continue;
1443                                }
1444                                Ok(None) => {}
1445                                Err(e) => {
1446                                    // Externalization failed — fall through to inline write,
1447                                    // but record the error on the access log via app_err.
1448                                    *app_err = Some(e);
1449                                }
1450                            }
1451                        }
1452                        out_writer.write(&batch, metadata.as_ref())?;
1453                    }
1454                }
1455            }
1456            // The client writes a tick and then blocks reading our response;
1457            // we must flush after every lockstep iteration.
1458            out_writer.flush()?;
1459
1460            if finished {
1461                break;
1462            }
1463        }
1464        let _ = cancelled;
1465        out_writer.finish()?;
1466
1467        // Drain remaining input.
1468        let _ = input_reader.drain();
1469        Ok(())
1470    }
1471}
1472
1473fn drain_input<R: Read>(r: &mut R) -> Result<()> {
1474    let mut rdr = StreamReader::new(r)?;
1475    rdr.drain()?;
1476    Ok(())
1477}
1478
1479pub(crate) fn cast_batch(batch: &RecordBatch, target: &SchemaRef) -> Result<RecordBatch> {
1480    if batch.num_columns() != target.fields().len() {
1481        return Err(RpcError::type_error(format!(
1482            "Input schema mismatch: expected {} fields, got {}",
1483            target.fields().len(),
1484            batch.num_columns()
1485        )));
1486    }
1487    let src_schema = batch.schema();
1488    for (i, field) in target.fields().iter().enumerate() {
1489        let src_name = src_schema.field(i).name();
1490        if src_name != field.name() {
1491            return Err(RpcError::type_error(format!(
1492                "Input schema mismatch: expected field {:?}, got {:?}",
1493                field.name(),
1494                src_name
1495            )));
1496        }
1497    }
1498    let opts = arrow_cast::CastOptions::default();
1499    let mut cols = Vec::with_capacity(batch.num_columns());
1500    for (i, field) in target.fields().iter().enumerate() {
1501        let src = batch.column(i);
1502        if src.data_type() == field.data_type() {
1503            cols.push(src.clone());
1504            continue;
1505        }
1506        let c = cast_with_options(src.as_ref(), field.data_type(), &opts)
1507            .map_err(|e| RpcError::type_error(format!("cast field {}: {}", field.name(), e)))?;
1508        cols.push(c);
1509    }
1510    // Reuse the caller's `SchemaRef` (Arc bump) instead of deep-cloning the
1511    // Schema on every casting tick.
1512    RecordBatch::try_new(target.clone(), cols).map_err(RpcError::from)
1513}
1514
1515/// Reusable builder for per-message log / error envelope metadata.
1516///
1517/// The `server_id` / `request_id` entries are stable for the lifetime of a
1518/// call, so they are allocated once at construction and kept in the map.
1519/// Each subsequent envelope only overwrites the transient
1520/// level/message/extra values in place (`get_mut`, reusing the existing key
1521/// and slot allocations) instead of building a fresh `HashMap` and
1522/// re-stringifying the ids on every log line — the per-tick allocation cost
1523/// under streaming logging. The on-wire bytes are unchanged (metadata key
1524/// order is already unspecified).
1525pub(crate) struct EnvelopeMeta<'a> {
1526    server_id: &'a str,
1527    request_id: &'a str,
1528    /// Allocated lazily on the first log/error so a call that never logs
1529    /// (the common case) pays nothing.
1530    md: Option<Metadata>,
1531}
1532
1533impl<'a> EnvelopeMeta<'a> {
1534    pub(crate) fn new(server_id: &'a str, request_id: &'a str) -> Self {
1535        Self {
1536            server_id,
1537            request_id,
1538            md: None,
1539        }
1540    }
1541
1542    /// The metadata map, allocating it (with the stable id entries) on first
1543    /// use.
1544    fn map(&mut self) -> &mut Metadata {
1545        if self.md.is_none() {
1546            let mut md = Metadata::with_capacity(5);
1547            if !self.server_id.is_empty() {
1548                md.insert(SERVER_ID_KEY.to_string(), self.server_id.to_string());
1549            }
1550            if !self.request_id.is_empty() {
1551                md.insert(REQUEST_ID_KEY.to_string(), self.request_id.to_string());
1552            }
1553            self.md = Some(md);
1554        }
1555        self.md.as_mut().unwrap()
1556    }
1557
1558    /// Overwrite `key`'s value in place when present (reusing the key + slot
1559    /// allocation), else insert it.
1560    fn set(&mut self, key: &'static str, val: String) {
1561        let md = self.map();
1562        if let Some(slot) = md.get_mut(key) {
1563            *slot = val;
1564        } else {
1565            md.insert(key.to_string(), val);
1566        }
1567    }
1568
1569    /// Populate for a log message and return the reused metadata map.
1570    pub(crate) fn log(&mut self, msg: &LogMessage) -> &Metadata {
1571        self.set(LOG_LEVEL_KEY, msg.level.as_str().to_string());
1572        self.set(LOG_MESSAGE_KEY, msg.message.clone());
1573        if !msg.extras.is_empty() {
1574            self.set(LOG_EXTRA_KEY, msg.extras_json());
1575        } else {
1576            // Clear any extra left by a previous error/log envelope.
1577            self.map().remove(LOG_EXTRA_KEY);
1578        }
1579        self.md.as_ref().unwrap()
1580    }
1581
1582    /// Populate for an error (EXCEPTION level) and return the reused map.
1583    pub(crate) fn error(&mut self, err: &RpcError) -> &Metadata {
1584        let extra = serde_json::json!({
1585            "exception_type": err.error_type,
1586            "exception_message": err.message,
1587            "traceback": err.traceback,
1588        })
1589        .to_string();
1590        self.set(LOG_LEVEL_KEY, "EXCEPTION".to_string());
1591        self.set(LOG_MESSAGE_KEY, err.message.clone());
1592        self.set(LOG_EXTRA_KEY, extra);
1593        self.md.as_ref().unwrap()
1594    }
1595}
1596
1597// Only the HTTP transport still calls the standalone builder — the pipe/unix
1598// serve loops use a reused `EnvelopeMeta` directly. Gate it so a no-`http`
1599// build (e.g. the conformance client-driver) doesn't flag it as dead code.
1600#[cfg(feature = "http")]
1601pub(crate) fn build_log_metadata(msg: &LogMessage, server_id: &str, request_id: &str) -> Metadata {
1602    let mut e = EnvelopeMeta::new(server_id, request_id);
1603    e.log(msg);
1604    e.md.unwrap()
1605}
1606
1607pub(crate) fn build_error_metadata(err: &RpcError, server_id: &str, request_id: &str) -> Metadata {
1608    let mut e = EnvelopeMeta::new(server_id, request_id);
1609    e.error(err);
1610    e.md.unwrap()
1611}
1612
1613/// Write an error as a complete single-batch IPC stream.
1614pub(crate) fn write_error_stream<W: Write>(
1615    w: &mut W,
1616    schema: &Schema,
1617    err: &RpcError,
1618    server_id: &str,
1619    request_id: &str,
1620) -> Result<()> {
1621    let mut sw = StreamWriter::new(w, schema)?;
1622    let md = build_error_metadata(err, server_id, request_id);
1623    sw.write(&empty_batch(schema)?, Some(&md))?;
1624    sw.finish()?;
1625    Ok(())
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630    use super::*;
1631    use std::io::Cursor;
1632    use std::sync::atomic::{AtomicBool, Ordering};
1633
1634    /// Frame a no-argument request for `method` as a self-contained IPC
1635    /// stream the pipe-transport serve loop can read.
1636    fn request_bytes(method: &str) -> Vec<u8> {
1637        let schema = empty_schema();
1638        let batch = empty_batch(&schema).unwrap();
1639        let mut buf = Vec::new();
1640        {
1641            let mut w = StreamWriter::new(&mut buf, &schema).unwrap();
1642            let mut md = Metadata::new();
1643            md.insert(RPC_METHOD_KEY.into(), method.into());
1644            md.insert(REQUEST_VERSION_KEY.into(), REQUEST_VERSION.into());
1645            md.insert(REQUEST_ID_KEY.into(), format!("req-{method}"));
1646            w.write(&batch, Some(&md)).unwrap();
1647            w.finish().unwrap();
1648        }
1649        buf
1650    }
1651
1652    #[test]
1653    fn panicking_handler_yields_error_envelope_and_loop_survives() {
1654        let mut server = RpcServer::new("test-srv");
1655        server.register(MethodInfo::unary(
1656            "boom",
1657            empty_schema(),
1658            empty_schema(),
1659            |_req, _ctx| panic!("handler exploded"),
1660        ));
1661        let ran_second = Arc::new(AtomicBool::new(false));
1662        let flag = ran_second.clone();
1663        server.register(MethodInfo::unary(
1664            "ok",
1665            empty_schema(),
1666            empty_schema(),
1667            move |_req, _ctx| {
1668                flag.store(true, Ordering::SeqCst);
1669                Ok(None)
1670            },
1671        ));
1672
1673        // Two back-to-back requests: the first handler panics, the
1674        // second must still run — the serve loop must not abort.
1675        let mut input = request_bytes("boom");
1676        input.extend(request_bytes("ok"));
1677        let mut output: Vec<u8> = Vec::new();
1678        server.serve(Cursor::new(input), &mut output);
1679
1680        assert!(
1681            ran_second.load(Ordering::SeqCst),
1682            "serve loop aborted after a handler panic"
1683        );
1684
1685        // The panic was surfaced to the client as an error envelope,
1686        // not a silent connection drop.
1687        let mut r = StreamReader::new(output.as_slice()).unwrap();
1688        let (_b, md) = r.read_next().unwrap().expect("error batch");
1689        assert_eq!(md_get(&md, LOG_LEVEL_KEY), Some("EXCEPTION"));
1690    }
1691
1692    #[test]
1693    fn transport_options_reports_shm_capability_unregistered() {
1694        use crate::metadata::TRANSPORT_SHM_KEY;
1695        use crate::transport_options::{shm_available, TRANSPORT_OPTIONS_METHOD_NAME};
1696
1697        let mut server = RpcServer::new("test-srv");
1698        server.register(MethodInfo::unary(
1699            "noop",
1700            empty_schema(),
1701            empty_schema(),
1702            |_req, _ctx| Ok(None),
1703        ));
1704        // Not a registered method — handled by pre-dispatch interception.
1705        assert!(!server.methods.contains_key(TRANSPORT_OPTIONS_METHOD_NAME));
1706
1707        let input = request_bytes(TRANSPORT_OPTIONS_METHOD_NAME);
1708        let mut output: Vec<u8> = Vec::new();
1709        server.serve(Cursor::new(input), &mut output);
1710
1711        let mut r = StreamReader::new(output.as_slice()).unwrap();
1712        let (_b, md) = r.read_next().unwrap().expect("transport options batch");
1713        let expected = if shm_available() { "true" } else { "false" };
1714        assert_eq!(md_get(&md, TRANSPORT_SHM_KEY), Some(expected));
1715        assert_eq!(md_get(&md, REQUEST_VERSION_KEY), Some(REQUEST_VERSION));
1716        assert_eq!(md_get(&md, SERVER_ID_KEY), Some("test-srv"));
1717    }
1718
1719    /// SHM-routed *request* batches (vgi-rpc Python 42701df).
1720    ///
1721    /// Cross-language regression: the C++ client routes a large single-row
1722    /// request batch through the shm side channel (sending a 0-row pointer
1723    /// inline), whereas the Python and Rust clients keep requests inline —
1724    /// so no end-to-end conformance lane exercised this path. Before the
1725    /// fix, a shm-routed request tripped the ``Expected 1 row in request
1726    /// batch`` guard (seen on accumulate / table_buffering over the shm
1727    /// transport).
1728    #[cfg(feature = "shm")]
1729    mod shm_requests {
1730        use super::*;
1731        use crate::metadata::{SHM_OFFSET_KEY, SHM_SEGMENT_NAME_KEY, SHM_SEGMENT_SIZE_KEY};
1732        use crate::shm::{
1733            is_shm_pointer_batch, make_shm_pointer_batch, maybe_write_to_shm, ShmSegment,
1734        };
1735        use arrow_array::{BinaryArray, Int64Array};
1736        use arrow_schema::{DataType, Field};
1737
1738        fn params_schema() -> SchemaRef {
1739            Arc::new(Schema::new(vec![Field::new(
1740                "request",
1741                DataType::Binary,
1742                false,
1743            )]))
1744        }
1745
1746        fn result_schema() -> SchemaRef {
1747            Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)]))
1748        }
1749
1750        fn request_batch(payload: &[u8]) -> RecordBatch {
1751            RecordBatch::try_new(
1752                params_schema(),
1753                vec![Arc::new(BinaryArray::from(vec![Some(payload)]))],
1754            )
1755            .unwrap()
1756        }
1757
1758        /// Dispatch metadata the way the C++ client stamps it: method +
1759        /// version, plus (optionally) the client-owned segment's name/size.
1760        fn dispatch_md(seg: Option<&ShmSegment>) -> Metadata {
1761            let mut md = Metadata::new();
1762            md.insert(RPC_METHOD_KEY.into(), "do_thing".into());
1763            md.insert(REQUEST_VERSION_KEY.into(), REQUEST_VERSION.into());
1764            if let Some(seg) = seg {
1765                md.insert(SHM_SEGMENT_NAME_KEY.into(), seg.name().to_string());
1766                md.insert(SHM_SEGMENT_SIZE_KEY.into(), seg.size().to_string());
1767            }
1768            md
1769        }
1770
1771        /// Frame one request stream whose parameter batch rides through shm
1772        /// (`maybe_write_to_shm`, the way the C++ client builds it). Fresh
1773        /// allocation each call: resolving frees the region, so a reused
1774        /// pointer would dangle.
1775        fn pointer_request(seg: &ShmSegment, payload: &[u8], advertise: bool) -> Vec<u8> {
1776            let md = dispatch_md(advertise.then_some(seg));
1777            let (ptr, ptr_md) = maybe_write_to_shm(request_batch(payload), md, Some(seg)).unwrap();
1778            assert!(
1779                is_shm_pointer_batch(&ptr, &ptr_md),
1780                "request batch should have routed through shm"
1781            );
1782            let mut buf = Vec::new();
1783            {
1784                let mut w = StreamWriter::new(&mut buf, ptr.schema().as_ref()).unwrap();
1785                w.write(&ptr, Some(&ptr_md)).unwrap();
1786                w.finish().unwrap();
1787            }
1788            buf
1789        }
1790
1791        /// Frame one inline request stream (optionally advertising the segment).
1792        fn inline_request(payload: &[u8], seg: Option<&ShmSegment>) -> Vec<u8> {
1793            let batch = request_batch(payload);
1794            let md = dispatch_md(seg);
1795            let mut buf = Vec::new();
1796            {
1797                let mut w = StreamWriter::new(&mut buf, batch.schema().as_ref()).unwrap();
1798                w.write(&batch, Some(&md)).unwrap();
1799                w.finish().unwrap();
1800            }
1801            buf
1802        }
1803
1804        /// Server whose single unary method records each request payload and
1805        /// answers with a 1-row batch (so the response data plane has
1806        /// something to route).
1807        fn payload_server(seen: Arc<Mutex<Vec<Vec<u8>>>>) -> RpcServer {
1808            let mut server = RpcServer::new("shm-srv");
1809            let rs = result_schema();
1810            server.register(MethodInfo::unary(
1811                "do_thing",
1812                params_schema(),
1813                result_schema(),
1814                move |req, _ctx| {
1815                    let col = req
1816                        .column("request")
1817                        .expect("request column")
1818                        .as_any()
1819                        .downcast_ref::<BinaryArray>()
1820                        .unwrap();
1821                    lock_ok(&seen).push(col.value(0).to_vec());
1822                    Ok(Some(RecordBatch::try_new(
1823                        rs.clone(),
1824                        vec![Arc::new(Int64Array::from(vec![1i64]))],
1825                    )?))
1826                },
1827            ));
1828            server
1829        }
1830
1831        /// Collect every response batch's metadata across the concatenated
1832        /// response streams in `output`.
1833        fn response_metadata(output: &[u8]) -> Vec<Metadata> {
1834            let mut out = Vec::new();
1835            let mut cursor = Cursor::new(output);
1836            while (cursor.position() as usize) < output.len() {
1837                let mut reader = StreamReader::new(&mut cursor).unwrap();
1838                while let Some((_b, md)) = reader.read_next().unwrap() {
1839                    out.push(md);
1840                }
1841            }
1842            out
1843        }
1844
1845        /// A shm-pointer request that names its segment resolves before the
1846        /// single-row guard — both on the cached `serve` path and on the
1847        /// per-call `serve_one` path.
1848        #[test]
1849        fn pointer_request_batch_resolves_via_segment_named_in_metadata() {
1850            let seg = ShmSegment::create(1024 * 1024).unwrap();
1851            let payload = b"serialized-request-blob";
1852
1853            let seen = Arc::new(Mutex::new(Vec::new()));
1854            let server = payload_server(seen.clone());
1855            let mut output: Vec<u8> = Vec::new();
1856            server.serve(
1857                Cursor::new(pointer_request(&seg, payload, true)),
1858                &mut output,
1859            );
1860            assert_eq!(lock_ok(&seen).as_slice(), &[payload.to_vec()]);
1861
1862            // Direct serve_one (no connection cache): one-shot attach.
1863            let seen2 = Arc::new(Mutex::new(Vec::new()));
1864            let server2 = payload_server(seen2.clone());
1865            let mut input = Cursor::new(pointer_request(&seg, payload, true));
1866            let mut output2: Vec<u8> = Vec::new();
1867            assert!(server2.serve_one(&mut input, &mut output2).unwrap());
1868            assert_eq!(lock_ok(&seen2).as_slice(), &[payload.to_vec()]);
1869        }
1870
1871        /// The client names its segment once; a later offset-only pointer
1872        /// request resolves against the connection-cached attachment.
1873        #[test]
1874        fn serve_caches_client_segment_for_offset_only_requests() {
1875            let seg = ShmSegment::create(1024 * 1024).unwrap();
1876
1877            // Request A: inline, advertising the segment (first sight).
1878            let mut input = inline_request(b"first", Some(&seg));
1879
1880            // Request B: offset-only pointer — no segment name in sight.
1881            let (off, len) = seg
1882                .allocate_and_write(&request_batch(b"second"))
1883                .unwrap()
1884                .expect("payload fits");
1885            let (ptr, mut ptr_md) =
1886                make_shm_pointer_batch(params_schema().as_ref(), off, len).unwrap();
1887            ptr_md.insert(RPC_METHOD_KEY.into(), "do_thing".into());
1888            ptr_md.insert(REQUEST_VERSION_KEY.into(), REQUEST_VERSION.into());
1889            {
1890                let mut w = StreamWriter::new(&mut input, ptr.schema().as_ref()).unwrap();
1891                w.write(&ptr, Some(&ptr_md)).unwrap();
1892                w.finish().unwrap();
1893            }
1894
1895            let seen = Arc::new(Mutex::new(Vec::new()));
1896            let server = payload_server(seen.clone());
1897            let mut output: Vec<u8> = Vec::new();
1898            server.serve(Cursor::new(input), &mut output);
1899            assert_eq!(
1900                lock_ok(&seen).as_slice(),
1901                &[b"first".to_vec(), b"second".to_vec()]
1902            );
1903        }
1904
1905        /// With neither a named segment nor a cached one, the 0-row pointer
1906        /// trips the single-row guard and the request fails.
1907        #[test]
1908        fn pointer_request_without_segment_trips_single_row_guard() {
1909            let seg = ShmSegment::create(1024 * 1024).unwrap();
1910            let seen = Arc::new(Mutex::new(Vec::new()));
1911            let server = payload_server(seen.clone());
1912            let mut output: Vec<u8> = Vec::new();
1913            server.serve(
1914                Cursor::new(pointer_request(&seg, b"orphan", false)),
1915                &mut output,
1916            );
1917            assert!(lock_ok(&seen).is_empty(), "guard should reject dispatch");
1918        }
1919
1920        /// The response routes through shm only when the client signalled
1921        /// shm for THIS exchange. The C++ client resolves shm responses only
1922        /// for the methods it shm-routed; an inline control request (bind,
1923        /// catalog_*) expects an inline response, and a shm-routed one is
1924        /// reported as empty.
1925        #[test]
1926        fn response_routed_through_shm_only_when_request_signalled_shm() {
1927            let seg = ShmSegment::create(1024 * 1024).unwrap();
1928
1929            // Request A advertises the segment; request B is plain inline
1930            // on the same connection (cache now holds the segment).
1931            let mut input = inline_request(b"a", Some(&seg));
1932            input.extend(inline_request(b"b", None));
1933
1934            let seen = Arc::new(Mutex::new(Vec::new()));
1935            let server = payload_server(seen.clone());
1936            let mut output: Vec<u8> = Vec::new();
1937            server.serve(Cursor::new(input), &mut output);
1938            assert_eq!(lock_ok(&seen).len(), 2);
1939
1940            let mds = response_metadata(&output);
1941            assert_eq!(mds.len(), 2, "one data batch per response");
1942            assert!(
1943                mds[0].contains_key(SHM_OFFSET_KEY),
1944                "response A (segment advertised) should route through shm"
1945            );
1946            assert!(
1947                !mds[1].contains_key(SHM_OFFSET_KEY),
1948                "response B (no shm signal) must stay inline despite the cached segment"
1949            );
1950        }
1951    }
1952}