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