net/adapter/net/cortex/rpc.rs
1//! nRPC — request/response on top of CortEX folds.
2//!
3//! See `docs/internal/misc/NRPC_DESIGN.md` for the full architectural framing.
4//! In short: an RPC server is a `RedexFold` whose state is the
5//! in-flight call set, whose events are typed `(REQUEST, RESPONSE,
6//! CANCEL, DEADLINE_EXCEEDED)`, whose `EventMeta::seq_or_ts` is the
7//! correlation id, and whose `EventMeta::origin_hash` is the
8//! AEAD-verified caller. The mesh-channel layer's queue-group
9//! subscription mode (see `channel::SubscriptionMode`) does the
10//! one-of-N work distribution across replica servers.
11//!
12//! This module is the **wire codec layer**: dispatch constants for
13//! `EventMeta::dispatch`, payload structs for `RpcRequestPayload` /
14//! `RpcResponsePayload`, and the `RpcStatus` enumeration. The fold
15//! types and the `Mesh::serve_rpc` / `Mesh::call` glue layer build
16//! on top.
17
18use bytes::{Buf, BufMut, Bytes};
19use parking_lot::Mutex;
20use std::collections::HashMap;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use tokio::sync::Notify;
24
25use super::super::redex::{RedexError, RedexEvent, RedexFold};
26use super::meta::{EventMeta, EVENT_META_SIZE};
27
28// ============================================================================
29// `EventMeta::dispatch` byte assignments for nRPC.
30//
31// All four values live in the cortex-internal range (`0x00..0x7F`).
32// Application/vendor dispatches stay in `0x80..0xFF`. Adapters that
33// don't care about RPC ignore unknown dispatches as they ignore any
34// other.
35// ============================================================================
36
37/// Caller → server. The first frame of an RPC call. `EventMeta::seq_or_ts`
38/// is the caller-generated `call_id`; `EventMeta::origin_hash` is the
39/// AEAD-verified caller. Payload is an [`RpcRequestPayload`].
40pub const DISPATCH_RPC_REQUEST: u8 = 0x10;
41
42/// Server → caller. The (terminal, for unary) frame of an RPC call.
43/// `EventMeta::seq_or_ts` matches the request's `call_id`. Payload is
44/// an [`RpcResponsePayload`].
45pub const DISPATCH_RPC_RESPONSE: u8 = 0x11;
46
47/// Caller → server. Cancellation signal. `EventMeta::seq_or_ts` matches
48/// the request's `call_id`. Empty payload — the dispatch byte plus
49/// the matching `call_id` is the whole signal. Server's fold removes
50/// the in-flight entry and (if cooperative) flips the handler's
51/// `CancellationToken`.
52pub const DISPATCH_RPC_CANCEL: u8 = 0x12;
53
54/// Server → caller. Deadline-exceeded signal. Emitted when the
55/// server's fold sees `now_ns() > request.deadline_ns` before
56/// starting the handler (or, optionally, when a long-running handler
57/// is aborted by the deadline timer). `EventMeta::seq_or_ts` matches
58/// the request's `call_id`. Empty payload.
59pub const DISPATCH_RPC_DEADLINE_EXCEEDED: u8 = 0x13;
60
61/// Caller → server. Stream credit grant. Carries a 4-byte
62/// big-endian `u32` in the payload after `EventMeta`: the number
63/// of additional response chunks the caller is willing to accept
64/// for the streaming call identified by `EventMeta::seq_or_ts`.
65///
66/// Only meaningful when the caller opted into flow control via
67/// the `nrpc-stream-window-initial` request header
68/// ([`HEADER_NRPC_STREAM_WINDOW_INITIAL`]). On a flow-controlled
69/// stream the server's pump task awaits one credit per chunk; on
70/// a non-flow-controlled stream (no header) the server ignores
71/// every GRANT.
72///
73/// Phase 3.
74pub const DISPATCH_RPC_STREAM_GRANT: u8 = 0x14;
75
76/// Caller → server. Continuation chunk of a client-streaming or
77/// duplex REQUEST. Carries an [`RpcRequestChunkPayload`] after the
78/// `EventMeta` prefix. `EventMeta::seq_or_ts` matches the initial
79/// REQUEST's `call_id`. Non-terminal chunks have
80/// `flags & FLAG_RPC_REQUEST_END == 0`; the terminal upload chunk
81/// sets [`FLAG_RPC_REQUEST_END`].
82///
83/// Only meaningful for calls whose initial REQUEST set
84/// [`FLAG_RPC_CLIENT_STREAMING_REQUEST`]; otherwise the server
85/// silently drops the chunk (caller bug; no observable effect).
86pub const DISPATCH_RPC_REQUEST_CHUNK: u8 = 0x15;
87
88/// Server → caller. Request-direction stream-credit grant. Mirror
89/// of [`DISPATCH_RPC_STREAM_GRANT`] for the upload direction.
90/// Carries an [`RpcRequestGrantPayload`] after `EventMeta`: a
91/// `call_id` plus a `u32` credit count. `EventMeta::seq_or_ts`
92/// matches the call's `call_id` (redundant with the payload, but
93/// kept symmetric with the rest of the dispatch family).
94///
95/// Only meaningful when the caller opted into request-direction
96/// flow control via the `nrpc-request-window-initial` header
97/// ([`HEADER_NRPC_REQUEST_WINDOW_INITIAL`]). Caller's sink
98/// awaits one credit per `REQUEST_CHUNK`; absent header →
99/// unbounded credit (sink emits as fast as the publish path can
100/// take it).
101pub const DISPATCH_RPC_REQUEST_GRANT: u8 = 0x16;
102
103// ============================================================================
104// OA2-E0.2 — RpcRouteV1 frame discriminator.
105//
106// Every nRPC frame is laid out `EventMeta ‖ RpcRouteV1 ‖ payload`,
107// where RpcRouteV1 is the CANONICAL u64 ChannelHash of the physical
108// channel the frame rides (caller → provider: `<service>.requests`;
109// provider → caller: the reply channel). The wire packet header
110// carries only a `u16` bucket, which can collide; this discriminator
111// lets mesh ingress select EXACTLY ONE registered canonical
112// dispatcher instead of fanning the frame to every candidate and
113// asking each fold to self-filter (the removed ambiguity).
114//
115// This is a coordinated nRPC frame-version change — every nRPC
116// sender writes it and mesh ingress requires it for RPC event types.
117// ============================================================================
118
119/// Size of the [`RpcRouteV1`](encode_rpc_route) discriminator: one
120/// canonical u64 `ChannelHash`.
121pub const RPC_ROUTE_V1_SIZE: usize = 8;
122
123/// Byte offset of the frame-specific payload inside an nRPC frame:
124/// past the `EventMeta` prefix AND the RpcRouteV1 discriminator.
125pub const RPC_FRAME_BODY_OFFSET: usize = EVENT_META_SIZE + RPC_ROUTE_V1_SIZE;
126
127/// Append the RpcRouteV1 discriminator (`canonical` channel hash)
128/// to a frame buffer that already holds the `EventMeta` prefix
129/// (OA2-E0.2). Every nRPC frame builder calls this immediately
130/// after `meta.to_bytes()`.
131#[inline]
132pub fn encode_rpc_route(buf: &mut Vec<u8>, canonical: crate::adapter::net::channel::ChannelHash) {
133 buf.extend_from_slice(&canonical.to_le_bytes());
134}
135
136/// Insert the RpcRouteV1 discriminator into an already-assembled
137/// `EventMeta ‖ payload` frame, producing `EventMeta ‖ route ‖
138/// payload` (OA2-E0.2). Used at the centralized response publish
139/// choke point (`publish_response_to_caller`), where the frame is
140/// built before the reply-channel hash is threaded in — one small
141/// copy per response frame. Direct request-direction builders use
142/// [`encode_rpc_route`] inline instead (no copy). Frames shorter
143/// than `EVENT_META_SIZE` are returned unchanged (never a real
144/// nRPC frame).
145pub fn insert_rpc_route(
146 frame: bytes::Bytes,
147 canonical: crate::adapter::net::channel::ChannelHash,
148) -> bytes::Bytes {
149 if frame.len() < EVENT_META_SIZE {
150 return frame;
151 }
152 let mut out = Vec::with_capacity(frame.len() + RPC_ROUTE_V1_SIZE);
153 out.extend_from_slice(&frame[..EVENT_META_SIZE]);
154 out.extend_from_slice(&canonical.to_le_bytes());
155 out.extend_from_slice(&frame[EVENT_META_SIZE..]);
156 bytes::Bytes::from(out)
157}
158
159/// Read the RpcRouteV1 discriminator from a full nRPC frame
160/// (`EventMeta ‖ route ‖ payload`). `None` if the frame is too
161/// short to carry it — a malformed/legacy frame that ingress drops.
162#[inline]
163pub fn decode_rpc_route(frame: &[u8]) -> Option<crate::adapter::net::channel::ChannelHash> {
164 let bytes = frame.get(EVENT_META_SIZE..EVENT_META_SIZE + RPC_ROUTE_V1_SIZE)?;
165 let arr: [u8; RPC_ROUTE_V1_SIZE] = bytes.try_into().ok()?;
166 Some(u64::from_le_bytes(arr))
167}
168
169/// `true` iff `event_type` is an nRPC dispatch frame that carries
170/// the RpcRouteV1 discriminator (OA2-E0.2). Mesh ingress uses this
171/// to apply route-based single-select to RPC frames while leaving
172/// non-RPC dispatcher registrations (e.g. the sensing intake) on
173/// the legacy fan-out path.
174#[inline]
175pub fn is_rpc_dispatch_frame(event_type: u8) -> bool {
176 matches!(
177 event_type,
178 DISPATCH_RPC_REQUEST
179 | DISPATCH_RPC_RESPONSE
180 | DISPATCH_RPC_CANCEL
181 | DISPATCH_RPC_DEADLINE_EXCEEDED
182 | DISPATCH_RPC_STREAM_GRANT
183 | DISPATCH_RPC_REQUEST_CHUNK
184 | DISPATCH_RPC_REQUEST_GRANT
185 )
186}
187
188/// Peek the self-declared `service` field of an initial-REQUEST
189/// frame (`EventMeta ‖ RpcRouteV1 ‖ RpcRequestPayload`) WITHOUT a
190/// full payload decode (OA2-E0.2 P0).
191///
192/// The route discriminator (E0.2) already selected a dispatcher by
193/// *canonical channel hash*, but `RpcRequestPayload` also carries
194/// its OWN `service` string — the first body field (`u8` length ‖
195/// bytes, see [`RpcRequestPayload::encode_into`]). The serve bridge
196/// uses this to enforce `payload.service == captured_service` before
197/// the capability gate or any fold state, so a frame routed to
198/// `admin.requests` whose payload names `echo` never reaches the
199/// admin handler.
200///
201/// Returns `None` when the service field is unreadable — frame too
202/// short (missing the route or the length byte), an empty or
203/// over-cap length, or non-UTF-8 bytes. Those exactly mirror the
204/// `Err` arms of [`RpcRequestPayload::decode`], so an unreadable
205/// service falls through to the fold's full decode, which rejects it
206/// (`UnknownVersion`) — no handler runs either way. A `Some(svc)`
207/// return borrows the service bytes straight out of `frame`.
208///
209/// Only meaningful for `DISPATCH_RPC_REQUEST` frames; control frames
210/// (CANCEL / CHUNK / GRANT) carry no service and inherit the
211/// route-selected active call, so callers gate on the dispatch type
212/// before calling this.
213#[inline]
214pub fn peek_request_service(frame: &[u8]) -> Option<&str> {
215 let body = frame.get(RPC_FRAME_BODY_OFFSET..)?;
216 let (&svc_len, rest) = body.split_first()?;
217 let svc_len = svc_len as usize;
218 if svc_len == 0 || svc_len > MAX_RPC_SERVICE_NAME_LEN {
219 return None;
220 }
221 let svc = rest.get(..svc_len)?;
222 std::str::from_utf8(svc).ok()
223}
224
225// ============================================================================
226// `RpcRequestPayload::flags` bit assignments.
227// ============================================================================
228
229// Bit 0 (`1 << 0`) is RESERVED — was previously documented as
230// `FLAG_RPC_IDEMPOTENT`, but the server-side replay-cache (LRU of
231// `(origin_hash, call_id) -> RpcResponsePayload`) was never landed,
232// so the flag silently no-op'd despite a load-bearing contract in
233// its doc-string. Removed to avoid shipping a documented behavior
234// the runtime doesn't implement; reservation kept so a future
235// re-add (with the LRU) preserves wire compatibility.
236
237/// Set if the server may emit multiple `DISPATCH_RPC_RESPONSE` events
238/// for this call. Without it, the first response terminates the
239/// call. With it, each response except the terminal one carries
240/// `headers["nrpc-streaming"] = b"continue"`; the terminal response
241/// has either `b"end"` (success) or a non-`Ok` status.
242pub const FLAG_RPC_STREAMING_RESPONSE: u16 = 1 << 1;
243
244/// Set if the request carries W3C Trace Context headers
245/// (`traceparent`, `tracestate`). Server propagates them to its own
246/// span emission. Phase 3.
247pub const FLAG_RPC_PROPAGATE_TRACE: u16 = 1 << 2;
248
249// Bit `1 << 3` reserved — symmetric to the reserved bit 0 above,
250// kept as breathing room for a future protocol-level flag without
251// pushing every existing live bit.
252
253/// Set on the initial REQUEST if the caller will follow up with
254/// one or more [`DISPATCH_RPC_REQUEST_CHUNK`] events. Distinguishes
255/// client-streaming / duplex calls from unary at the very first
256/// frame so the server's fold knows to open a request-side stream
257/// instead of treating the REQUEST as complete.
258///
259/// Combined with [`FLAG_RPC_STREAMING_RESPONSE`] on the same
260/// REQUEST: full duplex.
261///
262/// Bidi streaming plan (Phase A).
263pub const FLAG_RPC_CLIENT_STREAMING_REQUEST: u16 = 1 << 4;
264
265/// Set on a [`DISPATCH_RPC_REQUEST_CHUNK`] (or on the initial
266/// REQUEST itself) to signal the terminal upload frame for a
267/// client-streaming or duplex call. After receiving this, the
268/// server's request-side stream yields `None` and the handler
269/// proceeds to its terminal response.
270///
271/// Setting this on the initial REQUEST is the degenerate "client-
272/// streaming with exactly one item" path — saves a round-trip
273/// for the trivial case.
274///
275/// Bidi streaming plan (Phase A).
276pub const FLAG_RPC_REQUEST_END: u16 = 1 << 5;
277
278// Bits `6..=15` reserved; producers MUST write zero, consumers MUST
279// ignore unknown bits (forward-compat with future flags).
280
281// ============================================================================
282// `RpcResponsePayload::status` enumeration.
283// ============================================================================
284
285/// Outcome of an nRPC call. Net-native numbering with documented
286/// gRPC equivalents (see comments). Numeric stability: callers and
287/// servers across versions agree on `0x0000..=0x7FFF`; the
288/// application-defined range is `0x8000..=0xFFFF`.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290#[repr(u16)]
291pub enum RpcStatus {
292 /// Success. Payload carries the application response. Terminal
293 /// (or, for streaming responses, may be one of many — see the
294 /// streaming flag).
295 /// gRPC equivalent: `OK` (0).
296 Ok = 0x0000,
297 /// No service registered with the requested name on the server.
298 /// gRPC equivalent: `NOT_FOUND` (5).
299 NotFound = 0x0001,
300 /// Caller's token doesn't list the requested service in scope,
301 /// or the channel-level capability check failed.
302 /// gRPC equivalent: `PERMISSION_DENIED` (7).
303 Unauthorized = 0x0002,
304 /// Server observed `now_ns() > deadline_ns` before starting work.
305 /// (For the in-flight case after the handler started, see
306 /// [`DISPATCH_RPC_DEADLINE_EXCEEDED`].)
307 /// gRPC equivalent: `DEADLINE_EXCEEDED` (4).
308 Timeout = 0x0003,
309 /// Server's per-service queue is at `max_in_flight` capacity.
310 /// gRPC equivalent: `RESOURCE_EXHAUSTED` (8).
311 Backpressure = 0x0004,
312 /// Caller emitted `DISPATCH_RPC_CANCEL` before the server
313 /// completed.
314 /// gRPC equivalent: `CANCELLED` (1).
315 Cancelled = 0x0005,
316 /// Handler panicked or returned an error not classified as one
317 /// of the above. Payload carries a UTF-8 diagnostic.
318 /// gRPC equivalent: `INTERNAL` (13).
319 Internal = 0x0006,
320 /// Request payload version not supported by the server. Should
321 /// normally be caught earlier by subprotocol-version
322 /// negotiation; the in-payload guard is the floor.
323 /// gRPC equivalent: `UNIMPLEMENTED` (12).
324 UnknownVersion = 0x0007,
325 /// v0.4 capability-auth: the target's `CapabilityAnnouncement`
326 /// either does not list the requested `nrpc:<service>` tag, or
327 /// lists it with allow-lists the caller does not match. See
328 /// `docs/internal/plans/CAPABILITY_AUTH_PLAN.md` §3. Distinct from
329 /// `Unauthorized` (channel-auth / token-scope failures) so
330 /// operators can tell the two enforcement surfaces apart in
331 /// audit logs.
332 /// gRPC equivalent: `PERMISSION_DENIED` (7) — same outward
333 /// shape as `Unauthorized` but a separate substrate code.
334 CapabilityDenied = 0x0008,
335 /// OA-2 org admission (E2.2): a PROTECTED service denied this call —
336 /// the caller's `net-org-admission` proof failed verification, the
337 /// provider cannot admit right now, or the call shape is unsupported.
338 /// A COARSE reason (denied / not-supported / unavailable) rides the
339 /// response; the DETAILED `AdmissionDenied` variant stays provider-
340 /// side audit only, so denial is not a credential oracle. Distinct
341 /// from `CapabilityDenied` (v0.4 allow-list) and `Unauthorized`
342 /// (channel-auth / token-scope) so operators can tell the
343 /// enforcement surfaces apart.
344 /// gRPC equivalent: `PERMISSION_DENIED` (7).
345 AdmissionDenied = 0x0009,
346 /// Application-defined status. The wire carries the raw u16;
347 /// callers / servers agree on the meaning out of band.
348 Application(u16),
349}
350
351impl RpcStatus {
352 /// Encode to the wire `u16`.
353 pub fn to_wire(self) -> u16 {
354 match self {
355 Self::Ok => 0x0000,
356 Self::NotFound => 0x0001,
357 Self::Unauthorized => 0x0002,
358 Self::Timeout => 0x0003,
359 Self::Backpressure => 0x0004,
360 Self::Cancelled => 0x0005,
361 Self::Internal => 0x0006,
362 Self::UnknownVersion => 0x0007,
363 Self::CapabilityDenied => 0x0008,
364 Self::AdmissionDenied => 0x0009,
365 Self::Application(v) => v,
366 }
367 }
368
369 /// Decode from the wire `u16`. Reserved values
370 /// (`0x000A..=0x7FFF`) decode as `Application(v)` rather than
371 /// failing — forward-compat with future status assignments.
372 pub fn from_wire(v: u16) -> Self {
373 match v {
374 0x0000 => Self::Ok,
375 0x0001 => Self::NotFound,
376 0x0002 => Self::Unauthorized,
377 0x0003 => Self::Timeout,
378 0x0004 => Self::Backpressure,
379 0x0005 => Self::Cancelled,
380 0x0006 => Self::Internal,
381 0x0007 => Self::UnknownVersion,
382 0x0008 => Self::CapabilityDenied,
383 0x0009 => Self::AdmissionDenied,
384 other => Self::Application(other),
385 }
386 }
387
388 /// True iff `self == Ok`. Convenience for the hot caller-side
389 /// success-or-error branch.
390 #[inline]
391 pub fn is_ok(self) -> bool {
392 matches!(self, Self::Ok)
393 }
394}
395
396// ============================================================================
397// Request / response payloads.
398//
399// These ride in the bytes AFTER the 24-byte `EventMeta` prefix on a
400// CortEX-adapted event. The cortex adapter handles meta + tail
401// concatenation; this codec produces only the tail.
402// ============================================================================
403
404/// Header name + value pair. Used for trace-context propagation,
405/// idempotency-key carriage, content-type hints. Names are
406/// case-sensitive UTF-8; values are opaque bytes.
407pub type RpcHeader = (String, Vec<u8>);
408
409/// Maximum service-name length on the wire (matches
410/// `MAX_CHANNEL_NAME_LEN` upstream; reasonable upper bound for a
411/// human-readable identifier).
412pub const MAX_RPC_SERVICE_NAME_LEN: usize = 255;
413
414/// Maximum number of headers in a single request or response.
415/// Prevents pathological `headers.len()` reads from a malformed
416/// peer; legitimate callers stay well below this.
417pub const MAX_RPC_HEADERS: usize = 32;
418
419/// Maximum length of a single header name (UTF-8 bytes).
420pub const MAX_RPC_HEADER_NAME_LEN: usize = 64;
421
422/// Maximum length of a single header value (bytes).
423pub const MAX_RPC_HEADER_VALUE_LEN: usize = 4096;
424
425/// Maximum length of a request or response body. Larger payloads
426/// must use streaming responses (Phase 3) or chunk at the
427/// application layer. Comparable to gRPC's default `max_message_size`
428/// of 4 MiB; tuned downward to match RedEX's
429/// `MAX_REDEX_HEAP_PAYLOAD` ceiling.
430pub const MAX_RPC_BODY_LEN: usize = 4 * 1024 * 1024;
431
432/// nRPC request payload. Lives after the 24-byte `EventMeta` prefix
433/// in a `DISPATCH_RPC_REQUEST` event.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct RpcRequestPayload {
436 /// Service-name dispatch key. The server's fold looks this up
437 /// in its `serve_rpc` registry and routes to the registered
438 /// handler.
439 pub service: String,
440 /// Absolute deadline (unix nanos). `0` means no deadline; the
441 /// caller will cancel via `DISPATCH_RPC_CANCEL` if it changes
442 /// its mind.
443 pub deadline_ns: u64,
444 /// Bitfield of `FLAG_RPC_*` constants.
445 pub flags: u16,
446 /// Headers (trace context, idempotency key, content-type, etc.).
447 /// Capped at `MAX_RPC_HEADERS` entries, name <= `MAX_RPC_HEADER_NAME_LEN`,
448 /// value <= `MAX_RPC_HEADER_VALUE_LEN`.
449 pub headers: Vec<RpcHeader>,
450 /// Application-defined request body. Caller and server agree on
451 /// the codec out-of-band; nRPC doesn't interpret these bytes.
452 ///
453 /// Held as [`Bytes`] so [`Self::decode`] can zero-copy `slice_ref`
454 /// the body out of the inbound event's `Bytes` payload — pre-fix
455 /// perf #84 in `docs/internal/performance/net-perf-analysis.md` this was
456 /// `Vec<u8>` and every decode did a `data[body_start..body_end].to_vec()`
457 /// (a memcpy per frame). For high-RPS systems doing 100K+ RPCs/sec
458 /// with 1 KB+ bodies that was 100+ MB/sec of pure memcpy.
459 pub body: Bytes,
460}
461
462/// Continuation chunk for a client-streaming or duplex REQUEST.
463/// Lives after the 24-byte `EventMeta` prefix in a
464/// [`DISPATCH_RPC_REQUEST_CHUNK`] event.
465///
466/// Unlike the initial [`RpcRequestPayload`] there is no
467/// `service` field (server already routed by service at the
468/// initial REQUEST) and no `deadline_ns` (the initial REQUEST's
469/// deadline applies to the whole call). The `call_id` field is
470/// redundant with `EventMeta::seq_or_ts` but kept on the
471/// payload so the codec is self-contained — a reader handed a
472/// chunk's bytes without the meta header can still recover its
473/// correlation id.
474///
475/// Bidi streaming plan (Phase A).
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub struct RpcRequestChunkPayload {
478 /// Matches `EventMeta::seq_or_ts` and the original REQUEST's
479 /// `call_id`. Kept on the payload so the codec round-trips
480 /// in isolation.
481 pub call_id: u64,
482 /// Bitfield of `FLAG_RPC_*` constants. The only flag that
483 /// makes sense on a chunk today is [`FLAG_RPC_REQUEST_END`];
484 /// other flags MUST be zero on the wire so future protocol
485 /// extensions can claim them without colliding with
486 /// existing chunks.
487 pub flags: u16,
488 /// Per-chunk metadata. Typically empty; reserved for
489 /// trace-span continuity across long uploads, content-type
490 /// changes mid-stream, or other rare per-chunk concerns.
491 /// Capped at `MAX_RPC_HEADERS` entries with the same
492 /// per-field caps as `RpcRequestPayload::headers`.
493 pub headers: Vec<RpcHeader>,
494 /// Application-defined chunk body. Cap is `MAX_RPC_BODY_LEN`
495 /// (4 MiB), same as the initial REQUEST body — clients that
496 /// need >4 MiB total payload chunk their upload across
497 /// multiple `REQUEST_CHUNK` events.
498 ///
499 /// See [`RpcRequestPayload::body`] for the `Bytes`-vs-`Vec<u8>`
500 /// rationale.
501 pub body: Bytes,
502}
503
504/// Request-direction credit grant. Lives after the 24-byte
505/// `EventMeta` prefix in a [`DISPATCH_RPC_REQUEST_GRANT`] event.
506/// Mirror of the response-direction [`encode_stream_grant`] /
507/// [`decode_stream_grant`] pair, but with an explicit `call_id`
508/// in the payload (instead of relying solely on
509/// `EventMeta::seq_or_ts`) so the codec is self-contained — same
510/// rationale as [`RpcRequestChunkPayload::call_id`].
511///
512/// Bidi streaming plan (Phase A).
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub struct RpcRequestGrantPayload {
515 /// Matches the call's `call_id`.
516 pub call_id: u64,
517 /// Additional REQUEST_CHUNK frames the server is willing to
518 /// admit beyond the current credit. Server's incoming-credit
519 /// counter is capped defensively (see PHASE-B server fold)
520 /// so a misbehaving grant can't overflow.
521 pub credits: u32,
522}
523
524/// nRPC response payload. Lives after the 24-byte `EventMeta`
525/// prefix in a `DISPATCH_RPC_RESPONSE` event.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct RpcResponsePayload {
528 /// Outcome of the call. Decoded on the caller side via
529 /// [`RpcStatus::from_wire`].
530 pub status: RpcStatus,
531 /// Headers (trace context, content-type, content-encoding,
532 /// etc.). Same caps as `RpcRequestPayload::headers`.
533 pub headers: Vec<RpcHeader>,
534 /// For `status == Ok`: the application response body.
535 /// For non-`Ok` statuses: a UTF-8 diagnostic string (callers
536 /// `String::from_utf8_lossy` for display; the bytes are not
537 /// guaranteed to be valid UTF-8 against a malicious server).
538 ///
539 /// See [`RpcRequestPayload::body`] for the `Bytes`-vs-`Vec<u8>`
540 /// rationale.
541 pub body: Bytes,
542}
543
544// ============================================================================
545// Codec.
546//
547// All wire integers are little-endian. Lengths are u32_le where the
548// upper bound exceeds u16, u16_le where it fits, u8 where it fits.
549// ============================================================================
550
551/// Errors from the request / response codecs.
552#[derive(Debug, thiserror::Error)]
553pub enum RpcCodecError {
554 /// Buffer ended mid-field.
555 #[error("truncated payload at {0}")]
556 Truncated(&'static str),
557 /// Length prefix exceeds the configured maximum.
558 #[error("length {actual} exceeds limit {limit} for {field}")]
559 TooLarge {
560 /// Field name whose declared length exceeded the cap (e.g.
561 /// `"body"`, `"headers"`, `"service"`). Stable strings —
562 /// callers may match on them for diagnostics.
563 field: &'static str,
564 /// The length the wire claimed for the field.
565 actual: usize,
566 /// The maximum the codec accepts (one of the `MAX_RPC_*`
567 /// constants).
568 limit: usize,
569 },
570 /// String field contains non-UTF-8 bytes.
571 #[error("non-utf8 string in {0}")]
572 InvalidUtf8(&'static str),
573}
574
575impl RpcRequestPayload {
576 /// Validate every length field against the wire ceilings the codec's
577 /// `u8` / `u16` / `u32` length prefixes assume, BEFORE any encode.
578 /// In release builds [`Self::encode_into`] truncates an over-cap
579 /// length via its `as u8` / `as u16` / `as u32` casts (debug builds
580 /// `debug_assert`), which would let an oversized, publicly-
581 /// constructed request produce an ambiguous / non-round-tripping
582 /// encoding. Any caller that must NOT silently truncate — the org
583 /// request digest (AV-7 item 7), and any future checked send path —
584 /// validates first and refuses rather than hashing a collision-prone
585 /// encoding.
586 pub fn validate_wire_bounds(&self) -> Result<(), RpcCodecError> {
587 if self.service.len() > MAX_RPC_SERVICE_NAME_LEN {
588 return Err(RpcCodecError::TooLarge {
589 field: "service",
590 actual: self.service.len(),
591 limit: MAX_RPC_SERVICE_NAME_LEN,
592 });
593 }
594 if self.headers.len() > MAX_RPC_HEADERS {
595 return Err(RpcCodecError::TooLarge {
596 field: "headers",
597 actual: self.headers.len(),
598 limit: MAX_RPC_HEADERS,
599 });
600 }
601 for (name, value) in &self.headers {
602 if name.len() > MAX_RPC_HEADER_NAME_LEN {
603 return Err(RpcCodecError::TooLarge {
604 field: "header name",
605 actual: name.len(),
606 limit: MAX_RPC_HEADER_NAME_LEN,
607 });
608 }
609 if value.len() > MAX_RPC_HEADER_VALUE_LEN {
610 return Err(RpcCodecError::TooLarge {
611 field: "header value",
612 actual: value.len(),
613 limit: MAX_RPC_HEADER_VALUE_LEN,
614 });
615 }
616 }
617 if self.body.len() > MAX_RPC_BODY_LEN {
618 return Err(RpcCodecError::TooLarge {
619 field: "body",
620 actual: self.body.len(),
621 limit: MAX_RPC_BODY_LEN,
622 });
623 }
624 Ok(())
625 }
626
627 /// Compute the encoded byte length WITHOUT actually encoding.
628 /// Used by [`request_wire_size`] and any caller that needs to
629 /// budget event size at the bus layer (e.g., to refuse a
630 /// request that wouldn't fit in the configured packet budget)
631 /// without paying the encode cost.
632 pub fn encoded_len(&self) -> usize {
633 // service: u8 length + bytes
634 1 + self.service.len()
635 // deadline_ns: u64
636 + 8
637 // flags: u16
638 + 2
639 // headers: u8 count + per-header (u8 name_len + name + u16 value_len + value)
640 + 1
641 + self
642 .headers
643 .iter()
644 .map(|(n, v)| 1 + n.len() + 2 + v.len())
645 .sum::<usize>()
646 // body: u32 length + bytes
647 + 4
648 + self.body.len()
649 }
650
651 /// Encode to the wire format. The result is the bytes that
652 /// follow the 24-byte `EventMeta` prefix in the RedEX payload.
653 ///
654 /// **Encoder bounds:** every field that has a `MAX_RPC_*` cap
655 /// is asserted against that cap. In debug builds an oversize
656 /// field panics with a useful diagnostic so the programmer
657 /// notices in tests; in release builds the assert is dropped
658 /// (the decoder side still enforces the cap, so a malformed
659 /// frame would be rejected by the receiver — but constructing
660 /// one is always a caller bug).
661 pub fn encode(&self) -> Vec<u8> {
662 let mut buf = Vec::with_capacity(self.encoded_len());
663 self.encode_into(&mut buf);
664 buf
665 }
666
667 /// Encode directly into `buf`, appending the wire bytes (audit T2.2).
668 /// Callers that already hold an `EventMeta`-prefixed buffer use this to
669 /// skip `encode()`'s intermediate `Vec` allocation + copy. Produces the
670 /// identical bytes `encode()` returns. Pre-`reserve(encoded_len())` for an
671 /// exact-fit single allocation when starting from an empty buffer.
672 pub fn encode_into(&self, buf: &mut Vec<u8>) {
673 // service
674 let svc = self.service.as_bytes();
675 debug_assert!(
676 svc.len() <= MAX_RPC_SERVICE_NAME_LEN,
677 "service name {} exceeds MAX_RPC_SERVICE_NAME_LEN ({})",
678 svc.len(),
679 MAX_RPC_SERVICE_NAME_LEN,
680 );
681 buf.put_u8(svc.len() as u8);
682 buf.extend_from_slice(svc);
683 // deadline_ns
684 buf.put_u64_le(self.deadline_ns);
685 // flags
686 buf.put_u16_le(self.flags);
687 // headers
688 encode_headers(&self.headers, buf);
689 // body
690 debug_assert!(
691 self.body.len() <= MAX_RPC_BODY_LEN,
692 "body length {} exceeds MAX_RPC_BODY_LEN ({})",
693 self.body.len(),
694 MAX_RPC_BODY_LEN,
695 );
696 buf.put_u32_le(self.body.len() as u32);
697 buf.extend_from_slice(&self.body);
698 }
699
700 /// Decode from the wire bytes following the `EventMeta` prefix.
701 /// All length fields are bounded by the `MAX_RPC_*` constants;
702 /// over-cap inputs error rather than allocate unbounded
703 /// buffers.
704 ///
705 /// Takes [`Bytes`] (not `&[u8]`) so the decoded `body` field
706 /// can be a zero-copy `data.slice(..)` instead of an owned
707 /// `to_vec` — see perf #84.
708 pub fn decode(data: Bytes) -> Result<Self, RpcCodecError> {
709 let mut cur = std::io::Cursor::new(data.as_ref());
710 // service
711 if cur.remaining() < 1 {
712 return Err(RpcCodecError::Truncated("service length"));
713 }
714 let svc_len = cur.get_u8() as usize;
715 if svc_len == 0 {
716 return Err(RpcCodecError::Truncated("empty service name"));
717 }
718 if svc_len > MAX_RPC_SERVICE_NAME_LEN {
719 return Err(RpcCodecError::TooLarge {
720 field: "service",
721 actual: svc_len,
722 limit: MAX_RPC_SERVICE_NAME_LEN,
723 });
724 }
725 if cur.remaining() < svc_len {
726 return Err(RpcCodecError::Truncated("service bytes"));
727 }
728 let svc_start = cur.position() as usize;
729 let svc_end = svc_start + svc_len;
730 let service = std::str::from_utf8(&data[svc_start..svc_end])
731 .map_err(|_| RpcCodecError::InvalidUtf8("service"))?
732 .to_string();
733 cur.set_position(svc_end as u64);
734 // deadline_ns
735 if cur.remaining() < 8 {
736 return Err(RpcCodecError::Truncated("deadline_ns"));
737 }
738 let deadline_ns = cur.get_u64_le();
739 // flags
740 if cur.remaining() < 2 {
741 return Err(RpcCodecError::Truncated("flags"));
742 }
743 let flags = cur.get_u16_le();
744 // headers
745 let headers = decode_headers(&mut cur, &data)?;
746 // body
747 if cur.remaining() < 4 {
748 return Err(RpcCodecError::Truncated("body length"));
749 }
750 let body_len = cur.get_u32_le() as usize;
751 if body_len > MAX_RPC_BODY_LEN {
752 return Err(RpcCodecError::TooLarge {
753 field: "body",
754 actual: body_len,
755 limit: MAX_RPC_BODY_LEN,
756 });
757 }
758 if cur.remaining() < body_len {
759 return Err(RpcCodecError::Truncated("body bytes"));
760 }
761 let body_start = cur.position() as usize;
762 let body_end = body_start + body_len;
763 // Zero-copy slice over the input — refcount bump only.
764 let body = data.slice(body_start..body_end);
765 Ok(Self {
766 service,
767 deadline_ns,
768 flags,
769 headers,
770 body,
771 })
772 }
773}
774
775impl RpcRequestChunkPayload {
776 /// Compute the encoded byte length WITHOUT actually encoding.
777 /// See [`RpcRequestPayload::encoded_len`] for the rationale.
778 pub fn encoded_len(&self) -> usize {
779 // call_id: u64
780 8
781 // flags: u16
782 + 2
783 // headers: u8 count + per-header (u8 name_len + name + u16 value_len + value)
784 + 1
785 + self
786 .headers
787 .iter()
788 .map(|(n, v)| 1 + n.len() + 2 + v.len())
789 .sum::<usize>()
790 // body: u32 length + bytes
791 + 4
792 + self.body.len()
793 }
794
795 /// Encode to the wire bytes that follow the 24-byte `EventMeta`
796 /// prefix in a [`DISPATCH_RPC_REQUEST_CHUNK`] event. Same
797 /// encoder-bounds policy as [`RpcRequestPayload::encode`]:
798 /// oversize fields panic in debug, the decoder enforces in
799 /// release.
800 pub fn encode(&self) -> Vec<u8> {
801 let mut buf = Vec::with_capacity(self.encoded_len());
802 self.encode_into(&mut buf);
803 buf
804 }
805
806 /// Encode directly into `buf`, appending the wire bytes (audit T2.2).
807 /// See [`RpcRequestPayload::encode_into`].
808 pub fn encode_into(&self, buf: &mut Vec<u8>) {
809 // call_id
810 buf.put_u64_le(self.call_id);
811 // flags
812 buf.put_u16_le(self.flags);
813 // headers
814 encode_headers(&self.headers, buf);
815 // body
816 debug_assert!(
817 self.body.len() <= MAX_RPC_BODY_LEN,
818 "body length {} exceeds MAX_RPC_BODY_LEN ({})",
819 self.body.len(),
820 MAX_RPC_BODY_LEN,
821 );
822 buf.put_u32_le(self.body.len() as u32);
823 buf.extend_from_slice(&self.body);
824 }
825
826 /// Decode from the wire bytes following the `EventMeta` prefix.
827 /// Bounded by the same `MAX_RPC_*` caps as the initial REQUEST.
828 /// Takes [`Bytes`] for zero-copy `body` slicing — see perf #84.
829 pub fn decode(data: Bytes) -> Result<Self, RpcCodecError> {
830 let mut cur = std::io::Cursor::new(data.as_ref());
831 // call_id
832 if cur.remaining() < 8 {
833 return Err(RpcCodecError::Truncated("call_id"));
834 }
835 let call_id = cur.get_u64_le();
836 // flags
837 if cur.remaining() < 2 {
838 return Err(RpcCodecError::Truncated("flags"));
839 }
840 let flags = cur.get_u16_le();
841 // headers
842 let headers = decode_headers(&mut cur, &data)?;
843 // body
844 if cur.remaining() < 4 {
845 return Err(RpcCodecError::Truncated("body length"));
846 }
847 let body_len = cur.get_u32_le() as usize;
848 if body_len > MAX_RPC_BODY_LEN {
849 return Err(RpcCodecError::TooLarge {
850 field: "body",
851 actual: body_len,
852 limit: MAX_RPC_BODY_LEN,
853 });
854 }
855 if cur.remaining() < body_len {
856 return Err(RpcCodecError::Truncated("body bytes"));
857 }
858 let body_start = cur.position() as usize;
859 let body_end = body_start + body_len;
860 let body = data.slice(body_start..body_end);
861 Ok(Self {
862 call_id,
863 flags,
864 headers,
865 body,
866 })
867 }
868}
869
870impl RpcResponsePayload {
871 /// Compute the encoded byte length WITHOUT actually encoding.
872 /// See [`RpcRequestPayload::encoded_len`].
873 pub fn encoded_len(&self) -> usize {
874 // status: u16
875 2
876 // headers: u8 count + per-header
877 + 1
878 + self
879 .headers
880 .iter()
881 .map(|(n, v)| 1 + n.len() + 2 + v.len())
882 .sum::<usize>()
883 // body: u32 length + bytes
884 + 4
885 + self.body.len()
886 }
887
888 /// Encode to the wire format. The result is the bytes that
889 /// follow the 24-byte `EventMeta` prefix in the RedEX payload.
890 /// Same encoder-bounds policy as
891 /// [`RpcRequestPayload::encode`] — see that method's doc.
892 pub fn encode(&self) -> Vec<u8> {
893 let mut buf = Vec::with_capacity(self.encoded_len());
894 self.encode_into(&mut buf);
895 buf
896 }
897
898 /// Encode directly into `buf`, appending the wire bytes (audit T2.2).
899 /// See [`RpcRequestPayload::encode_into`].
900 pub fn encode_into(&self, buf: &mut Vec<u8>) {
901 buf.put_u16_le(self.status.to_wire());
902 encode_headers(&self.headers, buf);
903 debug_assert!(
904 self.body.len() <= MAX_RPC_BODY_LEN,
905 "body length {} exceeds MAX_RPC_BODY_LEN ({})",
906 self.body.len(),
907 MAX_RPC_BODY_LEN,
908 );
909 buf.put_u32_le(self.body.len() as u32);
910 buf.extend_from_slice(&self.body);
911 }
912
913 /// Decode from the wire bytes following the `EventMeta` prefix.
914 /// Takes [`Bytes`] for zero-copy `body` slicing — see perf #84.
915 pub fn decode(data: Bytes) -> Result<Self, RpcCodecError> {
916 let mut cur = std::io::Cursor::new(data.as_ref());
917 if cur.remaining() < 2 {
918 return Err(RpcCodecError::Truncated("status"));
919 }
920 let status = RpcStatus::from_wire(cur.get_u16_le());
921 let headers = decode_headers(&mut cur, &data)?;
922 if cur.remaining() < 4 {
923 return Err(RpcCodecError::Truncated("body length"));
924 }
925 let body_len = cur.get_u32_le() as usize;
926 if body_len > MAX_RPC_BODY_LEN {
927 return Err(RpcCodecError::TooLarge {
928 field: "body",
929 actual: body_len,
930 limit: MAX_RPC_BODY_LEN,
931 });
932 }
933 if cur.remaining() < body_len {
934 return Err(RpcCodecError::Truncated("body bytes"));
935 }
936 let body_start = cur.position() as usize;
937 let body_end = body_start + body_len;
938 let body = data.slice(body_start..body_end);
939 Ok(Self {
940 status,
941 headers,
942 body,
943 })
944 }
945}
946
947/// Pull `traceparent` / `tracestate` out of `headers` if present.
948/// Caller-side helper: callers building an `RpcRequestPayload`
949/// with a `TraceContext` use [`build_trace_headers`] to emit the
950/// matching headers; this is the inverse on the server side.
951///
952/// Returns `Some(TraceContext)` if `traceparent` is present;
953/// `None` otherwise. `tracestate` defaults to empty when absent
954/// — W3C says tracestate is optional even when traceparent is
955/// set.
956pub fn extract_trace_context(headers: &[RpcHeader]) -> Option<TraceContext> {
957 let mut traceparent: Option<String> = None;
958 let mut tracestate = String::new();
959 for (name, value) in headers {
960 // Header names are case-insensitive (matches W3C and HTTP
961 // convention) — same comparison style as `parse_stream_
962 // window_initial` for consistency. The wire spec doesn't
963 // mandate case so a peer that emits `Traceparent` (capital
964 // T) shouldn't be silently ignored.
965 if name.eq_ignore_ascii_case("traceparent") {
966 if let Ok(s) = std::str::from_utf8(value) {
967 traceparent = Some(s.to_string());
968 }
969 } else if name.eq_ignore_ascii_case("tracestate") {
970 if let Ok(s) = std::str::from_utf8(value) {
971 tracestate = s.to_string();
972 }
973 }
974 }
975 traceparent.map(|tp| TraceContext {
976 traceparent: tp,
977 tracestate,
978 })
979}
980
981/// Build the headers a caller appends to its
982/// `RpcRequestPayload::headers` to propagate the trace context
983/// across the call. Set `RpcRequestPayload::flags |= FLAG_RPC_PROPAGATE_TRACE`
984/// alongside this so the server's fold knows to extract them.
985///
986/// Always emits `traceparent`. Emits `tracestate` only when
987/// non-empty (matches the W3C convention of skipping empty
988/// tracestate values on the wire).
989pub fn build_trace_headers(ctx: &TraceContext) -> Vec<RpcHeader> {
990 let mut headers = Vec::with_capacity(2);
991 headers.push((
992 "traceparent".to_string(),
993 ctx.traceparent.clone().into_bytes(),
994 ));
995 if !ctx.tracestate.is_empty() {
996 headers.push((
997 "tracestate".to_string(),
998 ctx.tracestate.clone().into_bytes(),
999 ));
1000 }
1001 headers
1002}
1003
1004fn encode_headers(headers: &[RpcHeader], buf: &mut Vec<u8>) {
1005 debug_assert!(
1006 headers.len() <= MAX_RPC_HEADERS,
1007 "headers count {} exceeds MAX_RPC_HEADERS ({})",
1008 headers.len(),
1009 MAX_RPC_HEADERS,
1010 );
1011 buf.put_u8(headers.len() as u8);
1012 for (name, value) in headers {
1013 let nbytes = name.as_bytes();
1014 debug_assert!(
1015 nbytes.len() <= MAX_RPC_HEADER_NAME_LEN,
1016 "header name {} exceeds MAX_RPC_HEADER_NAME_LEN ({})",
1017 nbytes.len(),
1018 MAX_RPC_HEADER_NAME_LEN,
1019 );
1020 debug_assert!(
1021 value.len() <= MAX_RPC_HEADER_VALUE_LEN,
1022 "header value {} exceeds MAX_RPC_HEADER_VALUE_LEN ({})",
1023 value.len(),
1024 MAX_RPC_HEADER_VALUE_LEN,
1025 );
1026 buf.put_u8(nbytes.len() as u8);
1027 buf.extend_from_slice(nbytes);
1028 buf.put_u16_le(value.len() as u16);
1029 buf.extend_from_slice(value);
1030 }
1031}
1032
1033fn decode_headers(
1034 cur: &mut std::io::Cursor<&[u8]>,
1035 data: &[u8],
1036) -> Result<Vec<RpcHeader>, RpcCodecError> {
1037 if cur.remaining() < 1 {
1038 return Err(RpcCodecError::Truncated("headers count"));
1039 }
1040 let count = cur.get_u8() as usize;
1041 if count > MAX_RPC_HEADERS {
1042 return Err(RpcCodecError::TooLarge {
1043 field: "headers",
1044 actual: count,
1045 limit: MAX_RPC_HEADERS,
1046 });
1047 }
1048 let mut headers = Vec::with_capacity(count);
1049 for _ in 0..count {
1050 if cur.remaining() < 1 {
1051 return Err(RpcCodecError::Truncated("header name length"));
1052 }
1053 let name_len = cur.get_u8() as usize;
1054 if name_len == 0 {
1055 return Err(RpcCodecError::Truncated("empty header name"));
1056 }
1057 if name_len > MAX_RPC_HEADER_NAME_LEN {
1058 return Err(RpcCodecError::TooLarge {
1059 field: "header name",
1060 actual: name_len,
1061 limit: MAX_RPC_HEADER_NAME_LEN,
1062 });
1063 }
1064 if cur.remaining() < name_len {
1065 return Err(RpcCodecError::Truncated("header name bytes"));
1066 }
1067 let nstart = cur.position() as usize;
1068 let nend = nstart + name_len;
1069 let name = std::str::from_utf8(&data[nstart..nend])
1070 .map_err(|_| RpcCodecError::InvalidUtf8("header name"))?
1071 .to_string();
1072 cur.set_position(nend as u64);
1073
1074 if cur.remaining() < 2 {
1075 return Err(RpcCodecError::Truncated("header value length"));
1076 }
1077 let value_len = cur.get_u16_le() as usize;
1078 if value_len > MAX_RPC_HEADER_VALUE_LEN {
1079 return Err(RpcCodecError::TooLarge {
1080 field: "header value",
1081 actual: value_len,
1082 limit: MAX_RPC_HEADER_VALUE_LEN,
1083 });
1084 }
1085 if cur.remaining() < value_len {
1086 return Err(RpcCodecError::Truncated("header value bytes"));
1087 }
1088 let vstart = cur.position() as usize;
1089 let vend = vstart + value_len;
1090 let value = data[vstart..vend].to_vec();
1091 cur.set_position(vend as u64);
1092 headers.push((name, value));
1093 }
1094 Ok(headers)
1095}
1096
1097/// Convenience: the byte layout of an `RpcRequestPayload` that lands
1098/// after the `EventMeta` prefix in a `DISPATCH_RPC_REQUEST` event.
1099/// Exposed so callers can budget the total event size at the bus
1100/// layer without doing the encode first.
1101pub fn request_wire_size(payload: &RpcRequestPayload) -> usize {
1102 // OA2-E0.2: EventMeta + RpcRouteV1 discriminator + payload.
1103 RPC_FRAME_BODY_OFFSET + payload.encoded_len()
1104}
1105
1106/// Same for `RpcResponsePayload` after the `EventMeta` prefix in a
1107/// `DISPATCH_RPC_RESPONSE` event.
1108pub fn response_wire_size(payload: &RpcResponsePayload) -> usize {
1109 RPC_FRAME_BODY_OFFSET + payload.encoded_len()
1110}
1111
1112// ============================================================================
1113// Mesh inbound dispatch hook.
1114//
1115// `MeshNode::dispatch_packet` normally pushes inbound channel
1116// events onto a per-shard `inbound` queue keyed by `shard_id`. The
1117// channel name / hash is stripped on the way in — by the time the
1118// event lands in the queue, only the payload remains.
1119//
1120// RPC needs per-channel routing (events for `<service>.requests`
1121// drive the server fold; events for `<service>.replies.<origin>`
1122// drive the client fold). Without channel info on the queued
1123// event, we can't filter from the consumer side.
1124//
1125// The hook below adds a per-channel-hash dispatcher map that the
1126// mesh's inbound dispatch consults BEFORE pushing to the shard
1127// queue. If a dispatcher is registered for the event's
1128// canonical [`ChannelHash`], the event is routed there directly
1129// (bypassing the shard queue); otherwise the existing shard-queue
1130// path runs.
1131//
1132// **Collision posture.** The dispatch event carries the canonical
1133// 32-bit [`ChannelHash`] (joint-collision threshold ~65 K
1134// channels, well above realistic deployment); the wire
1135// `NetHeader::channel_hash` is `u16` and may bucket-collide at
1136// scale, so the mesh's inbound dispatch indexes by the wire `u16`
1137// and dispatches every canonical entry registered in that bucket
1138// (the canonical match resolves on the dispatcher side). At
1139// typical sizing this is a single entry per bucket.
1140// ============================================================================
1141
1142/// One inbound event delivered to a registered RPC dispatcher.
1143#[derive(Debug, Clone)]
1144pub struct RpcInboundEvent {
1145 /// Canonical [`ChannelHash`](crate::adapter::net::channel::ChannelHash)
1146 /// (u32) of the channel this event arrived on — widened from the
1147 /// per-packet wire `u16` `NetHeader::channel_hash` via the
1148 /// registered-dispatcher table at receive time.
1149 /// Collision-resistant at realistic scale; the wire `u16` may
1150 /// bucket-collide but the canonical hash uniquely identifies the
1151 /// registered dispatcher target.
1152 pub channel_hash: super::super::channel::ChannelHash,
1153 /// Caller's `origin_hash` from the packet header — the full
1154 /// 64-bit `EntityKeypair::origin_hash()` mirroring the wire
1155 /// field's width post-`WIRE_ORIGIN_HASH_64BIT`. The dispatcher
1156 /// should treat this as routing metadata, not identity
1157 /// authentication (the AEAD-verified `session_node` field
1158 /// below carries that).
1159 pub origin_hash: u64,
1160 /// Wire-session peer's `NodeId` resolved at packet receive
1161 /// time from the AEAD-verified session_id. Distinct from
1162 /// `origin_hash`: this is the full 64-bit network identity
1163 /// of the peer that delivered the packet. Used by
1164 /// `RpcClientPending::deliver`
1165 /// to reject spoofed RESPONSE frames whose call_id happens
1166 /// to match an in-flight request but whose session peer
1167 /// isn't the recorded target.
1168 ///
1169 /// Set to `0` on test / loopback paths that don't have a
1170 /// session to resolve against; callers that register
1171 /// pending entries with `target_node = 0` opt out of the
1172 /// binding gate (and trust the call_id randomness alone).
1173 ///
1174 /// **Production wire-path invariant**: real over-the-wire
1175 /// inbound delivery MUST NOT produce `from_node = 0`. The
1176 /// dispatcher in `mesh.rs` (`handle_inbound_user_payload`)
1177 /// drops the event when the wire session has no resolvable
1178 /// `NodeId`, rather than forwarding under the sentinel — see
1179 /// the explicit drop + warn at the
1180 /// `dropping cortex-RPC event: wire session has no resolvable NodeId`
1181 /// log site. The v0.4 capability-auth callee-side gate in
1182 /// `MeshNode::serve_rpc`'s bridge relies on this: it skips
1183 /// permissively when `from_node == 0` (loopback compat), so
1184 /// a wire-path leak of the sentinel would silently re-open
1185 /// the gate. If you change the dispatcher to fall back to 0
1186 /// instead of dropping, you ALSO have to teach the bridge
1187 /// to deny on the sentinel.
1188 pub from_node: super::super::behavior::placement::NodeId,
1189 /// Event payload bytes — the same bytes that would have been
1190 /// pushed onto the shard inbound queue. For RPC events these
1191 /// start with a 24-byte `EventMeta` followed by the
1192 /// `RpcRequestPayload` / `RpcResponsePayload` encoding.
1193 pub payload: bytes::Bytes,
1194}
1195
1196/// Type-erased callback fired by the mesh's inbound dispatch
1197/// when an event arrives for a registered `channel_hash`. The
1198/// callback runs on the mesh's dispatch task, so the body should
1199/// be quick (push the event onto an mpsc / fold consumer rather
1200/// than do real work).
1201pub type RpcInboundDispatcher = Arc<dyn Fn(RpcInboundEvent) + Send + Sync + 'static>;
1202
1203// ============================================================================
1204// Streaming-response protocol markers.
1205//
1206// When a caller sets `FLAG_RPC_STREAMING_RESPONSE` on the request,
1207// the server emits multiple `DISPATCH_RPC_RESPONSE` events for the
1208// same `call_id`. Non-terminal chunks carry the
1209// `nrpc-streaming = continue` header; the terminal chunk carries
1210// `nrpc-streaming = end` (or any non-`Ok` status, which is also
1211// terminal). The client-side stream collects chunks until it sees
1212// a terminal marker.
1213// ============================================================================
1214
1215/// Header name nRPC uses to mark streaming-response chunks.
1216/// Present on every chunk of a streaming response, with one of two
1217/// values defined below.
1218pub const HEADER_NRPC_STREAMING: &str = "nrpc-streaming";
1219
1220/// `nrpc-streaming` value on a non-terminal chunk. The client-side
1221/// stream yields the chunk's body and continues waiting for more.
1222pub const HEADER_NRPC_STREAMING_CONTINUE: &[u8] = b"continue";
1223
1224/// `nrpc-streaming` value on the terminal chunk. The client-side
1225/// stream yields the chunk's body (if non-empty) and then closes.
1226/// A non-`Ok` status is also terminal, regardless of header — the
1227/// stream yields the error and closes.
1228pub const HEADER_NRPC_STREAMING_END: &[u8] = b"end";
1229
1230/// Header on a streaming REQUEST that opts into flow control with
1231/// the given initial credit window. Value is the ASCII decimal
1232/// representation of a `u32` (e.g. `"32"`). When present, the
1233/// server's streaming fold creates a per-call semaphore initialized
1234/// to that count and the pump awaits one credit per emitted chunk.
1235/// The caller refills via [`DISPATCH_RPC_STREAM_GRANT`] events.
1236///
1237/// Absent → unbounded credit (the pump emits chunks as fast as
1238/// the publish path can take them). Long-running streams that
1239/// could outpace a slow consumer SHOULD opt into flow control —
1240/// without it, the server's sink mpsc grows unbounded under a
1241/// stalled caller.
1242pub const HEADER_NRPC_STREAM_WINDOW_INITIAL: &str = "nrpc-stream-window-initial";
1243
1244/// Encode a stream-grant payload — 4 bytes big-endian `u32`
1245/// representing additional credit. Pair with [`decode_stream_grant`]
1246/// on the server side.
1247pub fn encode_stream_grant(amount: u32) -> Vec<u8> {
1248 amount.to_be_bytes().to_vec()
1249}
1250
1251/// Decode a stream-grant payload. Returns `None` if the slice is
1252/// not exactly 4 bytes — defends the server fold against
1253/// malformed grants without killing the cortex adapter.
1254pub fn decode_stream_grant(payload: &[u8]) -> Option<u32> {
1255 if payload.len() != 4 {
1256 return None;
1257 }
1258 let mut bytes = [0u8; 4];
1259 bytes.copy_from_slice(payload);
1260 Some(u32::from_be_bytes(bytes))
1261}
1262
1263/// Parse the `nrpc-stream-window-initial` header from a request's
1264/// header list. Returns `Some(window)` if a valid u32 ASCII-decimal
1265/// value is present, else `None` (no header / malformed value /
1266/// non-utf8 — all treated as "no flow control").
1267pub fn parse_stream_window_initial(headers: &[RpcHeader]) -> Option<u32> {
1268 for (name, value) in headers {
1269 if name.eq_ignore_ascii_case(HEADER_NRPC_STREAM_WINDOW_INITIAL) {
1270 return std::str::from_utf8(value).ok()?.parse::<u32>().ok();
1271 }
1272 }
1273 None
1274}
1275
1276/// Header on the initial REQUEST of a client-streaming or duplex
1277/// call that opts the upload direction into flow control with the
1278/// given initial credit window. Value is the ASCII decimal
1279/// representation of a `u32`. When present, the server's
1280/// streaming-request fold creates a per-call semaphore and the
1281/// caller's sink awaits one credit per `REQUEST_CHUNK`. The server
1282/// refills via [`DISPATCH_RPC_REQUEST_GRANT`] events.
1283///
1284/// Absent → unbounded credit (caller's sink emits as fast as the
1285/// publish path can take it). Long client-streaming calls that
1286/// could outpace a slow handler SHOULD opt into flow control —
1287/// without it, the server's chunk mpsc grows unbounded under a
1288/// stalled handler.
1289///
1290/// Bidi streaming plan (Phase A).
1291pub const HEADER_NRPC_REQUEST_WINDOW_INITIAL: &str = "nrpc-request-window-initial";
1292
1293/// Encode a request-grant payload — `call_id` (u64 little-endian)
1294/// followed by additional credit (u32 big-endian). Big-endian on
1295/// the credit field matches [`encode_stream_grant`]; little-endian
1296/// on `call_id` matches the rest of the RPC codec's u64 fields.
1297///
1298/// Pair with [`decode_request_grant`] on the caller side.
1299pub fn encode_request_grant(call_id: u64, credits: u32) -> Vec<u8> {
1300 let mut buf = Vec::with_capacity(12);
1301 buf.put_u64_le(call_id);
1302 buf.extend_from_slice(&credits.to_be_bytes());
1303 buf
1304}
1305
1306/// Decode a request-grant payload. Returns `None` if the slice is
1307/// not exactly 12 bytes — defends the caller's fold against
1308/// malformed grants without killing the cortex adapter.
1309pub fn decode_request_grant(payload: &[u8]) -> Option<RpcRequestGrantPayload> {
1310 if payload.len() != 12 {
1311 return None;
1312 }
1313 let mut cid = [0u8; 8];
1314 cid.copy_from_slice(&payload[..8]);
1315 let call_id = u64::from_le_bytes(cid);
1316 let mut credits = [0u8; 4];
1317 credits.copy_from_slice(&payload[8..]);
1318 Some(RpcRequestGrantPayload {
1319 call_id,
1320 credits: u32::from_be_bytes(credits),
1321 })
1322}
1323
1324/// Parse the `nrpc-request-window-initial` header from a request's
1325/// header list. Same semantics as [`parse_stream_window_initial`]
1326/// but for the upload direction.
1327pub fn parse_request_window_initial(headers: &[RpcHeader]) -> Option<u32> {
1328 for (name, value) in headers {
1329 if name.eq_ignore_ascii_case(HEADER_NRPC_REQUEST_WINDOW_INITIAL) {
1330 return std::str::from_utf8(value).ok()?.parse::<u32>().ok();
1331 }
1332 }
1333 None
1334}
1335
1336/// Inspect a `RpcResponsePayload`'s headers and decide whether
1337/// it's a non-terminal streaming chunk (`continue`), a terminal
1338/// streaming chunk (`end` OR non-`Ok` status), OR a unary
1339/// response (no streaming header at all). Used by the client-side
1340/// fold to demux streaming vs unary responses without needing a
1341/// separate flag.
1342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1343pub enum StreamingChunkKind {
1344 /// Non-terminal chunk — yield body, continue waiting.
1345 Continue,
1346 /// Terminal chunk — yield body (if any), close stream.
1347 Terminal,
1348 /// Not a streaming response — unary semantics apply.
1349 Unary,
1350}
1351
1352/// Classify a response per the streaming-protocol markers.
1353pub fn classify_streaming_chunk(resp: &RpcResponsePayload) -> StreamingChunkKind {
1354 // Non-Ok status is always terminal regardless of header — the
1355 // stream surfaces the error and closes.
1356 if !resp.status.is_ok() {
1357 return StreamingChunkKind::Terminal;
1358 }
1359 // Walk headers for the streaming marker. Absence = unary
1360 // semantics (caller used `call`, not `call_streaming`).
1361 for (name, value) in &resp.headers {
1362 if name == HEADER_NRPC_STREAMING {
1363 return if value.as_slice() == HEADER_NRPC_STREAMING_END {
1364 StreamingChunkKind::Terminal
1365 } else if value.as_slice() == HEADER_NRPC_STREAMING_CONTINUE {
1366 StreamingChunkKind::Continue
1367 } else {
1368 // Unknown marker value — be defensive, treat as
1369 // terminal so a misbehaving server doesn't keep
1370 // a stream open forever.
1371 StreamingChunkKind::Terminal
1372 };
1373 }
1374 }
1375 StreamingChunkKind::Unary
1376}
1377
1378// ============================================================================
1379// Server-side fold.
1380//
1381// `RpcServerFold` is the `RedexFold` half of the server. It sees
1382// REQUEST events on the channel the cortex adapter is opened against,
1383// spawns the user handler in a tokio task, and emits the RESPONSE
1384// via a callback the `Mesh::serve_rpc` glue layer wires up. The
1385// fold itself is small and pure — all I/O happens in the spawned
1386// task and the emitter callback.
1387//
1388// Cancellation: each in-flight call gets an `RpcCancellationToken`
1389// that the handler can `select!` on. CANCEL events flip the
1390// matching token; the handler observes `cancellation.cancelled()`
1391// firing and aborts cooperatively.
1392// ============================================================================
1393
1394/// Cancellation signal for an in-flight RPC handler.
1395///
1396/// Created when the fold dispatches a REQUEST; cloned into the
1397/// handler's `RpcContext` and held in the fold's in-flight map. A
1398/// matching CANCEL event flips the token; handlers observe via
1399/// either [`Self::is_cancelled`] (synchronous probe) or
1400/// [`Self::cancelled`] (await for the signal).
1401#[derive(Clone, Default)]
1402pub struct RpcCancellationToken {
1403 inner: Arc<RpcCancellationInner>,
1404}
1405
1406#[derive(Default)]
1407struct RpcCancellationInner {
1408 fired: AtomicBool,
1409 notify: Notify,
1410}
1411
1412impl RpcCancellationToken {
1413 /// Construct a fresh, un-fired token.
1414 pub fn new() -> Self {
1415 Self::default()
1416 }
1417
1418 /// Flip the token. Idempotent — repeated calls are no-ops.
1419 /// Wakes any task currently in [`Self::cancelled`].
1420 pub fn cancel(&self) {
1421 // Release pairs with the Acquire load in `is_cancelled`
1422 // so a handler that observes `is_cancelled() == true` is
1423 // guaranteed to see every prior write the canceller did.
1424 self.inner.fired.store(true, Ordering::Release);
1425 self.inner.notify.notify_waiters();
1426 }
1427
1428 /// Synchronous probe. `true` once `cancel()` has been called.
1429 #[inline]
1430 pub fn is_cancelled(&self) -> bool {
1431 self.inner.fired.load(Ordering::Acquire)
1432 }
1433
1434 /// Await the cancellation. Returns immediately if already
1435 /// cancelled. Otherwise registers as a waiter and returns when
1436 /// `cancel()` is called.
1437 ///
1438 /// Race-safe: registering the `notified()` future BEFORE the
1439 /// `is_cancelled` check means a `cancel()` racing this method
1440 /// either (a) is observed by the post-register check and we
1441 /// return immediately, OR (b) lands after we register and wakes
1442 /// our future. Either way we don't sleep past a cancellation.
1443 pub async fn cancelled(&self) {
1444 let notified = self.inner.notify.notified();
1445 if self.is_cancelled() {
1446 return;
1447 }
1448 notified.await;
1449 }
1450}
1451
1452/// W3C Trace Context — `traceparent` and `tracestate` headers
1453/// propagated through nRPC for distributed-tracing systems.
1454///
1455/// `traceparent` carries the trace id, parent span id, and flags;
1456/// `tracestate` carries vendor-specific tracing extensions. nRPC
1457/// is **transport-only** for these — it doesn't parse or generate
1458/// IDs, doesn't emit spans, doesn't talk to any tracing backend.
1459/// Application code (typically via `tracing-opentelemetry` or a
1460/// Datadog client) reads these on the server side and continues
1461/// the trace.
1462///
1463/// See <https://www.w3.org/TR/trace-context/> for the wire format
1464/// of each field.
1465#[derive(Debug, Clone, Default, PartialEq, Eq)]
1466pub struct TraceContext {
1467 /// `traceparent` header value (e.g.
1468 /// `"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"`).
1469 /// Required by the W3C spec; nRPC treats it as opaque bytes.
1470 pub traceparent: String,
1471 /// `tracestate` header value — vendor-specific extensions.
1472 /// Optional in W3C; empty string when absent.
1473 pub tracestate: String,
1474}
1475
1476/// Context handed to a `RpcHandler::call`. Carries what the handler
1477/// needs to fulfill the request: caller routing attribution, the
1478/// request payload, and a cancellation token.
1479///
1480/// **This context does not carry an authenticated end-to-end caller
1481/// identity.** See [`RpcContext::caller_origin`]; handlers that need
1482/// to authorize a caller must either run behind the PROTECTED-service
1483/// admission gate (and read [`RpcContext::org_admission`]) or carry
1484/// their own application-level signature.
1485pub struct RpcContext {
1486 /// Caller's `origin_hash`, copied verbatim from the inbound
1487 /// packet header ([`RpcInboundEvent::origin_hash`]).
1488 ///
1489 /// **Routing metadata, not identity authentication — do not
1490 /// authorize on this.** It is a value carried on the wire, so a
1491 /// peer chooses what it says. Comparing it against an identity
1492 /// claimed elsewhere in the request compares two claims from the
1493 /// same untrusted source and proves nothing.
1494 ///
1495 /// The authenticated fields, and what they actually mean:
1496 ///
1497 /// - the AEAD-verified *wire-session peer* is the node that
1498 /// delivered the packet — the last hop, not necessarily the
1499 /// originator (it is `RpcInboundEvent::from_node`, the wire-session
1500 /// peer's `NodeId`);
1501 /// - [`RpcContext::org_admission`] carries a verified four-party
1502 /// identity, but only for calls admitted through the
1503 /// PROTECTED-service gate; it is `None` for public calls.
1504 ///
1505 /// For anything stronger on a public service, the handler needs
1506 /// an application-level signature over a transcript that binds
1507 /// the destination and carries its own freshness.
1508 pub caller_origin: u64,
1509 /// Caller-generated correlation id. Same value on the matching
1510 /// CANCEL or RESPONSE.
1511 pub call_id: u64,
1512 /// Decoded request payload.
1513 pub payload: RpcRequestPayload,
1514 /// Cancellation signal. Handlers should `select!` on
1515 /// `cancellation.cancelled()` if their work is async-cancellable;
1516 /// long-running synchronous handlers should periodically check
1517 /// `cancellation.is_cancelled()`.
1518 pub cancellation: RpcCancellationToken,
1519 /// W3C Trace Context propagated from the caller, if the
1520 /// caller set `FLAG_RPC_PROPAGATE_TRACE` and supplied
1521 /// `traceparent` / `tracestate` headers in the request. The
1522 /// server's handler reads this to continue the distributed
1523 /// trace. `None` for calls that didn't propagate trace
1524 /// context.
1525 pub trace_context: Option<TraceContext>,
1526 /// OA-2 org-admission attribution (E1.6). `Some(Admitted)` for a
1527 /// call that passed the PROTECTED-service admission gate — the
1528 /// four-party verified identity (caller, acting org, provider org,
1529 /// exact provider, capability). The raw `net-org-admission` proof
1530 /// header is STRIPPED from `payload.headers` before the handler
1531 /// sees it, so application code receives verified attribution, never
1532 /// raw credential material. `None` for public
1533 /// (`PublicAuthenticated`) calls, which keep existing header
1534 /// behavior.
1535 pub org_admission: Option<crate::adapter::net::behavior::org_admission::Admitted>,
1536}
1537
1538/// Handler-side error that doesn't fit the application's normal
1539/// `Ok(RpcResponsePayload)` channel. The fold maps these onto a
1540/// failure-status `RpcResponsePayload` for the caller.
1541#[derive(Debug, thiserror::Error)]
1542pub enum RpcHandlerError {
1543 /// Application-defined error. The fold encodes this as
1544 /// `RpcStatus::Application(code)` with `message` as the body.
1545 #[error("application error {code:#06x}: {message}")]
1546 Application {
1547 /// Application error code; surfaces as `RpcStatus::Application(code)`
1548 /// to the caller. Use `0x8000..=0xFFFF` to avoid the
1549 /// reserved canonical range.
1550 code: u16,
1551 /// Diagnostic. Becomes the response body (UTF-8 bytes).
1552 message: String,
1553 },
1554 /// Catch-all for handler-internal failures. The fold encodes this
1555 /// as `RpcStatus::Internal` with `message` as the body.
1556 #[error("internal: {0}")]
1557 Internal(String),
1558}
1559
1560/// User-supplied handler. Implementors typically wrap their state
1561/// (or an `Arc<Mutex<State>>`) and route to the appropriate logic
1562/// based on `ctx.payload.service` or per-handler dispatch.
1563///
1564/// Multiple `Mesh::serve_rpc` registrations on different services
1565/// each install their own handler; a single handler typically
1566/// services one service.
1567#[async_trait::async_trait]
1568pub trait RpcHandler: Send + Sync + 'static {
1569 /// Process one request and return the response payload. The
1570 /// fold spawns this in a tokio task; the fold itself doesn't
1571 /// block on it. Handlers should respect `ctx.cancellation` for
1572 /// cooperative early-abort.
1573 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError>;
1574}
1575
1576/// Callback the fold invokes to publish a response back to the
1577/// caller. Wired up by `Mesh::serve_rpc` to publish on
1578/// `<service>.replies.<caller_origin>`. Type-erased so the fold
1579/// doesn't depend on the mesh layer directly.
1580///
1581/// Arguments: `(from_node, caller_origin, call_id, response_payload)`.
1582/// `from_node` (R2-5) is the AEAD-authenticated session peer that
1583/// delivered the REQUEST — the authoritative response destination, so
1584/// two sessions pinned to the same entity/origin submitting the same
1585/// call_id each route their response to their OWN session.
1586pub type RpcResponseEmitter =
1587 Arc<dyn Fn(u64, u64, u64, RpcResponsePayload) + Send + Sync + 'static>;
1588
1589/// Async counterpart of [`RpcResponseEmitter`] used by the
1590/// streaming fold's pump task to serialize per-call publishes.
1591///
1592/// The streaming pump awaits each emit before reading the next
1593/// chunk from the sink — this guarantees that chunks for one
1594/// `call_id` reach the network publish path in the order the
1595/// handler emitted them. (The unary fold has no such requirement
1596/// — it emits exactly one RESPONSE per call — so it sticks with
1597/// the simpler sync `RpcResponseEmitter`.)
1598pub type RpcAsyncResponseEmitter = Arc<
1599 dyn Fn(u64, u64, u64, RpcResponsePayload) -> futures::future::BoxFuture<'static, ()>
1600 + Send
1601 + Sync
1602 + 'static,
1603>;
1604
1605/// `(from_node, caller_origin, call_id)` → cancellation token for an
1606/// in-flight call. `from_node` (AV-1 item 1) is the AEAD-authenticated
1607/// last-hop session peer, so a control frame that copies another peer's
1608/// origin + call_id lands under a distinct, absent key and cannot
1609/// cancel or otherwise mutate the victim's call. Shared across all four
1610/// server folds.
1611type InFlightCalls = Arc<Mutex<HashMap<(u64, u64, u64), RpcCancellationToken>>>;
1612
1613/// Server-side fold. Sees REQUEST events on the configured channel,
1614/// dispatches to the user-supplied handler, emits RESPONSE events
1615/// via the supplied emitter. CANCEL events flip the matching
1616/// in-flight token.
1617///
1618/// State `()` — the user's state lives on whatever the `RpcHandler`
1619/// captures (typically `Arc<Mutex<S>>`). The fold's own state (the
1620/// in-flight map) lives on `&mut self` and is shared with spawned
1621/// handler tasks via `Arc<Mutex<...>>` so the task can self-clean
1622/// on completion.
1623pub struct RpcServerFold {
1624 handler: Arc<dyn RpcHandler>,
1625 emit: RpcResponseEmitter,
1626 /// (from_node, caller_origin, call_id) → cancellation token for
1627 /// the in-flight handler. `from_node` is the AEAD-authenticated
1628 /// last-hop session peer (AV-1 item 1): binding it into the key
1629 /// means a peer that copies another peer's origin + call_id onto
1630 /// a forged CANCEL looks up a distinct, absent key and no-ops
1631 /// rather than cancelling the victim's call. Inserted on REQUEST,
1632 /// removed by either the spawned handler task on completion or by
1633 /// the fold on CANCEL. Wrapped in `Arc<Mutex<...>>` so spawned
1634 /// tasks can remove their own entries without going back through
1635 /// the fold.
1636 in_flight: InFlightCalls,
1637 /// Optional per-service metrics handle. When `Some`, the
1638 /// spawned handler task bumps `handler_invocations_total` /
1639 /// `handler_in_flight` / `handler_panics_total` and records
1640 /// per-task wall-clock durations. `None` → no metrics
1641 /// (test-only path; production `Mesh::serve_rpc` always
1642 /// supplies one).
1643 metrics: Option<Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>>,
1644 /// Optional clock override for tests. `None` → real wall-clock
1645 /// `unix_nanos`. `Some(...)` → fixed value, lets tests pin
1646 /// deadline-already-passed behavior without sleeping.
1647 #[cfg(test)]
1648 test_now_ns: Option<u64>,
1649}
1650
1651impl RpcServerFold {
1652 /// Construct a server fold around `handler`. `emit` is the
1653 /// callback that publishes RESPONSE events to the caller's
1654 /// reply channel — `Mesh::serve_rpc` wires this to the
1655 /// publisher for `<service>.replies.<caller_origin>`.
1656 /// Constructed without a metrics handle; production callers
1657 /// chain `.with_metrics(...)` to opt into per-service
1658 /// counters.
1659 pub fn new(handler: Arc<dyn RpcHandler>, emit: RpcResponseEmitter) -> Self {
1660 Self {
1661 handler,
1662 emit,
1663 in_flight: Arc::new(Mutex::new(HashMap::new())),
1664 metrics: None,
1665 #[cfg(test)]
1666 test_now_ns: None,
1667 }
1668 }
1669
1670 /// Attach a per-service metrics handle. Hooks the spawned
1671 /// handler task to bump `handler_invocations_total`, balance
1672 /// `handler_in_flight`, count panics, and record handler
1673 /// duration into the histogram.
1674 pub fn with_metrics(
1675 mut self,
1676 metrics: Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>,
1677 ) -> Self {
1678 self.metrics = Some(metrics);
1679 self
1680 }
1681
1682 /// Test-only: pin the clock the fold uses for deadline
1683 /// short-circuit. Lets a unit test exercise the
1684 /// deadline-already-passed branch without waiting for wall
1685 /// time.
1686 #[cfg(test)]
1687 pub fn with_test_now_ns(mut self, now_ns: u64) -> Self {
1688 self.test_now_ns = Some(now_ns);
1689 self
1690 }
1691
1692 /// Test-only: snapshot of the in-flight call set.
1693 #[cfg(test)]
1694 pub fn in_flight_keys(&self) -> Vec<(u64, u64, u64)> {
1695 self.in_flight.lock().keys().copied().collect()
1696 }
1697
1698 fn now_ns(&self) -> u64 {
1699 #[cfg(test)]
1700 if let Some(t) = self.test_now_ns {
1701 return t;
1702 }
1703 std::time::SystemTime::now()
1704 .duration_since(std::time::UNIX_EPOCH)
1705 .map(|d| d.as_nanos() as u64)
1706 .unwrap_or(0)
1707 }
1708
1709 /// `true` if the request's deadline has already elapsed at
1710 /// the server's current wall-clock — accounting for a small
1711 /// tolerance window that absorbs clock skew between caller
1712 /// and server. Without the tolerance, a request from a peer
1713 /// whose clock is a few hundred ms ahead of the server's
1714 /// would be timed out before the handler even saw it. Matches
1715 /// gRPC's default deadline-clock-skew tolerance shape (gRPC
1716 /// uses ~10 s).
1717 fn deadline_already_passed(&self, deadline_ns: u64) -> bool {
1718 if deadline_ns == 0 {
1719 return false;
1720 }
1721 self.now_ns().saturating_sub(DEADLINE_SKEW_TOLERANCE_NS) > deadline_ns
1722 }
1723}
1724
1725/// Tolerance for clock skew between caller and server when the
1726/// server short-circuits a request whose `deadline_ns` looks like
1727/// it has already elapsed. The check is
1728/// `now_ns - SKEW > deadline_ns`, so a request from a peer whose
1729/// clock is up to `SKEW` nanoseconds ahead of ours never hits the
1730/// short-circuit path. 10 s matches gRPC's default and is well
1731/// within the threshold an NTP-disciplined cluster ever drifts to.
1732pub const DEADLINE_SKEW_TOLERANCE_NS: u64 = 10_000_000_000; // 10 seconds
1733
1734impl RpcServerFold {
1735 /// Production-path entry point. The serve bridge calls this with
1736 /// the AEAD-verified session peer's `NodeId` in `ev.from_node`;
1737 /// all per-call state is keyed by `(from_node, claimed_origin,
1738 /// call_id)` so no peer can create, cancel, or otherwise mutate
1739 /// another peer's call by copying its origin + call_id (AV-1
1740 /// item 1).
1741 pub fn apply_inbound(&mut self, ev: &RpcInboundEvent) -> Result<(), RedexError> {
1742 self.apply_frame(ev.from_node, &ev.payload, None)
1743 }
1744
1745 /// OA-2 protected entry point (E1.6). The protected serve bridge
1746 /// calls this AFTER `verify_org_admission` succeeds, handing the
1747 /// four-party [`Admitted`](crate::adapter::net::behavior::org_admission::Admitted)
1748 /// attribution the fold places into `RpcContext::org_admission`; the
1749 /// fold also STRIPS every `net-org-admission` header from the payload
1750 /// so the handler never sees the raw proof. Unary-only — the
1751 /// streaming/duplex folds have no admitted entry point (E1.8).
1752 pub fn apply_inbound_admitted(
1753 &mut self,
1754 ev: &RpcInboundEvent,
1755 admitted: crate::adapter::net::behavior::org_admission::Admitted,
1756 ) -> Result<(), RedexError> {
1757 self.apply_frame(ev.from_node, &ev.payload, Some(admitted))
1758 }
1759
1760 /// Core frame application shared by [`Self::apply_inbound`] (real
1761 /// authenticated `from_node`) and the [`RedexFold`] loopback shim
1762 /// (`from_node = 0`, test / loopback paths with no session peer).
1763 /// `org_admission` is `Some` only on the initial REQUEST of an
1764 /// admitted protected call; it is placed into the handler's
1765 /// `RpcContext` and its presence triggers the proof-header strip.
1766 fn apply_frame(
1767 &mut self,
1768 from_node: u64,
1769 frame: &Bytes,
1770 org_admission: Option<crate::adapter::net::behavior::org_admission::Admitted>,
1771 ) -> Result<(), RedexError> {
1772 // Decode the meta header. A garbled meta means the event
1773 // doesn't even claim to be an RPC packet — log and skip
1774 // rather than killing the fold. Returning `Err(Decode)`
1775 // here would stop the entire cortex adapter for one
1776 // malformed event, which is wrong for an RPC server that
1777 // needs to keep serving.
1778 let Some(meta) = (if frame.len() >= EVENT_META_SIZE {
1779 EventMeta::from_bytes(&frame[..EVENT_META_SIZE])
1780 } else {
1781 None
1782 }) else {
1783 tracing::warn!(
1784 payload_len = frame.len(),
1785 "rpc server fold: event payload too short for EventMeta; skipping",
1786 );
1787 return Ok(());
1788 };
1789 let key = (from_node, meta.origin_hash, meta.seq_or_ts);
1790 match meta.dispatch {
1791 DISPATCH_RPC_REQUEST => {
1792 let mut payload =
1793 match RpcRequestPayload::decode(frame.slice(RPC_FRAME_BODY_OFFSET..)) {
1794 Ok(p) => p,
1795 Err(e) => {
1796 // Malformed request payload. Surface as
1797 // `UnknownVersion` to the caller — they sent
1798 // bytes we couldn't parse, which usually
1799 // means a wire-format mismatch (the most
1800 // common cause). Log so operators can
1801 // diagnose.
1802 tracing::warn!(
1803 error = %e,
1804 caller_origin = format!("{:#x}", meta.origin_hash),
1805 call_id = meta.seq_or_ts,
1806 "rpc server fold: malformed request payload",
1807 );
1808 let resp = RpcResponsePayload {
1809 status: RpcStatus::UnknownVersion,
1810 headers: vec![],
1811 body: Bytes::from(format!("malformed request: {e}")),
1812 };
1813 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
1814 return Ok(());
1815 }
1816 };
1817 // E1.6: an admitted protected call STRIPS the raw
1818 // `net-org-admission` proof header(s) before the handler
1819 // sees the payload — application code receives verified
1820 // attribution via `RpcContext::org_admission`, never the
1821 // credential material. Public calls (`org_admission` None)
1822 // keep their headers untouched.
1823 if org_admission.is_some() {
1824 payload.headers.retain(|(name, _)| {
1825 name != crate::adapter::net::behavior::org_call::ORG_ADMISSION_HEADER
1826 });
1827 }
1828 // Fast deadline-already-passed short-circuit.
1829 // Server-side `Timeout` without invoking the
1830 // handler. Includes a clock-skew tolerance window
1831 // so a peer with a slightly-fast clock isn't
1832 // prematurely timed out — see
1833 // `deadline_already_passed`.
1834 if self.deadline_already_passed(payload.deadline_ns) {
1835 let resp = RpcResponsePayload {
1836 status: RpcStatus::Timeout,
1837 headers: vec![],
1838 body: Bytes::from_static(b"deadline already passed when request landed"),
1839 };
1840 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
1841 return Ok(());
1842 }
1843 // Refuse a duplicate REQUEST with the same
1844 // `(origin_hash, call_id)` — see streaming fold for
1845 // the full rationale. For the unary fold this would
1846 // spawn a second handler under the same key, and
1847 // whichever handler completes first removes the
1848 // in-flight entry — leaving the second handler's
1849 // CANCEL handling broken (CANCEL events look up
1850 // the now-missing key and no-op). Cleaner to refuse.
1851 {
1852 let in_flight = self.in_flight.lock();
1853 if in_flight.contains_key(&key) {
1854 drop(in_flight);
1855 tracing::warn!(
1856 caller_origin = format!("{:#x}", meta.origin_hash),
1857 call_id = meta.seq_or_ts,
1858 "rpc server fold: duplicate REQUEST for in-flight call_id; refusing",
1859 );
1860 let resp = RpcResponsePayload {
1861 status: RpcStatus::Internal,
1862 headers: vec![],
1863 body: Bytes::from_static(
1864 b"duplicate REQUEST for already-in-flight call_id",
1865 ),
1866 };
1867 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
1868 return Ok(());
1869 }
1870 }
1871 let cancellation = RpcCancellationToken::new();
1872 self.in_flight.lock().insert(key, cancellation.clone());
1873 let handler = self.handler.clone();
1874 let emit = self.emit.clone();
1875 let in_flight = self.in_flight.clone();
1876 let caller_origin = meta.origin_hash;
1877 let call_id = meta.seq_or_ts;
1878 // Decode the W3C Trace Context if the caller
1879 // signaled it via `FLAG_RPC_PROPAGATE_TRACE` and
1880 // included the `traceparent` / `tracestate`
1881 // headers. nRPC is transport-only — application
1882 // code reads `ctx.trace_context` to continue the
1883 // trace via whatever backend it has wired up.
1884 let trace_context = if payload.flags & FLAG_RPC_PROPAGATE_TRACE != 0 {
1885 extract_trace_context(&payload.headers)
1886 } else {
1887 None
1888 };
1889 let metrics = self.metrics.clone();
1890 // Keep a probe handle so the spawned task can detect
1891 // a CANCEL that fired during handler execution and
1892 // override its response with `RpcStatus::Cancelled`.
1893 let cancel_probe = cancellation.clone();
1894 tokio::spawn(async move {
1895 // Server-side metrics: count this invocation;
1896 // bump in_flight; time the handler; tally
1897 // panics. Only fires when a metrics handle was
1898 // attached via `with_metrics(...)` — test-only
1899 // folds construct without one.
1900 if let Some(m) = metrics.as_ref() {
1901 m.handler_invocations_total
1902 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1903 m.handler_in_flight
1904 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1905 }
1906 let handler_started = std::time::Instant::now();
1907 let ctx = RpcContext {
1908 caller_origin,
1909 call_id,
1910 payload,
1911 cancellation,
1912 trace_context,
1913 org_admission,
1914 };
1915 // Catch panics so a misbehaving handler can't
1916 // take down the runtime. `AssertUnwindSafe` is
1917 // load-bearing because `RpcHandler::call`
1918 // returns a future that may borrow non-
1919 // `UnwindSafe` types from the handler; we
1920 // accept the assertion because the handler's
1921 // state is untouched on panic (we just don't
1922 // observe its in-progress mutations).
1923 let outcome = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(
1924 handler.call(ctx),
1925 ))
1926 .await;
1927 if let Some(m) = metrics.as_ref() {
1928 m.handler_in_flight
1929 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1930 m.record_handler_duration(handler_started.elapsed());
1931 if outcome.is_err() {
1932 m.handler_panics_total
1933 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1934 }
1935 }
1936 // CANCEL-wins ordering: if the cancellation
1937 // token fired at any point during handler
1938 // execution, override the handler's outcome
1939 // with `RpcStatus::Cancelled` so the caller
1940 // (or hedge primary, retry layer, etc.) sees
1941 // the documented `Cancelled` status code rather
1942 // than whatever the handler happened to return
1943 // before / despite cancellation. A cooperative
1944 // handler that observes the token and bails
1945 // early gets the same Cancelled framing as a
1946 // handler that ignored cancellation and ran to
1947 // completion — the caller's view is uniform.
1948 let resp = if cancel_probe.is_cancelled() {
1949 RpcResponsePayload {
1950 status: RpcStatus::Cancelled,
1951 headers: vec![],
1952 body: Bytes::from_static(
1953 b"server observed CANCEL during handler execution",
1954 ),
1955 }
1956 } else {
1957 match outcome {
1958 Ok(Ok(payload)) => payload,
1959 Ok(Err(RpcHandlerError::Application { code, message })) => {
1960 RpcResponsePayload {
1961 status: RpcStatus::Application(code),
1962 headers: vec![],
1963 body: Bytes::from(message),
1964 }
1965 }
1966 Ok(Err(RpcHandlerError::Internal(message))) => RpcResponsePayload {
1967 status: RpcStatus::Internal,
1968 headers: vec![],
1969 body: Bytes::from(message),
1970 },
1971 Err(panic) => {
1972 let panic_msg = panic
1973 .downcast_ref::<&'static str>()
1974 .map(|s| s.to_string())
1975 .or_else(|| panic.downcast_ref::<String>().cloned())
1976 .unwrap_or_else(|| "<non-string panic>".into());
1977 tracing::error!(
1978 caller_origin = format!("{:#x}", caller_origin),
1979 call_id,
1980 panic = %panic_msg,
1981 "rpc server handler panicked",
1982 );
1983 RpcResponsePayload {
1984 status: RpcStatus::Internal,
1985 headers: vec![],
1986 body: Bytes::from(format!("handler panicked: {panic_msg}")),
1987 }
1988 }
1989 }
1990 };
1991 in_flight.lock().remove(&key);
1992 emit(from_node, caller_origin, call_id, resp);
1993 });
1994 }
1995 DISPATCH_RPC_CANCEL => {
1996 if let Some(token) = self.in_flight.lock().remove(&key) {
1997 token.cancel();
1998 }
1999 // Idempotent — CANCEL for an unknown call_id (e.g.
2000 // a CANCEL that races the handler's completion) is
2001 // a no-op rather than an error. The spawned handler
2002 // task observes `cancel_probe.is_cancelled()` after
2003 // its future resolves and overrides the response
2004 // with `RpcStatus::Cancelled` so the caller sees a
2005 // documented status code rather than the handler's
2006 // accidental Ok / Internal payload.
2007 }
2008 // RESPONSE / DEADLINE_EXCEEDED are server-emitted; if
2009 // the server's own fold sees them (e.g. from a replay)
2010 // there's nothing to do.
2011 _ => {}
2012 }
2013 Ok(())
2014 }
2015}
2016
2017impl RedexFold<()> for RpcServerFold {
2018 /// Loopback / test shim: no session peer to resolve, so this
2019 /// drives `apply_frame` with `from_node = 0` (AV-1 item 1). The
2020 /// production wire path uses [`RpcServerFold::apply_inbound`].
2021 fn apply(&mut self, ev: &RedexEvent, _state: &mut ()) -> Result<(), RedexError> {
2022 self.apply_frame(0, &ev.payload, None)
2023 }
2024}
2025
2026// ============================================================================
2027// Streaming server-side: handler trait + sink + fold.
2028// ============================================================================
2029
2030/// Sink the handler writes to in order to emit streaming-response
2031/// chunks. Each `send` produces one non-terminal `RESPONSE` event
2032/// to the caller. The terminal frame is emitted automatically when
2033/// the sink is dropped — the handler returning `Ok(())` drops the
2034/// sink, which closes the stream cleanly. Returning
2035/// `Err(RpcHandlerError)` drops the sink and emits the error as a
2036/// terminal non-`Ok` RESPONSE.
2037///
2038/// `send` is best-effort and infallible: the underlying mpsc is
2039/// **bounded** at [`STREAMING_PUMP_CAPACITY`] chunks. If the pump
2040/// can't keep up (publish path is congested, caller hasn't granted
2041/// flow-control credits), `send` discards on overflow — same
2042/// observable shape as a closed receiver (caller cancelled mid-
2043/// stream). Counts the drop in `streaming_chunks_dropped_total` so
2044/// operators can see backpressure occurring. Cooperative
2045/// cancellation via `ctx.cancellation` is the right way for the
2046/// handler to notice the consumer is gone; opt-in flow control via
2047/// `CallOptions::stream_window_initial` is the right way to
2048/// throttle a fast handler against a slow consumer.
2049pub struct RpcResponseSink {
2050 inner: tokio::sync::mpsc::Sender<bytes::Bytes>,
2051 /// Optional metrics handle so a dropped-on-full chunk bumps the
2052 /// `streaming_chunks_dropped_total` counter. `None` for unit-
2053 /// test folds that construct without metrics.
2054 metrics: Option<Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>>,
2055}
2056
2057impl RpcResponseSink {
2058 /// Emit one non-terminal chunk. Cheap (`try_send` on a
2059 /// [`STREAMING_PUMP_CAPACITY`]-bounded mpsc); never blocks. On
2060 /// overflow OR receiver-closed, the chunk is dropped and (when
2061 /// metrics are wired) `streaming_chunks_dropped_total` is
2062 /// incremented for the service.
2063 pub fn send(&self, body: impl Into<bytes::Bytes>) {
2064 if self.inner.try_send(body.into()).is_err() {
2065 if let Some(m) = self.metrics.as_ref() {
2066 m.streaming_chunks_dropped_total
2067 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2068 }
2069 }
2070 }
2071
2072 /// Emit one non-terminal chunk, **waiting** for pump-queue room
2073 /// instead of dropping on overflow. The backpressure-aware
2074 /// sibling of [`Self::send`]: when the per-call pump queue
2075 /// ([`STREAMING_PUMP_CAPACITY`]) is full — e.g. a flow-controlled
2076 /// caller has stopped granting credit — this parks until the
2077 /// pump drains a slot rather than silently discarding the chunk.
2078 ///
2079 /// Returns [`RpcSinkClosed`] when the pump receiver is gone (the
2080 /// call is torn down); the chunk was not sent and the handler
2081 /// should stop producing.
2082 ///
2083 /// Use this from handlers whose stream carries deltas that must
2084 /// not be lost silently (e.g. the `tool.watch` subscription,
2085 /// whose overflow contract requires an explicit resync frame
2086 /// instead of a drop); keep [`Self::send`] for streams where
2087 /// dropping under backpressure is acceptable.
2088 pub async fn send_wait(&self, body: impl Into<bytes::Bytes>) -> Result<(), RpcSinkClosed> {
2089 self.inner
2090 .send(body.into())
2091 .await
2092 .map_err(|_| RpcSinkClosed)
2093 }
2094}
2095
2096/// Error returned by [`RpcResponseSink::send_wait`] when the
2097/// per-call pump receiver has shut down (the streaming call is
2098/// being torn down server-side). The chunk was not sent.
2099#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2100pub struct RpcSinkClosed;
2101
2102impl std::fmt::Display for RpcSinkClosed {
2103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2104 write!(
2105 f,
2106 "rpc streaming sink: pump receiver closed; chunk not sent"
2107 )
2108 }
2109}
2110
2111impl std::error::Error for RpcSinkClosed {}
2112
2113/// Bounded capacity for the streaming pump's internal mpsc. A
2114/// runaway handler that produces chunks faster than the publish
2115/// path can drain them stops blocking the runtime past this many
2116/// queued chunks — additional chunks are dropped (and counted via
2117/// `streaming_chunks_dropped_total`). 1024 is generous for typical
2118/// streaming patterns; opt-in flow control via
2119/// `CallOptions::stream_window_initial` is the right primitive for
2120/// strict throttling.
2121pub const STREAMING_PUMP_CAPACITY: usize = 1024;
2122
2123/// Bounded capacity for the client-streaming server fold's
2124/// per-call request mpsc. Mirror of [`STREAMING_PUMP_CAPACITY`]
2125/// for the upload direction. A runaway caller that emits
2126/// REQUEST_CHUNKs faster than the handler can drain stops
2127/// queueing past this many chunks — additional chunks are
2128/// dropped (and counted via `streaming_request_chunks_dropped_total`
2129/// when metrics are wired). Opt-in flow control via the
2130/// `nrpc-request-window-initial` header is the right primitive
2131/// for strict throttling on the upload side.
2132///
2133/// Bidi streaming plan (Phase B).
2134pub const STREAMING_REQUEST_PUMP_CAPACITY: usize = 1024;
2135
2136// ============================================================================
2137// Phase B — server-side primitives for client-streaming.
2138// ============================================================================
2139
2140/// Context handed to an [`RpcClientStreamingHandler::call`]. Same
2141/// shape as [`RpcContext`] minus the eager `payload` (the request
2142/// stream delivers chunk bodies on the fly) and plus the
2143/// per-call `deadline_ns` (which would otherwise have ridden on
2144/// the eager payload).
2145///
2146/// Bidi streaming plan (Phase B).
2147pub struct RpcStreamingContext {
2148 /// Caller's `origin_hash`, from the inbound packet header. Same
2149 /// source, and the same caveat, as [`RpcContext::caller_origin`]:
2150 /// **routing metadata, not identity authentication — do not
2151 /// authorize on this.**
2152 pub caller_origin: u64,
2153 /// Caller-generated correlation id. Matches the initial
2154 /// REQUEST's `call_id` and every subsequent REQUEST_CHUNK /
2155 /// CANCEL / REQUEST_GRANT for this call.
2156 pub call_id: u64,
2157 /// Absolute deadline (unix nanos) from the initial REQUEST.
2158 /// `0` means no deadline; the fold does NOT auto-cancel on
2159 /// deadline (handlers self-supervise via tokio timers, same
2160 /// contract as the unary fold).
2161 pub deadline_ns: u64,
2162 /// Per-chunk metadata headers from the initial REQUEST.
2163 /// Per-REQUEST_CHUNK headers are NOT surfaced at the substrate
2164 /// layer — the typed SDK veneer (Phase E) is where header
2165 /// inspection across chunks lives (if it lands at all; the
2166 /// plan defers per-chunk-headers as opt-in raw-path access).
2167 pub headers: Vec<RpcHeader>,
2168 /// Cancellation signal. Flipped by the fold when a
2169 /// `DISPATCH_RPC_CANCEL` arrives for this call's `call_id`.
2170 /// Long-running handlers should `select!` on
2171 /// `cancellation.cancelled()`; the request stream also
2172 /// terminates on cancellation, but the token is the
2173 /// authoritative signal (the stream's terminator is shared
2174 /// with REQUEST_END).
2175 pub cancellation: RpcCancellationToken,
2176 /// W3C Trace Context propagated from the caller's initial
2177 /// REQUEST. Same semantics as [`RpcContext::trace_context`].
2178 pub trace_context: Option<TraceContext>,
2179}
2180
2181/// Callback the fold invokes to publish a [`DISPATCH_RPC_REQUEST_GRANT`]
2182/// event back to the caller. Wired up by the `Mesh` glue (Phase C)
2183/// to publish on the caller's reply channel. Type-erased so the
2184/// fold doesn't depend on the mesh layer directly.
2185///
2186/// Arguments: `(caller_origin, call_id, credits)`. Synchronous —
2187/// the publish itself is non-blocking (the underlying transport
2188/// has its own internal queueing); the fold fires-and-forgets
2189/// every grant, so dropped grants are at worst a latency wobble,
2190/// not a correctness issue (the caller's send sink will retry
2191/// when its credit budget refills via the next grant or via the
2192/// initial window).
2193///
2194/// Bidi streaming plan (Phase B).
2195/// Arguments: `(from_node, caller_origin, call_id, credits)`. R3-1 —
2196/// the AEAD-authenticated `from_node` is carried so an upload grant is
2197/// session-scoped: coalesced and published per `(from_node, origin,
2198/// call_id)`, and (for a trusted direct route) delivered ONLY to the
2199/// session that issued the call, never roster-fanned to a same-origin
2200/// sibling whose request semaphore it would otherwise wrongly refill.
2201pub type RpcRequestGrantEmitter = Arc<dyn Fn(u64, u64, u64, u32) + Send + Sync + 'static>;
2202
2203/// Server-side stream of inbound request chunk bodies for one
2204/// client-streaming (or duplex) call. Yields one `Bytes` per
2205/// `DISPATCH_RPC_REQUEST` / `DISPATCH_RPC_REQUEST_CHUNK` frame
2206/// (including empty bodies — the semantics of "empty bytes" are
2207/// the application's concern, not the substrate's). Closes on
2208/// `FLAG_RPC_REQUEST_END` or on CANCEL.
2209///
2210/// **Stream item ordering convention**: the first item this
2211/// stream yields corresponds to the initial REQUEST body; every
2212/// subsequent item corresponds to a REQUEST_CHUNK body, in the
2213/// order the chunks were received from the wire. The substrate
2214/// does not tag items with their frame kind — the SDK veneer
2215/// (Phase E) is responsible for the Init / Data classification
2216/// via its `Chunk<T>` enum.
2217///
2218/// **Auto-grant behavior**: when the caller opted into
2219/// request-direction flow control via
2220/// [`HEADER_NRPC_REQUEST_WINDOW_INITIAL`], every successful
2221/// `poll_next()` fires one credit back to the caller via the
2222/// captured `grant_emitter`. This keeps the in-flight window
2223/// at the caller's initial value as the handler drains the
2224/// stream. When the caller did NOT opt in (no header), the
2225/// `grant_emitter` is `None` and the auto-grant path is a no-op
2226/// (caller is on the unbounded-credit fast path).
2227///
2228/// Bidi streaming plan (Phase B).
2229pub struct RequestStream {
2230 inner: tokio::sync::mpsc::Receiver<bytes::Bytes>,
2231 grant_emitter: Option<RpcRequestGrantEmitter>,
2232 /// AEAD-authenticated session that issued this call (R3-1) — the
2233 /// grant identity, so a grant refills only THIS call's semaphore.
2234 from_node: u64,
2235 caller_origin: u64,
2236 call_id: u64,
2237}
2238
2239impl RequestStream {
2240 /// Visible to the fold (and only the fold) for constructing
2241 /// a stream tied to a specific receiver + caller. The
2242 /// `grant_emitter` is `None` when the caller didn't opt into
2243 /// flow control; `Some(...)` when they did. `from_node` is the
2244 /// authenticated session the fold bound the call to (R3-1).
2245 pub(crate) fn new(
2246 inner: tokio::sync::mpsc::Receiver<bytes::Bytes>,
2247 grant_emitter: Option<RpcRequestGrantEmitter>,
2248 from_node: u64,
2249 caller_origin: u64,
2250 call_id: u64,
2251 ) -> Self {
2252 Self {
2253 inner,
2254 grant_emitter,
2255 from_node,
2256 caller_origin,
2257 call_id,
2258 }
2259 }
2260}
2261
2262impl futures::Stream for RequestStream {
2263 type Item = bytes::Bytes;
2264
2265 fn poll_next(
2266 mut self: std::pin::Pin<&mut Self>,
2267 cx: &mut std::task::Context<'_>,
2268 ) -> std::task::Poll<Option<Self::Item>> {
2269 match self.inner.poll_recv(cx) {
2270 std::task::Poll::Ready(Some(bytes)) => {
2271 // Auto-grant fires on every successful pull when
2272 // flow control was opted into. Cheap and
2273 // fire-and-forget; missed grants are recovered
2274 // by subsequent pulls.
2275 if let Some(emit) = self.grant_emitter.as_ref() {
2276 emit(self.from_node, self.caller_origin, self.call_id, 1);
2277 }
2278 std::task::Poll::Ready(Some(bytes))
2279 }
2280 other => other,
2281 }
2282 }
2283}
2284
2285/// User-supplied handler for a client-streaming RPC. Receives an
2286/// [`RpcStreamingContext`] (caller identity, deadline, cancellation,
2287/// trace context, initial REQUEST headers) plus a [`RequestStream`]
2288/// of chunk bodies. Returns one terminal [`RpcResponsePayload`] —
2289/// the fold publishes it as the call's single RESPONSE frame.
2290///
2291/// **Cancellation contract.** Long-running handlers should
2292/// `select!` on `ctx.cancellation.cancelled()` so a caller-side
2293/// drop / deadline correctly stops the handler. The request
2294/// stream also terminates on cancellation (yields `None`), but
2295/// the token is the authoritative signal — the stream's `None`
2296/// is shared with the clean REQUEST_END path, so handlers can't
2297/// distinguish "caller finished cleanly" from "caller cancelled"
2298/// without consulting the token.
2299///
2300/// **Auto-grant.** When the caller opted into request-direction
2301/// flow control via [`HEADER_NRPC_REQUEST_WINDOW_INITIAL`], every
2302/// `stream.next().await` that yields `Some` fires one
2303/// REQUEST_GRANT back to the caller, maintaining the in-flight
2304/// window at the caller's initial value. Handlers don't need to
2305/// think about credit management for the common case.
2306///
2307/// Bidi streaming plan (Phase B).
2308#[async_trait::async_trait]
2309pub trait RpcClientStreamingHandler: Send + Sync + 'static {
2310 /// Process a client-streaming call. Drain the request stream,
2311 /// produce one terminal response payload (or an
2312 /// [`RpcHandlerError`] for failure mapping).
2313 async fn call(
2314 &self,
2315 ctx: RpcStreamingContext,
2316 requests: RequestStream,
2317 ) -> Result<RpcResponsePayload, RpcHandlerError>;
2318}
2319
2320/// User-supplied handler for a duplex RPC — many requests in,
2321/// many responses out, interleaved. Receives an [`RpcStreamingContext`]
2322/// plus a [`RequestStream`] of chunk bodies plus an
2323/// [`RpcResponseSink`] for emitting response chunks. The handler's
2324/// return value is its terminal status, NOT a final payload:
2325/// `Ok(())` closes the response stream cleanly with a terminal
2326/// `Ok` frame, `Err(RpcHandlerError)` closes with the matching
2327/// error status.
2328///
2329/// **Composition.** A duplex handler is a hybrid of an
2330/// [`RpcClientStreamingHandler`] (drains request chunks) and an
2331/// [`RpcStreamingHandler`] (emits response chunks). The two
2332/// directions are independent — a handler can finish emitting
2333/// responses before reading all requests, or vice versa. The
2334/// server fold serializes RESPONSE chunk publishes per call_id
2335/// so wire order matches handler order.
2336///
2337/// **Cancellation contract.** Identical to
2338/// [`RpcClientStreamingHandler`]: long-running work should
2339/// `select!` on `ctx.cancellation.cancelled()`.
2340///
2341/// **Auto-grant.** Identical to [`RpcClientStreamingHandler`]:
2342/// every successful `requests.next().await` emits one
2343/// REQUEST_GRANT back to the caller (when the caller opted in).
2344///
2345/// Bidi streaming plan (Phase D).
2346#[async_trait::async_trait]
2347pub trait RpcDuplexHandler: Send + Sync + 'static {
2348 /// Process one duplex call. Drain inbound chunks via
2349 /// `requests.next().await`; emit outbound chunks via
2350 /// `responses.send(...)`. Return `Ok(())` for clean close,
2351 /// `Err(RpcHandlerError)` for failure mapping.
2352 async fn call(
2353 &self,
2354 ctx: RpcStreamingContext,
2355 requests: RequestStream,
2356 responses: RpcResponseSink,
2357 ) -> Result<(), RpcHandlerError>;
2358}
2359
2360/// User-supplied streaming handler. Receives the same `RpcContext`
2361/// as a unary handler plus a `RpcResponseSink` for emitting chunks.
2362/// Returning `Ok(())` closes the stream cleanly with a terminal
2363/// `Ok` RESPONSE; `Err(RpcHandlerError)` closes the stream with a
2364/// terminal non-`Ok` RESPONSE carrying the diagnostic.
2365///
2366/// **Cancellation contract.** Long-running streams should
2367/// `select!` on `ctx.cancellation.cancelled()` so a caller-side
2368/// drop / deadline correctly stops the handler. Continuing to
2369/// `send` after cancellation is harmless (sink discards) but
2370/// wastes work.
2371#[async_trait::async_trait]
2372pub trait RpcStreamingHandler: Send + Sync + 'static {
2373 /// Process one streaming request. Emit chunks via `sink.send(...)`.
2374 /// Drop the sink (or return) to close the stream.
2375 async fn call(&self, ctx: RpcContext, sink: RpcResponseSink) -> Result<(), RpcHandlerError>;
2376}
2377
2378/// Per-call flow-control map type. Keyed on
2379/// `(caller_origin_hash, call_id)`; value is a tokio
2380/// `Semaphore` shared between the pump task (which awaits
2381/// permits) and the fold's `apply()` method handling
2382/// STREAM_GRANT events (which add permits).
2383// Keyed on `(from_node, caller_origin, call_id)` (AV-1 item 1): the
2384// authenticated last-hop session peer is part of the key so a peer
2385// cannot refill another peer's flow-control window by copying its
2386// origin + call_id onto a forged STREAM_GRANT.
2387type FlowControlMap = Arc<Mutex<HashMap<(u64, u64, u64), Arc<tokio::sync::Semaphore>>>>;
2388
2389/// Server-side fold for streaming RPC. Parallel to `RpcServerFold`
2390/// but multi-fire emit: each handler invocation may produce many
2391/// `RESPONSE` events for the same `call_id`, marked
2392/// non-terminal/terminal via the `nrpc-streaming` header.
2393///
2394/// State `()` — like the unary fold, the handler owns user state
2395/// via captured `Arc<Mutex<S>>`. The fold's own state (in-flight
2396/// cancellation tokens) lives on `&mut self`.
2397pub struct RpcServerStreamingFold {
2398 handler: Arc<dyn RpcStreamingHandler>,
2399 emit: RpcAsyncResponseEmitter,
2400 /// (from_node, caller_origin, call_id) → cancellation token —
2401 /// authenticated-peer-scoped so a forged CANCEL can't cancel
2402 /// another peer's stream (AV-1 item 1).
2403 in_flight: InFlightCalls,
2404 /// Per-call flow-control semaphore (when the caller opted in).
2405 /// `Some(sem)` means "pump must `acquire().await` one permit
2406 /// per chunk before emitting; STREAM_GRANT events
2407 /// `add_permits(n)`". Absence of an entry for a `(origin,
2408 /// call_id)` key means unbounded credit (no flow control —
2409 /// pump emits as fast as the publish path can take chunks).
2410 flow_control: FlowControlMap,
2411 /// Optional per-service metrics handle. Same shape as
2412 /// `RpcServerFold::metrics`; the streaming fold ALSO bumps
2413 /// `streaming_chunks_emitted_total` from the pump task on
2414 /// every chunk.
2415 metrics: Option<Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>>,
2416}
2417
2418impl RpcServerStreamingFold {
2419 /// Construct a streaming server fold. `emit` publishes
2420 /// individual chunks (and the terminal frame) on the caller's
2421 /// reply channel.
2422 ///
2423 /// Uses the **async** emitter variant so the pump task can
2424 /// serialize per-call publishes — without that ordering
2425 /// guarantee, two chunks emitted in succession can race into
2426 /// the publish path and arrive at the caller out of order
2427 /// (or be eclipsed by the terminal frame and lost entirely).
2428 pub fn new(handler: Arc<dyn RpcStreamingHandler>, emit: RpcAsyncResponseEmitter) -> Self {
2429 Self {
2430 handler,
2431 emit,
2432 in_flight: Arc::new(Mutex::new(HashMap::new())),
2433 flow_control: Arc::new(Mutex::new(HashMap::new())),
2434 metrics: None,
2435 }
2436 }
2437
2438 /// Attach a per-service metrics handle. Hooks the spawned
2439 /// handler task to bump `handler_invocations_total` /
2440 /// `handler_in_flight` / `handler_panics_total` /
2441 /// `handler_duration_*`, and the pump task to bump
2442 /// `streaming_chunks_emitted_total` per emitted chunk.
2443 pub fn with_metrics(
2444 mut self,
2445 metrics: Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>,
2446 ) -> Self {
2447 self.metrics = Some(metrics);
2448 self
2449 }
2450
2451 /// Test-only: snapshot of the in-flight call set.
2452 #[cfg(test)]
2453 pub fn in_flight_keys(&self) -> Vec<(u64, u64, u64)> {
2454 self.in_flight.lock().keys().copied().collect()
2455 }
2456
2457 /// Test-only: available flow-control permits for a call key
2458 /// `(from_node, origin, call_id)`, or `None` if no per-call
2459 /// semaphore is installed. Lets the AV-1 STREAM_GRANT-hijack
2460 /// witness prove a forged grant from a foreign session does not
2461 /// refill the victim's window.
2462 #[cfg(test)]
2463 pub fn flow_control_permits(&self, key: (u64, u64, u64)) -> Option<usize> {
2464 self.flow_control
2465 .lock()
2466 .get(&key)
2467 .map(|s| s.available_permits())
2468 }
2469}
2470
2471impl RpcServerStreamingFold {
2472 /// Production-path entry point. Keys per-call state (in-flight
2473 /// token + flow-control semaphore) by `(from_node,
2474 /// claimed_origin, call_id)` so a forged CANCEL / STREAM_GRANT
2475 /// from another peer misses the map and no-ops (AV-1 item 1).
2476 pub fn apply_inbound(&mut self, ev: &RpcInboundEvent) -> Result<(), RedexError> {
2477 self.apply_frame(ev.from_node, &ev.payload)
2478 }
2479
2480 /// Core frame application shared by [`Self::apply_inbound`] (real
2481 /// `from_node`) and the [`RedexFold`] loopback shim (`0`).
2482 fn apply_frame(&mut self, from_node: u64, frame: &Bytes) -> Result<(), RedexError> {
2483 let Some(meta) = (if frame.len() >= EVENT_META_SIZE {
2484 EventMeta::from_bytes(&frame[..EVENT_META_SIZE])
2485 } else {
2486 None
2487 }) else {
2488 tracing::warn!(
2489 payload_len = frame.len(),
2490 "rpc streaming server fold: event payload too short for EventMeta",
2491 );
2492 return Ok(());
2493 };
2494 let key = (from_node, meta.origin_hash, meta.seq_or_ts);
2495 match meta.dispatch {
2496 DISPATCH_RPC_REQUEST => {
2497 let payload = match RpcRequestPayload::decode(frame.slice(RPC_FRAME_BODY_OFFSET..))
2498 {
2499 Ok(p) => p,
2500 Err(e) => {
2501 tracing::warn!(
2502 error = %e,
2503 caller_origin = format!("{:#x}", meta.origin_hash),
2504 call_id = meta.seq_or_ts,
2505 "rpc streaming server fold: malformed request payload",
2506 );
2507 // Surface as a terminal error chunk. Spawn
2508 // because the apply method is sync and the
2509 // emit is async; this is a one-shot publish
2510 // so ordering doesn't matter here.
2511 let resp = RpcResponsePayload {
2512 status: RpcStatus::UnknownVersion,
2513 headers: vec![(
2514 HEADER_NRPC_STREAMING.to_string(),
2515 HEADER_NRPC_STREAMING_END.to_vec(),
2516 )],
2517 body: Bytes::from(format!("malformed request: {e}")),
2518 };
2519 let emit = self.emit.clone();
2520 let caller_origin = meta.origin_hash;
2521 let call_id = meta.seq_or_ts;
2522 tokio::spawn(async move {
2523 emit(from_node, caller_origin, call_id, resp).await;
2524 });
2525 return Ok(());
2526 }
2527 };
2528 // Refuse a duplicate REQUEST with the same
2529 // `(origin_hash, call_id)`. Without this, a retry
2530 // that arrives while the first attempt's pump is
2531 // still draining will overwrite the prior
2532 // semaphore Arc in `flow_control`, leaving the
2533 // first pump awaiting an orphaned semaphore (the
2534 // terminal cleanup keys on `key` and removes the
2535 // *new* entry, so the orphan never gets dropped
2536 // and the first handler hangs forever).
2537 //
2538 // Idempotent for the caller: we emit a terminal
2539 // `Internal` chunk so the duplicate sender sees a
2540 // clean refusal rather than waiting on a stream
2541 // that will never produce output.
2542 {
2543 let in_flight = self.in_flight.lock();
2544 if in_flight.contains_key(&key) {
2545 drop(in_flight);
2546 tracing::warn!(
2547 caller_origin = format!("{:#x}", meta.origin_hash),
2548 call_id = meta.seq_or_ts,
2549 "rpc streaming server fold: duplicate REQUEST for in-flight call_id; refusing",
2550 );
2551 let resp = RpcResponsePayload {
2552 status: RpcStatus::Internal,
2553 headers: vec![(
2554 HEADER_NRPC_STREAMING.to_string(),
2555 HEADER_NRPC_STREAMING_END.to_vec(),
2556 )],
2557 body: Bytes::from_static(
2558 b"duplicate REQUEST for already-in-flight call_id",
2559 ),
2560 };
2561 let emit = self.emit.clone();
2562 let caller_origin = meta.origin_hash;
2563 let call_id = meta.seq_or_ts;
2564 tokio::spawn(async move {
2565 emit(from_node, caller_origin, call_id, resp).await;
2566 });
2567 return Ok(());
2568 }
2569 }
2570 // Cancellation token + in-flight bookkeeping —
2571 // identical to the unary fold's pattern.
2572 let cancellation = RpcCancellationToken::new();
2573 self.in_flight.lock().insert(key, cancellation.clone());
2574 // Flow-control opt-in: parse the
2575 // `nrpc-stream-window-initial` header. When
2576 // present, install a per-call semaphore the pump
2577 // task will await per chunk; subsequent
2578 // STREAM_GRANT events refill it. When absent, no
2579 // entry → pump skips the await (back-compat).
2580 let flow_sem = parse_stream_window_initial(&payload.headers).map(|n| {
2581 let sem = Arc::new(tokio::sync::Semaphore::new(n as usize));
2582 self.flow_control.lock().insert(key, sem.clone());
2583 sem
2584 });
2585 let handler = self.handler.clone();
2586 let emit = self.emit.clone();
2587 let in_flight = self.in_flight.clone();
2588 let flow_control = self.flow_control.clone();
2589 let caller_origin = meta.origin_hash;
2590 let call_id = meta.seq_or_ts;
2591 let trace_context = if payload.flags & FLAG_RPC_PROPAGATE_TRACE != 0 {
2592 extract_trace_context(&payload.headers)
2593 } else {
2594 None
2595 };
2596 let metrics = self.metrics.clone();
2597 // See unary fold for rationale — clone the
2598 // cancellation handle so the spawned task can probe
2599 // it after the handler returns and override the
2600 // terminal frame with `RpcStatus::Cancelled`.
2601 let cancel_probe = cancellation.clone();
2602 tokio::spawn(async move {
2603 if let Some(m) = metrics.as_ref() {
2604 m.handler_invocations_total
2605 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2606 m.handler_in_flight
2607 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2608 }
2609 let handler_started = std::time::Instant::now();
2610 let ctx = RpcContext {
2611 caller_origin,
2612 call_id,
2613 payload,
2614 cancellation,
2615 trace_context,
2616 // Streaming is never protected (E1.8) — always public.
2617 org_admission: None,
2618 };
2619 // Build the sink + receive end. Spawn a
2620 // pump that forwards each chunk to the emit
2621 // closure. The handler's `sink.send(...)`
2622 // calls show up here as items on the receiver.
2623 // **Bounded** at STREAMING_PUMP_CAPACITY: a
2624 // runaway handler that produces chunks faster
2625 // than the publish path can drain stops
2626 // blocking the runtime past this many queued
2627 // chunks; additional chunks are dropped and
2628 // counted via streaming_chunks_dropped_total.
2629 let (tx, mut rx) =
2630 tokio::sync::mpsc::channel::<bytes::Bytes>(STREAMING_PUMP_CAPACITY);
2631 let sink = RpcResponseSink {
2632 inner: tx,
2633 metrics: metrics.clone(),
2634 };
2635 let pump_emit = emit.clone();
2636 let pump_metrics = metrics.clone();
2637 let pump_flow = flow_sem.clone();
2638 let pump = tokio::spawn(async move {
2639 while let Some(chunk) = rx.recv().await {
2640 // Flow control: when the caller opted
2641 // in, await one semaphore permit per
2642 // chunk before publishing. The semaphore
2643 // starts at the caller's `initial_window`
2644 // and refills when the caller sends
2645 // STREAM_GRANT events. `forget()`
2646 // consumes the slot — each chunk uses
2647 // exactly one credit, never returned.
2648 // No-op when `pump_flow` is None
2649 // (back-compat path).
2650 if let Some(sem) = pump_flow.as_ref() {
2651 let permit = match sem.clone().acquire_owned().await {
2652 Ok(p) => p,
2653 Err(_) => {
2654 // Semaphore was closed —
2655 // shouldn't happen during
2656 // normal operation; bail.
2657 break;
2658 }
2659 };
2660 permit.forget();
2661 }
2662 if let Some(m) = pump_metrics.as_ref() {
2663 m.streaming_chunks_emitted_total
2664 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2665 }
2666 let resp = RpcResponsePayload {
2667 status: RpcStatus::Ok,
2668 headers: vec![(
2669 HEADER_NRPC_STREAMING.to_string(),
2670 HEADER_NRPC_STREAMING_CONTINUE.to_vec(),
2671 )],
2672 body: chunk.clone(),
2673 };
2674 // Await per-chunk publish so chunks for
2675 // one call_id reach the network in send
2676 // order. Without this, two chunks emitted
2677 // in tight succession can race into the
2678 // publish path and arrive out of order
2679 // (or be eclipsed by the terminal frame
2680 // and lost entirely on the caller side).
2681 pump_emit(from_node, caller_origin, call_id, resp).await;
2682 }
2683 });
2684 // Run the handler. Catch panics so a
2685 // misbehaving handler can't take down the
2686 // runtime — same shape as the unary fold.
2687 let outcome = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(
2688 handler.call(ctx, sink),
2689 ))
2690 .await;
2691 // The handler dropped the sink (either by
2692 // returning or by panicking through the
2693 // catch_unwind). Wait for the pump to drain
2694 // any final in-flight chunks.
2695 let _ = pump.await;
2696 if let Some(m) = metrics.as_ref() {
2697 m.handler_in_flight
2698 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
2699 m.record_handler_duration(handler_started.elapsed());
2700 if outcome.is_err() {
2701 m.handler_panics_total
2702 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2703 }
2704 }
2705 // Emit the terminal frame. CANCEL-wins ordering
2706 // matches the unary fold: if the cancellation
2707 // token fired during execution, override the
2708 // handler's terminal with `RpcStatus::Cancelled`.
2709 let terminal = if cancel_probe.is_cancelled() {
2710 RpcResponsePayload {
2711 status: RpcStatus::Cancelled,
2712 headers: vec![],
2713 body: Bytes::from_static(
2714 b"server observed CANCEL during streaming handler execution",
2715 ),
2716 }
2717 } else {
2718 match outcome {
2719 Ok(Ok(())) => RpcResponsePayload {
2720 status: RpcStatus::Ok,
2721 headers: vec![(
2722 HEADER_NRPC_STREAMING.to_string(),
2723 HEADER_NRPC_STREAMING_END.to_vec(),
2724 )],
2725 body: Bytes::new(),
2726 },
2727 Ok(Err(RpcHandlerError::Application { code, message })) => {
2728 RpcResponsePayload {
2729 status: RpcStatus::Application(code),
2730 headers: vec![],
2731 body: Bytes::from(message),
2732 }
2733 }
2734 Ok(Err(RpcHandlerError::Internal(message))) => RpcResponsePayload {
2735 status: RpcStatus::Internal,
2736 headers: vec![],
2737 body: Bytes::from(message),
2738 },
2739 Err(panic) => {
2740 let panic_msg = panic
2741 .downcast_ref::<&'static str>()
2742 .map(|s| s.to_string())
2743 .or_else(|| panic.downcast_ref::<String>().cloned())
2744 .unwrap_or_else(|| "<non-string panic>".into());
2745 tracing::error!(
2746 caller_origin = format!("{:#x}", caller_origin),
2747 call_id,
2748 panic = %panic_msg,
2749 "rpc streaming server handler panicked",
2750 );
2751 RpcResponsePayload {
2752 status: RpcStatus::Internal,
2753 headers: vec![],
2754 body: Bytes::from(format!("handler panicked: {panic_msg}")),
2755 }
2756 }
2757 }
2758 };
2759 in_flight.lock().remove(&key);
2760 // Drop the per-call flow-control semaphore
2761 // (if any) so a stale GRANT arriving after
2762 // termination is silently dropped — the entry
2763 // is gone, lookup misses.
2764 flow_control.lock().remove(&key);
2765 // Await the terminal frame's publish too so it
2766 // arrives strictly AFTER the last chunk on the
2767 // wire (the pump has already drained, but the
2768 // emit itself is still async and we must await
2769 // it before the spawned task ends).
2770 emit(from_node, caller_origin, call_id, terminal).await;
2771 });
2772 }
2773 DISPATCH_RPC_CANCEL => {
2774 if let Some(token) = self.in_flight.lock().remove(&key) {
2775 token.cancel();
2776 }
2777 // Also drop the flow-control entry — the spawned
2778 // task's terminal cleanup will run too, but doing
2779 // it here makes the CANCEL path immediately stop
2780 // refilling the pump (the pending `acquire().await`
2781 // will resolve once the semaphore is dropped or
2782 // when the task exits).
2783 self.flow_control.lock().remove(&key);
2784 }
2785 DISPATCH_RPC_STREAM_GRANT => {
2786 // Add credit to the per-call semaphore. Silently
2787 // drop GRANT events for unknown / non-flow-
2788 // controlled calls — server can't tell whether
2789 // the caller is racing a terminal vs. sending a
2790 // grant for a non-flow-controlled stream, and
2791 // both are harmless to ignore.
2792 let amount = match decode_stream_grant(&frame[RPC_FRAME_BODY_OFFSET..]) {
2793 Some(n) => n,
2794 None => {
2795 tracing::debug!(
2796 caller_origin = format!("{:#x}", meta.origin_hash),
2797 call_id = meta.seq_or_ts,
2798 "rpc streaming server fold: malformed STREAM_GRANT payload",
2799 );
2800 return Ok(());
2801 }
2802 };
2803 if amount == 0 {
2804 return Ok(());
2805 }
2806 if let Some(sem) = self.flow_control.lock().get(&key).cloned() {
2807 // Tokio's `Semaphore::add_permits` is bounded
2808 // by `MAX_PERMITS = usize::MAX >> 3`. A
2809 // misbehaving caller flooding huge grants
2810 // would eventually saturate; cap defensively.
2811 let safe = (amount as usize).min(usize::MAX >> 4);
2812 sem.add_permits(safe);
2813 }
2814 }
2815 _ => {}
2816 }
2817 Ok(())
2818 }
2819}
2820
2821impl RedexFold<()> for RpcServerStreamingFold {
2822 /// Loopback / test shim: drives `apply_frame` with
2823 /// `from_node = 0` (AV-1 item 1). Production uses
2824 /// [`RpcServerStreamingFold::apply_inbound`].
2825 fn apply(&mut self, ev: &RedexEvent, _state: &mut ()) -> Result<(), RedexError> {
2826 self.apply_frame(0, &ev.payload)
2827 }
2828}
2829
2830// ============================================================================
2831// Phase B — server-side fold for client-streaming.
2832//
2833// `RpcStreamingRequestFold` mirrors `RpcServerStreamingFold` but
2834// flipped on the data-direction axis: the SERVER consumes a
2835// stream of REQUEST_CHUNK events and the handler produces ONE
2836// terminal RESPONSE (vs. the response-side fold where one REQUEST
2837// drives many RESPONSE chunks).
2838//
2839// Wire shape it handles:
2840// DISPATCH_RPC_REQUEST (FLAG_RPC_CLIENT_STREAMING_REQUEST)
2841// DISPATCH_RPC_REQUEST_CHUNK (zero or more)
2842// DISPATCH_RPC_REQUEST_CHUNK (FLAG_RPC_REQUEST_END)
2843// DISPATCH_RPC_CANCEL (any time; flips token + closes stream)
2844//
2845// Wire shape it EMITS (via callbacks):
2846// DISPATCH_RPC_RESPONSE (one terminal frame; via RpcResponseEmitter)
2847// DISPATCH_RPC_REQUEST_GRANT (one per consumed chunk when flow
2848// control is opted in; via
2849// RpcRequestGrantEmitter)
2850//
2851// Each service binds to exactly one fold shape (unary, server-
2852// streaming, or client-streaming) at `serve_rpc*` registration.
2853// A REQUEST without FLAG_RPC_CLIENT_STREAMING_REQUEST that lands
2854// on the client-streaming fold is a caller bug — the fold emits a
2855// terminal `Internal` and drops the call.
2856// ============================================================================
2857
2858/// Per-call request-direction sender map type. Keyed on
2859/// `(from_node, caller_origin_hash, call_id)` (AV-1 item 1): the
2860/// AEAD-authenticated last-hop session peer is part of the key so a
2861/// peer cannot push a REQUEST_CHUNK into another peer's upload stream
2862/// by copying its origin + call_id. Value is the bounded mpsc sender
2863/// the fold's `apply_frame()` pushes REQUEST_CHUNK bodies into. The
2864/// matching receiver lives inside the handler's [`RequestStream`];
2865/// dropping the sender (on REQUEST_END or CANCEL) closes the stream.
2866type RequestChunkSenders =
2867 Arc<Mutex<HashMap<(u64, u64, u64), tokio::sync::mpsc::Sender<bytes::Bytes>>>>;
2868
2869/// Shared REQUEST_CHUNK handling used by both
2870/// [`RpcStreamingRequestFold`] and [`RpcDuplexFold`]. Decodes the
2871/// payload, validates the call_id agreement, looks up the per-call
2872/// sender, pushes the body (skipping the empty-body FLAG_END
2873/// terminator), and removes the sender on FLAG_END so the
2874/// handler's stream observes EOF.
2875///
2876/// `diag_tag` selects the log prefix ("client-streaming" or
2877/// "duplex") so the two call sites surface identically-shaped
2878/// diagnostics with the correct fold name. The behavior is
2879/// otherwise identical — both folds carry the same wire format
2880/// and the same per-call mpsc + sender-map contract.
2881fn apply_request_chunk_to_senders(
2882 from_node: u64,
2883 payload_bytes: Bytes,
2884 meta: &EventMeta,
2885 senders: &RequestChunkSenders,
2886 diag_tag: &'static str,
2887) {
2888 let payload = match RpcRequestChunkPayload::decode(payload_bytes) {
2889 Ok(p) => p,
2890 Err(e) => {
2891 tracing::warn!(
2892 error = %e,
2893 caller_origin = format!("{:#x}", meta.origin_hash),
2894 call_id = meta.seq_or_ts,
2895 tag = diag_tag,
2896 "rpc server fold: malformed REQUEST_CHUNK payload",
2897 );
2898 return;
2899 }
2900 };
2901 if payload.call_id != meta.seq_or_ts {
2902 tracing::warn!(
2903 caller_origin = format!("{:#x}", meta.origin_hash),
2904 meta_call_id = meta.seq_or_ts,
2905 payload_call_id = payload.call_id,
2906 tag = diag_tag,
2907 "rpc server fold: REQUEST_CHUNK payload call_id does not match EventMeta",
2908 );
2909 return;
2910 }
2911 // Scope the sender lookup to the authenticated session peer so a
2912 // forged REQUEST_CHUNK carrying another peer's origin + call_id
2913 // misses the map (AV-1 item 1).
2914 let key = (from_node, meta.origin_hash, meta.seq_or_ts);
2915 let is_end = payload.flags & FLAG_RPC_REQUEST_END != 0;
2916 let sender = senders.lock().get(&key).cloned();
2917 let Some(sender) = sender else {
2918 // Unknown call — either the initial REQUEST hasn't
2919 // arrived yet (out-of-order delivery is possible on the
2920 // bus) or the handler already completed and the entry is
2921 // gone. Drop silently.
2922 tracing::debug!(
2923 caller_origin = format!("{:#x}", meta.origin_hash),
2924 call_id = meta.seq_or_ts,
2925 tag = diag_tag,
2926 "rpc server fold: REQUEST_CHUNK for unknown call_id; dropping",
2927 );
2928 return;
2929 };
2930 let is_pure_terminator = is_end && payload.body.is_empty();
2931 if !is_pure_terminator && sender.try_send(payload.body).is_err() {
2932 tracing::debug!(
2933 caller_origin = format!("{:#x}", meta.origin_hash),
2934 call_id = meta.seq_or_ts,
2935 tag = diag_tag,
2936 "rpc server fold: request-chunk mpsc full or closed; dropping",
2937 );
2938 }
2939 if is_end {
2940 // Drop the sender from the map → its clone here goes out
2941 // of scope at end of function → the receiver in the
2942 // handler's RequestStream sees EOF on the next poll.
2943 senders.lock().remove(&key);
2944 }
2945}
2946
2947/// Server-side fold for client-streaming RPC. Parallel to
2948/// [`RpcServerStreamingFold`] but consumes REQUEST_CHUNK on the
2949/// input side and produces one terminal RESPONSE on the output
2950/// side (vs. one REQUEST in / many RESPONSE chunks out).
2951///
2952/// State `()` — like the other folds, application state lives in
2953/// the handler's captured `Arc<Mutex<S>>`. The fold's own state
2954/// (in-flight cancellation tokens + per-call request-chunk
2955/// senders) lives on `&mut self` via `Arc<Mutex<...>>` so spawned
2956/// handler tasks can self-clean on completion.
2957///
2958/// Bidi streaming plan (Phase B).
2959pub struct RpcStreamingRequestFold {
2960 handler: Arc<dyn RpcClientStreamingHandler>,
2961 emit: RpcResponseEmitter,
2962 /// Optional request-direction grant emitter. `Some(...)`
2963 /// when the surrounding mesh glue is wired to publish
2964 /// REQUEST_GRANT events; `None` in unit tests / contexts
2965 /// without a real publish path. When `None`, the auto-grant
2966 /// path on every `RequestStream::poll_next` becomes a no-op
2967 /// (callers that opted into flow control will see no
2968 /// refill and stall once their initial window is exhausted —
2969 /// honest behavior for a fold not wired up for grants).
2970 grant_emit: Option<RpcRequestGrantEmitter>,
2971 /// (from_node, caller_origin, call_id) → cancellation token —
2972 /// authenticated-peer-scoped (AV-1 item 1).
2973 in_flight: InFlightCalls,
2974 senders: RequestChunkSenders,
2975 /// Optional per-service metrics handle. Same shape as the
2976 /// other folds. Reuses the response-side counters where they
2977 /// apply (handler_invocations / handler_panics / etc.) and
2978 /// would gain request-side counters (e.g.
2979 /// `streaming_request_chunks_dropped_total`) in a follow-up.
2980 metrics: Option<Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>>,
2981}
2982
2983impl RpcStreamingRequestFold {
2984 /// Construct a client-streaming server fold. `emit` publishes
2985 /// the terminal RESPONSE on the caller's reply channel.
2986 ///
2987 /// Use the sync [`RpcResponseEmitter`] here — there's only
2988 /// one RESPONSE per call (the terminal frame), so the
2989 /// per-call serialization the async emitter buys for the
2990 /// response-side fold is not needed here.
2991 pub fn new(handler: Arc<dyn RpcClientStreamingHandler>, emit: RpcResponseEmitter) -> Self {
2992 Self {
2993 handler,
2994 emit,
2995 grant_emit: None,
2996 in_flight: Arc::new(Mutex::new(HashMap::new())),
2997 senders: Arc::new(Mutex::new(HashMap::new())),
2998 metrics: None,
2999 }
3000 }
3001
3002 /// Attach the request-direction grant emitter. Hands every
3003 /// `RequestStream::poll_next` a hook to fire one REQUEST_GRANT
3004 /// back to the caller after a chunk is consumed. Optional —
3005 /// folds constructed without it still work, callers that
3006 /// opted into flow control just won't be refilled.
3007 pub fn with_grant_emitter(mut self, grant_emit: RpcRequestGrantEmitter) -> Self {
3008 self.grant_emit = Some(grant_emit);
3009 self
3010 }
3011
3012 /// Attach a per-service metrics handle. Hooks the spawned
3013 /// handler task to bump `handler_invocations_total` /
3014 /// `handler_in_flight` / `handler_panics_total` /
3015 /// `handler_duration_*`. Symmetric with `RpcServerFold` and
3016 /// `RpcServerStreamingFold`.
3017 pub fn with_metrics(
3018 mut self,
3019 metrics: Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>,
3020 ) -> Self {
3021 self.metrics = Some(metrics);
3022 self
3023 }
3024
3025 /// Test-only: snapshot of the in-flight call set.
3026 #[cfg(test)]
3027 pub fn in_flight_keys(&self) -> Vec<(u64, u64, u64)> {
3028 self.in_flight.lock().keys().copied().collect()
3029 }
3030
3031 /// Test-only: snapshot of the in-flight per-call senders.
3032 /// Useful for tests that need to assert a call's sender has
3033 /// been dropped after REQUEST_END / CANCEL.
3034 #[cfg(test)]
3035 pub fn sender_keys(&self) -> Vec<(u64, u64, u64)> {
3036 self.senders.lock().keys().copied().collect()
3037 }
3038}
3039
3040impl RpcStreamingRequestFold {
3041 /// Production-path entry point. Keys per-call state (in-flight
3042 /// token + request-chunk sender) by `(from_node, claimed_origin,
3043 /// call_id)` so a forged REQUEST_CHUNK / CANCEL from another peer
3044 /// misses the map and no-ops (AV-1 item 1).
3045 pub fn apply_inbound(&mut self, ev: &RpcInboundEvent) -> Result<(), RedexError> {
3046 self.apply_frame(ev.from_node, &ev.payload)
3047 }
3048
3049 /// Core frame application shared by [`Self::apply_inbound`] (real
3050 /// `from_node`) and the [`RedexFold`] loopback shim (`0`).
3051 fn apply_frame(&mut self, from_node: u64, frame: &Bytes) -> Result<(), RedexError> {
3052 let Some(meta) = (if frame.len() >= EVENT_META_SIZE {
3053 EventMeta::from_bytes(&frame[..EVENT_META_SIZE])
3054 } else {
3055 None
3056 }) else {
3057 tracing::warn!(
3058 payload_len = frame.len(),
3059 "rpc client-streaming server fold: event payload too short for EventMeta",
3060 );
3061 return Ok(());
3062 };
3063 let key = (from_node, meta.origin_hash, meta.seq_or_ts);
3064 match meta.dispatch {
3065 DISPATCH_RPC_REQUEST => {
3066 let payload = match RpcRequestPayload::decode(frame.slice(RPC_FRAME_BODY_OFFSET..))
3067 {
3068 Ok(p) => p,
3069 Err(e) => {
3070 tracing::warn!(
3071 error = %e,
3072 caller_origin = format!("{:#x}", meta.origin_hash),
3073 call_id = meta.seq_or_ts,
3074 "rpc client-streaming server fold: malformed request payload",
3075 );
3076 let resp = RpcResponsePayload {
3077 status: RpcStatus::UnknownVersion,
3078 headers: vec![],
3079 body: Bytes::from(format!("malformed request: {e}")),
3080 };
3081 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
3082 return Ok(());
3083 }
3084 };
3085 // A REQUEST without the client-streaming flag on
3086 // this fold is a caller bug — the service was
3087 // registered as client-streaming. Refuse cleanly.
3088 if payload.flags & FLAG_RPC_CLIENT_STREAMING_REQUEST == 0 {
3089 tracing::warn!(
3090 caller_origin = format!("{:#x}", meta.origin_hash),
3091 call_id = meta.seq_or_ts,
3092 flags = format!("{:#06x}", payload.flags),
3093 "rpc client-streaming server fold: REQUEST missing FLAG_RPC_CLIENT_STREAMING_REQUEST",
3094 );
3095 let resp = RpcResponsePayload {
3096 status: RpcStatus::Internal,
3097 headers: vec![],
3098 body: Bytes::from_static(
3099 b"REQUEST on a client-streaming service must set FLAG_RPC_CLIENT_STREAMING_REQUEST",
3100 ),
3101 };
3102 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
3103 return Ok(());
3104 }
3105 // Refuse a duplicate REQUEST with the same
3106 // `(origin_hash, call_id)` — same rationale as
3107 // the response-side fold: a retry that arrives
3108 // while the first attempt is still in-flight
3109 // would overwrite the prior sender and orphan the
3110 // existing handler.
3111 {
3112 let in_flight = self.in_flight.lock();
3113 if in_flight.contains_key(&key) {
3114 drop(in_flight);
3115 tracing::warn!(
3116 caller_origin = format!("{:#x}", meta.origin_hash),
3117 call_id = meta.seq_or_ts,
3118 "rpc client-streaming server fold: duplicate REQUEST for in-flight call_id; refusing",
3119 );
3120 let resp = RpcResponsePayload {
3121 status: RpcStatus::Internal,
3122 headers: vec![],
3123 body: Bytes::from_static(
3124 b"duplicate REQUEST for already-in-flight call_id",
3125 ),
3126 };
3127 (self.emit)(from_node, meta.origin_hash, meta.seq_or_ts, resp);
3128 return Ok(());
3129 }
3130 }
3131 let cancellation = RpcCancellationToken::new();
3132 self.in_flight.lock().insert(key, cancellation.clone());
3133 // Build the per-call request-chunk mpsc. Bounded
3134 // capacity — overflow on the sender side drops the
3135 // chunk (caller can re-send or, if flow-control is
3136 // wired, will naturally not push past the credit
3137 // window).
3138 let (tx, rx) =
3139 tokio::sync::mpsc::channel::<bytes::Bytes>(STREAMING_REQUEST_PUMP_CAPACITY);
3140 // Terminator-semantics rule: an empty body
3141 // combined with FLAG_REQUEST_END is a pure
3142 // terminator — the caller's `finish()` emits it
3143 // to close the stream without yielding a phantom
3144 // empty item to the handler. A non-empty body on
3145 // a FLAG_END frame IS a final item (used by the
3146 // "single-item degenerate path": initial REQUEST
3147 // with FLAG_END + a real body sends one item +
3148 // closes in a single frame).
3149 let end_on_initial = payload.flags & FLAG_RPC_REQUEST_END != 0;
3150 let is_pure_terminator = end_on_initial && payload.body.is_empty();
3151 if !is_pure_terminator {
3152 // Fresh `mpsc::channel(STREAMING_REQUEST_PUMP_CAPACITY)`
3153 // with a live receiver — try_send cannot fail.
3154 // debug_assert surfaces the invariant break in
3155 // tests; release logs at error level rather than
3156 // silently swallowing the first request body.
3157 if tx.try_send(payload.body).is_err() {
3158 debug_assert!(
3159 false,
3160 "fresh client-streaming request mpsc rejected initial body"
3161 );
3162 tracing::error!(
3163 caller_origin = format!("{:#x}", meta.origin_hash),
3164 call_id = meta.seq_or_ts,
3165 "rpc client-streaming server fold: fresh mpsc rejected initial REQUEST body (invariant break)",
3166 );
3167 }
3168 }
3169 // If the initial REQUEST also set FLAG_REQUEST_END,
3170 // close the stream immediately — degenerate case of
3171 // "one-item upload" where the caller didn't bother
3172 // with a trailing REQUEST_CHUNK. Don't even insert
3173 // the sender into the map; just drop it here.
3174 if !end_on_initial {
3175 self.senders.lock().insert(key, tx);
3176 }
3177 // Build the handler's context + stream. Auto-grant
3178 // is opted into when the caller set the request
3179 // window header AND the fold was wired with a
3180 // grant emitter; both must be present for grants
3181 // to actually fly.
3182 let grant_emitter = if parse_request_window_initial(&payload.headers).is_some() {
3183 self.grant_emit.clone()
3184 } else {
3185 None
3186 };
3187 let request_stream = RequestStream::new(
3188 rx,
3189 grant_emitter,
3190 from_node,
3191 meta.origin_hash,
3192 meta.seq_or_ts,
3193 );
3194 let trace_context = if payload.flags & FLAG_RPC_PROPAGATE_TRACE != 0 {
3195 extract_trace_context(&payload.headers)
3196 } else {
3197 None
3198 };
3199 let deadline_ns = payload.deadline_ns;
3200 let ctx = RpcStreamingContext {
3201 caller_origin: meta.origin_hash,
3202 call_id: meta.seq_or_ts,
3203 deadline_ns,
3204 headers: payload.headers,
3205 cancellation: cancellation.clone(),
3206 trace_context,
3207 };
3208 let handler = self.handler.clone();
3209 let emit = self.emit.clone();
3210 let in_flight = self.in_flight.clone();
3211 let senders = self.senders.clone();
3212 let caller_origin = meta.origin_hash;
3213 let call_id = meta.seq_or_ts;
3214 let cancel_probe = cancellation.clone();
3215 let cancel_for_deadline = cancellation.clone();
3216 let metrics = self.metrics.clone();
3217 tokio::spawn(async move {
3218 if let Some(m) = metrics.as_ref() {
3219 m.handler_invocations_total
3220 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3221 m.handler_in_flight
3222 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3223 }
3224 let handler_started = std::time::Instant::now();
3225 // Deadline guard: if the caller declared
3226 // `deadline_ns`, force-drop the handler future
3227 // after it elapses so an orphaned request stream
3228 // (caller-side network partition before
3229 // REQUEST_END arrives) can never hang the call
3230 // indefinitely. `deadline_ns = 0` means "no
3231 // deadline" — caller's responsibility.
3232 let call_fut = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(
3233 handler.call(ctx, request_stream),
3234 ));
3235 let outcome = if deadline_ns > 0 {
3236 let now_ns = std::time::SystemTime::now()
3237 .duration_since(std::time::UNIX_EPOCH)
3238 .map(|d| d.as_nanos() as u64)
3239 .unwrap_or(0);
3240 let remaining = deadline_ns.saturating_sub(now_ns);
3241 if remaining == 0 {
3242 cancel_for_deadline.cancel();
3243 Ok(Err(RpcHandlerError::Internal(
3244 "handler deadline_ns already expired at spawn".to_string(),
3245 )))
3246 } else {
3247 match tokio::time::timeout(
3248 std::time::Duration::from_nanos(remaining),
3249 call_fut,
3250 )
3251 .await
3252 {
3253 Ok(o) => o,
3254 Err(_) => {
3255 cancel_for_deadline.cancel();
3256 Ok(Err(RpcHandlerError::Internal(
3257 "handler deadline_ns exceeded".to_string(),
3258 )))
3259 }
3260 }
3261 }
3262 } else {
3263 call_fut.await
3264 };
3265 if let Some(m) = metrics.as_ref() {
3266 m.handler_in_flight
3267 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
3268 m.record_handler_duration(handler_started.elapsed());
3269 if outcome.is_err() {
3270 m.handler_panics_total
3271 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3272 }
3273 }
3274 // CANCEL-wins ordering: if the cancellation
3275 // token fired during execution, override the
3276 // handler's terminal with Cancelled.
3277 let terminal = if cancel_probe.is_cancelled() {
3278 RpcResponsePayload {
3279 status: RpcStatus::Cancelled,
3280 headers: vec![],
3281 body: Bytes::from_static(
3282 b"server observed CANCEL during client-streaming handler execution",
3283 ),
3284 }
3285 } else {
3286 match outcome {
3287 Ok(Ok(resp)) => resp,
3288 Ok(Err(RpcHandlerError::Application { code, message })) => {
3289 RpcResponsePayload {
3290 status: RpcStatus::Application(code),
3291 headers: vec![],
3292 body: Bytes::from(message),
3293 }
3294 }
3295 Ok(Err(RpcHandlerError::Internal(message))) => RpcResponsePayload {
3296 status: RpcStatus::Internal,
3297 headers: vec![],
3298 body: Bytes::from(message),
3299 },
3300 Err(panic) => {
3301 let panic_msg = panic
3302 .downcast_ref::<&'static str>()
3303 .map(|s| s.to_string())
3304 .or_else(|| panic.downcast_ref::<String>().cloned())
3305 .unwrap_or_else(|| "<non-string panic>".into());
3306 tracing::error!(
3307 caller_origin = format!("{:#x}", caller_origin),
3308 call_id,
3309 panic = %panic_msg,
3310 "rpc client-streaming server handler panicked",
3311 );
3312 RpcResponsePayload {
3313 status: RpcStatus::Internal,
3314 headers: vec![],
3315 body: Bytes::from(format!("handler panicked: {panic_msg}")),
3316 }
3317 }
3318 }
3319 };
3320 in_flight.lock().remove(&key);
3321 // Drop the per-call request-chunk sender too
3322 // (idempotent — already gone if REQUEST_END
3323 // arrived; defensive otherwise so a handler
3324 // that returned without consuming all chunks
3325 // doesn't leak the entry).
3326 senders.lock().remove(&key);
3327 (emit)(from_node, caller_origin, call_id, terminal);
3328 });
3329 }
3330 DISPATCH_RPC_REQUEST_CHUNK => {
3331 apply_request_chunk_to_senders(
3332 from_node,
3333 frame.slice(RPC_FRAME_BODY_OFFSET..),
3334 &meta,
3335 &self.senders,
3336 "client-streaming",
3337 );
3338 }
3339 DISPATCH_RPC_CANCEL => {
3340 if let Some(token) = self.in_flight.lock().remove(&key) {
3341 token.cancel();
3342 }
3343 // Drop the per-call sender so the handler's
3344 // RequestStream yields None on the next poll
3345 // (handler observes cancel via the token OR via
3346 // the stream's EOF; the cancel_probe in the
3347 // spawned task ensures the terminal RESPONSE is
3348 // Cancelled regardless of which the handler
3349 // checks first).
3350 self.senders.lock().remove(&key);
3351 }
3352 _ => {}
3353 }
3354 Ok(())
3355 }
3356}
3357
3358impl RedexFold<()> for RpcStreamingRequestFold {
3359 /// Loopback / test shim: drives `apply_frame` with
3360 /// `from_node = 0` (AV-1 item 1). Production uses
3361 /// [`RpcStreamingRequestFold::apply_inbound`].
3362 fn apply(&mut self, ev: &RedexEvent, _state: &mut ()) -> Result<(), RedexError> {
3363 self.apply_frame(0, &ev.payload)
3364 }
3365}
3366
3367// ============================================================================
3368// Phase D — server-side fold for full duplex.
3369//
3370// `RpcDuplexFold` is the hybrid of `RpcStreamingRequestFold`
3371// (Phase B — request side) and `RpcServerStreamingFold` (existing
3372// — response side). The handler trait takes BOTH a `RequestStream`
3373// AND an `RpcResponseSink`; the fold spawns one handler task per
3374// REQUEST and one pump task per call_id, then emits a terminal
3375// RESPONSE on handler return.
3376//
3377// Wire shape it consumes:
3378// DISPATCH_RPC_REQUEST (FLAG_CLIENT_STREAMING_REQUEST + FLAG_STREAMING_RESPONSE)
3379// DISPATCH_RPC_REQUEST_CHUNK (zero or more, with FLAG_REQUEST_END on the last)
3380// DISPATCH_RPC_CANCEL (flips token + closes both directions)
3381//
3382// Wire shape it produces:
3383// DISPATCH_RPC_RESPONSE (multi-fire; nrpc-streaming: continue / end)
3384// DISPATCH_RPC_REQUEST_GRANT (one per consumed request-chunk when flow
3385// control is opted in)
3386//
3387// Bidi streaming plan (Phase D).
3388// ============================================================================
3389
3390/// Server-side fold for duplex RPC. Composes Phase B's request
3391/// stream + per-call request-chunk senders with the existing
3392/// response-side pump + multi-fire RESPONSE emit.
3393///
3394/// State `()` — same as the sibling folds.
3395///
3396/// Bidi streaming plan (Phase D).
3397pub struct RpcDuplexFold {
3398 handler: Arc<dyn RpcDuplexHandler>,
3399 /// Async emitter for response chunks (per-call ordering via
3400 /// awaited emits — same rationale as `RpcServerStreamingFold`).
3401 emit: RpcAsyncResponseEmitter,
3402 /// Optional request-direction grant emitter.
3403 grant_emit: Option<RpcRequestGrantEmitter>,
3404 /// (from_node, caller_origin, call_id) → cancellation token —
3405 /// authenticated-peer-scoped (AV-1 item 1).
3406 in_flight: InFlightCalls,
3407 senders: RequestChunkSenders,
3408 metrics: Option<Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>>,
3409}
3410
3411impl RpcDuplexFold {
3412 /// Construct a duplex server fold. `emit` publishes individual
3413 /// response chunks AND the terminal frame on the caller's
3414 /// reply channel (uses the async emitter for per-call
3415 /// ordering, same as `RpcServerStreamingFold`).
3416 pub fn new(handler: Arc<dyn RpcDuplexHandler>, emit: RpcAsyncResponseEmitter) -> Self {
3417 Self {
3418 handler,
3419 emit,
3420 grant_emit: None,
3421 in_flight: Arc::new(Mutex::new(HashMap::new())),
3422 senders: Arc::new(Mutex::new(HashMap::new())),
3423 metrics: None,
3424 }
3425 }
3426
3427 /// Attach the request-direction grant emitter. See
3428 /// [`RpcStreamingRequestFold::with_grant_emitter`] for the
3429 /// auto-grant behavior. When unset, callers that opted into
3430 /// flow control simply won't get refilled.
3431 pub fn with_grant_emitter(mut self, grant_emit: RpcRequestGrantEmitter) -> Self {
3432 self.grant_emit = Some(grant_emit);
3433 self
3434 }
3435
3436 /// Attach a per-service metrics handle. Bumps
3437 /// handler_invocations / handler_in_flight / handler_panics /
3438 /// handler_duration_* + the response pump's
3439 /// streaming_chunks_emitted_total per emitted chunk.
3440 pub fn with_metrics(
3441 mut self,
3442 metrics: Arc<crate::adapter::net::mesh_rpc_metrics::ServiceMetricsAtomic>,
3443 ) -> Self {
3444 self.metrics = Some(metrics);
3445 self
3446 }
3447
3448 /// Test-only: snapshot of the in-flight call set.
3449 #[cfg(test)]
3450 pub fn in_flight_keys(&self) -> Vec<(u64, u64, u64)> {
3451 self.in_flight.lock().keys().copied().collect()
3452 }
3453
3454 /// Test-only: snapshot of the in-flight per-call senders.
3455 #[cfg(test)]
3456 pub fn sender_keys(&self) -> Vec<(u64, u64, u64)> {
3457 self.senders.lock().keys().copied().collect()
3458 }
3459}
3460
3461impl RpcDuplexFold {
3462 /// Production-path entry point. Keys per-call state (in-flight
3463 /// token + request-chunk sender) by `(from_node, claimed_origin,
3464 /// call_id)` so a forged REQUEST_CHUNK / CANCEL from another peer
3465 /// misses the map and no-ops (AV-1 item 1).
3466 pub fn apply_inbound(&mut self, ev: &RpcInboundEvent) -> Result<(), RedexError> {
3467 self.apply_frame(ev.from_node, &ev.payload)
3468 }
3469
3470 /// Core frame application shared by [`Self::apply_inbound`] (real
3471 /// `from_node`) and the [`RedexFold`] loopback shim (`0`).
3472 fn apply_frame(&mut self, from_node: u64, frame: &Bytes) -> Result<(), RedexError> {
3473 let Some(meta) = (if frame.len() >= EVENT_META_SIZE {
3474 EventMeta::from_bytes(&frame[..EVENT_META_SIZE])
3475 } else {
3476 None
3477 }) else {
3478 tracing::warn!(
3479 payload_len = frame.len(),
3480 "rpc duplex server fold: event payload too short for EventMeta",
3481 );
3482 return Ok(());
3483 };
3484 let key = (from_node, meta.origin_hash, meta.seq_or_ts);
3485 match meta.dispatch {
3486 DISPATCH_RPC_REQUEST => {
3487 let payload = match RpcRequestPayload::decode(frame.slice(RPC_FRAME_BODY_OFFSET..))
3488 {
3489 Ok(p) => p,
3490 Err(e) => {
3491 tracing::warn!(
3492 error = %e,
3493 caller_origin = format!("{:#x}", meta.origin_hash),
3494 call_id = meta.seq_or_ts,
3495 "rpc duplex server fold: malformed request payload",
3496 );
3497 let resp = RpcResponsePayload {
3498 status: RpcStatus::UnknownVersion,
3499 headers: vec![(
3500 HEADER_NRPC_STREAMING.to_string(),
3501 HEADER_NRPC_STREAMING_END.to_vec(),
3502 )],
3503 body: Bytes::from(format!("malformed request: {e}")),
3504 };
3505 let emit = self.emit.clone();
3506 let caller_origin = meta.origin_hash;
3507 let call_id = meta.seq_or_ts;
3508 tokio::spawn(async move {
3509 emit(from_node, caller_origin, call_id, resp).await;
3510 });
3511 return Ok(());
3512 }
3513 };
3514 // Caller-bug guard: a duplex REQUEST must set
3515 // BOTH the client-streaming flag (we'll receive
3516 // request chunks) AND the streaming-response flag
3517 // (we'll emit response chunks). Missing flags →
3518 // refuse cleanly.
3519 let required = FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_STREAMING_RESPONSE;
3520 if payload.flags & required != required {
3521 tracing::warn!(
3522 caller_origin = format!("{:#x}", meta.origin_hash),
3523 call_id = meta.seq_or_ts,
3524 flags = format!("{:#06x}", payload.flags),
3525 "rpc duplex server fold: REQUEST missing required flags",
3526 );
3527 let resp = RpcResponsePayload {
3528 status: RpcStatus::Internal,
3529 headers: vec![(
3530 HEADER_NRPC_STREAMING.to_string(),
3531 HEADER_NRPC_STREAMING_END.to_vec(),
3532 )],
3533 body: Bytes::from_static(
3534 b"REQUEST on a duplex service must set FLAG_RPC_CLIENT_STREAMING_REQUEST and FLAG_RPC_STREAMING_RESPONSE",
3535 ),
3536 };
3537 let emit = self.emit.clone();
3538 let caller_origin = meta.origin_hash;
3539 let call_id = meta.seq_or_ts;
3540 tokio::spawn(async move {
3541 emit(from_node, caller_origin, call_id, resp).await;
3542 });
3543 return Ok(());
3544 }
3545 // Duplicate-REQUEST refusal.
3546 {
3547 let in_flight = self.in_flight.lock();
3548 if in_flight.contains_key(&key) {
3549 drop(in_flight);
3550 tracing::warn!(
3551 caller_origin = format!("{:#x}", meta.origin_hash),
3552 call_id = meta.seq_or_ts,
3553 "rpc duplex server fold: duplicate REQUEST for in-flight call_id; refusing",
3554 );
3555 let resp = RpcResponsePayload {
3556 status: RpcStatus::Internal,
3557 headers: vec![(
3558 HEADER_NRPC_STREAMING.to_string(),
3559 HEADER_NRPC_STREAMING_END.to_vec(),
3560 )],
3561 body: Bytes::from_static(
3562 b"duplicate REQUEST for already-in-flight call_id",
3563 ),
3564 };
3565 let emit = self.emit.clone();
3566 let caller_origin = meta.origin_hash;
3567 let call_id = meta.seq_or_ts;
3568 tokio::spawn(async move {
3569 emit(from_node, caller_origin, call_id, resp).await;
3570 });
3571 return Ok(());
3572 }
3573 }
3574 let cancellation = RpcCancellationToken::new();
3575 self.in_flight.lock().insert(key, cancellation.clone());
3576
3577 // Build per-call request-side mpsc (Phase B
3578 // pattern).
3579 let (req_tx, req_rx) =
3580 tokio::sync::mpsc::channel::<bytes::Bytes>(STREAMING_REQUEST_PUMP_CAPACITY);
3581 let end_on_initial = payload.flags & FLAG_RPC_REQUEST_END != 0;
3582 let is_pure_terminator = end_on_initial && payload.body.is_empty();
3583 if !is_pure_terminator {
3584 // Same invariant as the client-streaming fold:
3585 // fresh bounded mpsc with a live receiver cannot
3586 // reject the first send.
3587 if req_tx.try_send(payload.body).is_err() {
3588 debug_assert!(false, "fresh duplex request mpsc rejected initial body");
3589 tracing::error!(
3590 caller_origin = format!("{:#x}", meta.origin_hash),
3591 call_id = meta.seq_or_ts,
3592 "rpc duplex server fold: fresh mpsc rejected initial REQUEST body (invariant break)",
3593 );
3594 }
3595 }
3596 if !end_on_initial {
3597 self.senders.lock().insert(key, req_tx);
3598 }
3599 // Hand the handler an auto-granting RequestStream
3600 // when the caller opted into request-direction
3601 // flow control AND the fold was wired with a
3602 // grant emitter.
3603 let grant_emitter = if parse_request_window_initial(&payload.headers).is_some() {
3604 self.grant_emit.clone()
3605 } else {
3606 None
3607 };
3608 let request_stream = RequestStream::new(
3609 req_rx,
3610 grant_emitter,
3611 from_node,
3612 meta.origin_hash,
3613 meta.seq_or_ts,
3614 );
3615
3616 // Build the per-call response-side mpsc (existing
3617 // server-streaming-response pattern). The handler
3618 // writes chunks to the sink; the pump task drains
3619 // the receiver and publishes RESPONSE events.
3620 let (resp_tx, mut resp_rx) =
3621 tokio::sync::mpsc::channel::<bytes::Bytes>(STREAMING_PUMP_CAPACITY);
3622 let response_sink = RpcResponseSink {
3623 inner: resp_tx,
3624 metrics: self.metrics.clone(),
3625 };
3626
3627 let trace_context = if payload.flags & FLAG_RPC_PROPAGATE_TRACE != 0 {
3628 extract_trace_context(&payload.headers)
3629 } else {
3630 None
3631 };
3632 let deadline_ns = payload.deadline_ns;
3633 let ctx = RpcStreamingContext {
3634 caller_origin: meta.origin_hash,
3635 call_id: meta.seq_or_ts,
3636 deadline_ns,
3637 headers: payload.headers,
3638 cancellation: cancellation.clone(),
3639 trace_context,
3640 };
3641 let handler = self.handler.clone();
3642 let emit = self.emit.clone();
3643 let in_flight = self.in_flight.clone();
3644 let senders = self.senders.clone();
3645 let caller_origin = meta.origin_hash;
3646 let call_id = meta.seq_or_ts;
3647 let cancel_probe = cancellation.clone();
3648 let cancel_for_deadline = cancellation.clone();
3649 let metrics = self.metrics.clone();
3650
3651 // Pump: drains resp_rx, emits per-chunk RESPONSE
3652 // events with `nrpc-streaming: continue`.
3653 let pump_emit = emit.clone();
3654 let pump_metrics = metrics.clone();
3655 let pump = tokio::spawn(async move {
3656 while let Some(chunk) = resp_rx.recv().await {
3657 if let Some(m) = pump_metrics.as_ref() {
3658 m.streaming_chunks_emitted_total
3659 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3660 }
3661 let resp = RpcResponsePayload {
3662 status: RpcStatus::Ok,
3663 headers: vec![(
3664 HEADER_NRPC_STREAMING.to_string(),
3665 HEADER_NRPC_STREAMING_CONTINUE.to_vec(),
3666 )],
3667 body: chunk.clone(),
3668 };
3669 pump_emit(from_node, caller_origin, call_id, resp).await;
3670 }
3671 });
3672
3673 tokio::spawn(async move {
3674 if let Some(m) = metrics.as_ref() {
3675 m.handler_invocations_total
3676 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3677 m.handler_in_flight
3678 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3679 }
3680 let handler_started = std::time::Instant::now();
3681 // Same deadline guard as the client-streaming
3682 // fold: force-drop the handler future at
3683 // deadline_ns so an orphaned request stream
3684 // can't hang the call. `0` means no deadline.
3685 let call_fut = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(
3686 handler.call(ctx, request_stream, response_sink),
3687 ));
3688 let outcome = if deadline_ns > 0 {
3689 let now_ns = std::time::SystemTime::now()
3690 .duration_since(std::time::UNIX_EPOCH)
3691 .map(|d| d.as_nanos() as u64)
3692 .unwrap_or(0);
3693 let remaining = deadline_ns.saturating_sub(now_ns);
3694 if remaining == 0 {
3695 cancel_for_deadline.cancel();
3696 Ok(Err(RpcHandlerError::Internal(
3697 "duplex handler deadline_ns already expired at spawn".to_string(),
3698 )))
3699 } else {
3700 match tokio::time::timeout(
3701 std::time::Duration::from_nanos(remaining),
3702 call_fut,
3703 )
3704 .await
3705 {
3706 Ok(o) => o,
3707 Err(_) => {
3708 cancel_for_deadline.cancel();
3709 Ok(Err(RpcHandlerError::Internal(
3710 "duplex handler deadline_ns exceeded".to_string(),
3711 )))
3712 }
3713 }
3714 }
3715 } else {
3716 call_fut.await
3717 };
3718 // Handler dropped the sink — let the pump
3719 // drain any final in-flight chunks before we
3720 // emit the terminal frame.
3721 let _ = pump.await;
3722 if let Some(m) = metrics.as_ref() {
3723 m.handler_in_flight
3724 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
3725 m.record_handler_duration(handler_started.elapsed());
3726 if outcome.is_err() {
3727 m.handler_panics_total
3728 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3729 }
3730 }
3731 let terminal = if cancel_probe.is_cancelled() {
3732 RpcResponsePayload {
3733 status: RpcStatus::Cancelled,
3734 headers: vec![],
3735 body: Bytes::from_static(
3736 b"server observed CANCEL during duplex handler execution",
3737 ),
3738 }
3739 } else {
3740 match outcome {
3741 Ok(Ok(())) => RpcResponsePayload {
3742 status: RpcStatus::Ok,
3743 headers: vec![(
3744 HEADER_NRPC_STREAMING.to_string(),
3745 HEADER_NRPC_STREAMING_END.to_vec(),
3746 )],
3747 body: Bytes::new(),
3748 },
3749 Ok(Err(RpcHandlerError::Application { code, message })) => {
3750 RpcResponsePayload {
3751 status: RpcStatus::Application(code),
3752 headers: vec![],
3753 body: Bytes::from(message),
3754 }
3755 }
3756 Ok(Err(RpcHandlerError::Internal(message))) => RpcResponsePayload {
3757 status: RpcStatus::Internal,
3758 headers: vec![],
3759 body: Bytes::from(message),
3760 },
3761 Err(panic) => {
3762 let panic_msg = panic
3763 .downcast_ref::<&'static str>()
3764 .map(|s| s.to_string())
3765 .or_else(|| panic.downcast_ref::<String>().cloned())
3766 .unwrap_or_else(|| "<non-string panic>".into());
3767 tracing::error!(
3768 caller_origin = format!("{:#x}", caller_origin),
3769 call_id,
3770 panic = %panic_msg,
3771 "rpc duplex server handler panicked",
3772 );
3773 RpcResponsePayload {
3774 status: RpcStatus::Internal,
3775 headers: vec![],
3776 body: Bytes::from(format!("handler panicked: {panic_msg}")),
3777 }
3778 }
3779 }
3780 };
3781 in_flight.lock().remove(&key);
3782 senders.lock().remove(&key);
3783 emit(from_node, caller_origin, call_id, terminal).await;
3784 });
3785 }
3786 DISPATCH_RPC_REQUEST_CHUNK => {
3787 apply_request_chunk_to_senders(
3788 from_node,
3789 frame.slice(RPC_FRAME_BODY_OFFSET..),
3790 &meta,
3791 &self.senders,
3792 "duplex",
3793 );
3794 }
3795 DISPATCH_RPC_CANCEL => {
3796 if let Some(token) = self.in_flight.lock().remove(&key) {
3797 token.cancel();
3798 }
3799 self.senders.lock().remove(&key);
3800 }
3801 _ => {}
3802 }
3803 Ok(())
3804 }
3805}
3806
3807impl RedexFold<()> for RpcDuplexFold {
3808 /// Loopback / test shim: drives `apply_frame` with
3809 /// `from_node = 0` (AV-1 item 1). Production uses
3810 /// [`RpcDuplexFold::apply_inbound`].
3811 fn apply(&mut self, ev: &RedexEvent, _state: &mut ()) -> Result<(), RedexError> {
3812 self.apply_frame(0, &ev.payload)
3813 }
3814}
3815
3816// ============================================================================
3817// Client-side fold.
3818//
3819// `RpcClientFold` is the symmetric companion of `RpcServerFold`.
3820// It sees RESPONSE events on the caller's reply channel
3821// (`<service>.replies.<self_origin>`) and routes each one to the
3822// matching call's awaiting `oneshot::Receiver` keyed on `call_id`
3823// (the `EventMeta::seq_or_ts`).
3824//
3825// The fold's mutable state (the pending-senders map) is shared
3826// with the `Mesh::call` API via a clone of the same Arc — so the
3827// publisher side can `register(call_id)` to stage a receiver
3828// before publishing the REQUEST, and the fold side can `deliver`
3829// when the matching RESPONSE arrives.
3830// ============================================================================
3831
3832/// One pending entry — unary oneshot, server-streaming mpsc, or
3833/// client-streaming (one terminal oneshot + a separate grant
3834/// mpsc). The fold dispatches to the right variant based on
3835/// what's registered for the `call_id`.
3836enum PendingEntry {
3837 /// Unary call — exactly one RESPONSE expected. Completes the
3838 /// oneshot with the decoded payload.
3839 Unary(tokio::sync::oneshot::Sender<RpcResponsePayload>),
3840 /// Server-streaming call — multiple non-terminal `Continue`
3841 /// chunks followed by one terminal frame. Each non-terminal
3842 /// chunk pushes a `StreamItem::Chunk(body)` onto the mpsc;
3843 /// the terminal frame pushes `StreamItem::End` (Ok) or
3844 /// `StreamItem::Error(payload)` (non-Ok status) and the
3845 /// pending entry is removed.
3846 Streaming(tokio::sync::mpsc::UnboundedSender<StreamItem>),
3847 /// Client-streaming or duplex call. Two sender halves:
3848 ///
3849 /// - `terminal_tx`: oneshot that completes when the server's
3850 /// single terminal RESPONSE arrives. Response shape and
3851 /// delivery semantics are identical to the unary variant —
3852 /// the caller awaits one payload, success or failure status.
3853 /// - `grant_tx`: mpsc that ferries REQUEST_GRANT credit values
3854 /// from the client fold to the caller's send sink. Each
3855 /// `DISPATCH_RPC_REQUEST_GRANT` event for this call_id
3856 /// pushes one `u32` credit onto the mpsc; the caller's send
3857 /// sink consumes credits to gate `send(...).await`.
3858 ///
3859 /// Bidi streaming plan (Phase C). Used for pure client-
3860 /// streaming (one terminal RESPONSE closes the call). Duplex
3861 /// calls use the [`PendingEntry::Duplex`] variant instead,
3862 /// since they receive many response chunks rather than one
3863 /// terminal payload.
3864 ClientStreaming {
3865 terminal_tx: tokio::sync::oneshot::Sender<RpcResponsePayload>,
3866 grant_tx: tokio::sync::mpsc::UnboundedSender<u32>,
3867 },
3868 /// Duplex call — many request chunks out, many response
3869 /// chunks in. Two senders, same shape as `ClientStreaming`
3870 /// except the terminal slot is an mpsc instead of a oneshot
3871 /// because the response side is multi-chunk (terminator is
3872 /// implicit in `StreamItem::End` / `StreamItem::Error` on
3873 /// the chunks_tx mpsc, same as `PendingEntry::Streaming`).
3874 ///
3875 /// - `chunks_tx`: response-chunk mpsc — fed by `deliver`
3876 /// when RESPONSE events arrive on the reply channel.
3877 /// `StreamItem::Chunk` for non-terminal, `StreamItem::End`
3878 /// / `StreamItem::Error` terminates and removes the entry.
3879 /// - `grant_tx`: request-direction credit mpsc — fed by
3880 /// `deliver_grant` when REQUEST_GRANT events arrive.
3881 ///
3882 /// Bidi streaming plan (Phase D).
3883 Duplex {
3884 chunks_tx: tokio::sync::mpsc::UnboundedSender<StreamItem>,
3885 grant_tx: tokio::sync::mpsc::UnboundedSender<u32>,
3886 },
3887}
3888
3889/// One item delivered to a streaming caller. The caller's
3890/// `RpcStream` translates these into `Stream::Item =
3891/// Result<Bytes, RpcError>` plus stream termination.
3892#[derive(Debug, Clone)]
3893pub enum StreamItem {
3894 /// Non-terminal chunk — a body slice from the server.
3895 Chunk(bytes::Bytes),
3896 /// Terminal frame, server signaled clean stream end.
3897 End,
3898 /// Terminal frame with a non-`Ok` status. Body is the
3899 /// server's diagnostic; status is the wire `RpcStatus` value.
3900 Error(RpcResponsePayload),
3901}
3902
3903/// Shared pending-call state. Held by both the `RpcClientFold`
3904/// (writer side: completes oneshot senders / pushes streaming
3905/// chunks on RESPONSE arrival) and the `Mesh::call*` APIs (reader
3906/// side: registers entries before publishing the REQUEST).
3907/// Concurrent access is mediated by `DashMap`.
3908///
3909/// Multiplexes unary AND streaming calls in a single map keyed
3910/// on `call_id` — the entry's enum variant tells the fold how
3911/// to dispatch incoming RESPONSE events.
3912pub struct RpcClientPending {
3913 /// Map keyed on `call_id`, value carries `(expected_target,
3914 /// PendingEntry)`. `expected_target` is the `NodeId` of the
3915 /// peer the request was dispatched to; `deliver` rejects
3916 /// frames whose wire `from_node` doesn't match. A
3917 /// `expected_target == 0` entry opts out of the binding
3918 /// (loopback tests + paths with no session).
3919 senders: dashmap::DashMap<u64, (super::super::behavior::placement::NodeId, PendingEntry)>,
3920}
3921
3922impl RpcClientPending {
3923 /// Construct an empty pending-call store.
3924 pub fn new() -> Self {
3925 Self {
3926 senders: dashmap::DashMap::new(),
3927 }
3928 }
3929
3930 /// Register a oneshot for a unary `call_id`. Returns the
3931 /// receiver the caller awaits. The caller MUST publish the
3932 /// REQUEST after registration (and not before) so the
3933 /// matching RESPONSE can't arrive while the pending entry is
3934 /// missing.
3935 ///
3936 /// `target_node` is the wire-session peer the request will
3937 /// be sent to; `deliver` rejects RESPONSE frames whose
3938 /// `from_node` doesn't match. Pass `0` for loopback / no-
3939 /// session test paths to opt out of the binding gate.
3940 ///
3941 /// If a sender already exists for `call_id` (improperly reused
3942 /// id), it is replaced and the old receiver gets a
3943 /// `RecvError::Closed` — surfacing the misuse as a hard error
3944 /// at the caller rather than silently delivering the response
3945 /// to the wrong waiter.
3946 pub fn register(
3947 &self,
3948 call_id: u64,
3949 target_node: super::super::behavior::placement::NodeId,
3950 ) -> tokio::sync::oneshot::Receiver<RpcResponsePayload> {
3951 let (tx, rx) = tokio::sync::oneshot::channel();
3952 self.senders
3953 .insert(call_id, (target_node, PendingEntry::Unary(tx)));
3954 rx
3955 }
3956
3957 /// Register a streaming entry for `call_id`. Returns the
3958 /// receive end of an mpsc the fold will push chunks onto.
3959 /// Same registration ordering rules as `register` —
3960 /// publisher must call this BEFORE publishing the REQUEST.
3961 pub fn register_streaming(
3962 &self,
3963 call_id: u64,
3964 target_node: super::super::behavior::placement::NodeId,
3965 ) -> tokio::sync::mpsc::UnboundedReceiver<StreamItem> {
3966 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
3967 self.senders
3968 .insert(call_id, (target_node, PendingEntry::Streaming(tx)));
3969 rx
3970 }
3971
3972 /// Register a client-streaming (or duplex) entry for
3973 /// `call_id`. Returns BOTH the terminal-response receiver
3974 /// (the caller awaits on this for the single terminal
3975 /// RESPONSE that ends the call) AND a grant receiver (the
3976 /// caller's send sink consumes this to gate `send().await`
3977 /// when the caller opted into request-direction flow
3978 /// control).
3979 ///
3980 /// Same registration ordering rules as `register` /
3981 /// `register_streaming` — publisher must call this BEFORE
3982 /// publishing the REQUEST so a fast server's RESPONSE /
3983 /// REQUEST_GRANT can't arrive while no pending entry exists.
3984 ///
3985 /// Bidi streaming plan (Phase C).
3986 pub fn register_client_streaming(
3987 &self,
3988 call_id: u64,
3989 target_node: super::super::behavior::placement::NodeId,
3990 ) -> (
3991 tokio::sync::oneshot::Receiver<RpcResponsePayload>,
3992 tokio::sync::mpsc::UnboundedReceiver<u32>,
3993 ) {
3994 let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
3995 let (grant_tx, grant_rx) = tokio::sync::mpsc::unbounded_channel();
3996 self.senders.insert(
3997 call_id,
3998 (
3999 target_node,
4000 PendingEntry::ClientStreaming {
4001 terminal_tx,
4002 grant_tx,
4003 },
4004 ),
4005 );
4006 (terminal_rx, grant_rx)
4007 }
4008
4009 /// Register a duplex entry for `call_id`. Returns BOTH a
4010 /// response-chunk receiver (yields `StreamItem` per inbound
4011 /// RESPONSE chunk; terminator is `End` / `Error`) AND a
4012 /// grant receiver (yields `u32` credits per inbound
4013 /// REQUEST_GRANT).
4014 ///
4015 /// Same registration ordering rules as the other `register_*`
4016 /// methods: publisher must call this BEFORE publishing the
4017 /// REQUEST so the server's response chunks / grants can't
4018 /// arrive while no pending entry exists.
4019 ///
4020 /// Bidi streaming plan (Phase D).
4021 pub fn register_duplex(
4022 &self,
4023 call_id: u64,
4024 target_node: super::super::behavior::placement::NodeId,
4025 ) -> (
4026 tokio::sync::mpsc::UnboundedReceiver<StreamItem>,
4027 tokio::sync::mpsc::UnboundedReceiver<u32>,
4028 ) {
4029 let (chunks_tx, chunks_rx) = tokio::sync::mpsc::unbounded_channel();
4030 let (grant_tx, grant_rx) = tokio::sync::mpsc::unbounded_channel();
4031 self.senders.insert(
4032 call_id,
4033 (
4034 target_node,
4035 PendingEntry::Duplex {
4036 chunks_tx,
4037 grant_tx,
4038 },
4039 ),
4040 );
4041 (chunks_rx, grant_rx)
4042 }
4043
4044 /// Drop the pending entry for `call_id`. Called by the
4045 /// caller-side cancellation path (e.g. `Mesh::call`'s future
4046 /// being dropped, the stream being dropped, or a deadline
4047 /// timer firing). The matching RESPONSE(s) that may still
4048 /// arrive afterwards are silently discarded by `deliver`.
4049 pub fn cancel(&self, call_id: u64) {
4050 self.senders.remove(&call_id);
4051 }
4052
4053 /// Deliver `resp` to the waiter for `call_id`, if any.
4054 ///
4055 /// `from_node` is the wire-session peer of the inbound
4056 /// RESPONSE. If the pending entry's recorded `target_node`
4057 /// is non-zero and does not match `from_node`, the frame is
4058 /// dropped with a trace log and the pending entry stays
4059 /// intact — a forged response on a shared reply channel
4060 /// can't resolve a victim's call. A recorded `target_node
4061 /// == 0` opts the call out of the binding (loopback paths).
4062 ///
4063 /// For a unary entry: completes the oneshot and removes the
4064 /// entry.
4065 ///
4066 /// For a streaming entry: examines the response's headers to
4067 /// decide whether it's a non-terminal chunk (`Continue` —
4068 /// push `StreamItem::Chunk`, keep the entry) or terminal
4069 /// (`End` / non-`Ok` — push `StreamItem::End` or `Error`,
4070 /// remove the entry).
4071 ///
4072 /// Idempotent on subsequent deliveries to a removed entry.
4073 fn deliver(
4074 &self,
4075 call_id: u64,
4076 from_node: super::super::behavior::placement::NodeId,
4077 resp: RpcResponsePayload,
4078 ) {
4079 // Look up the entry — but DON'T remove it yet, because for
4080 // streaming we may want to keep it for non-terminal chunks.
4081 // The remove decision is per-variant.
4082 let entry = self.senders.get(&call_id);
4083 let Some(entry) = entry else { return };
4084 // S-4 part 2 gate. The pending registry binds each call
4085 // to the AEAD-verified `target_node` the request was
4086 // dispatched to; any other session peer publishing on the
4087 // shared reply channel with a guessed call_id is dropped
4088 // here without touching the waiter. `0` opts out — used
4089 // by loopback paths that have no session peer.
4090 let (target_node, _entry_value) = entry.value();
4091 if *target_node != 0 && *target_node != from_node {
4092 tracing::trace!(
4093 call_id,
4094 from_node,
4095 expected = *target_node,
4096 "rpc client: dropping RESPONSE from non-target session peer"
4097 );
4098 return;
4099 }
4100 match entry.value() {
4101 (_, PendingEntry::Unary(_)) => {
4102 drop(entry);
4103 if let Some((_, (_, PendingEntry::Unary(tx)))) = self.senders.remove(&call_id) {
4104 let _ = tx.send(resp);
4105 }
4106 }
4107 (_, PendingEntry::ClientStreaming { .. }) => {
4108 // Terminal RESPONSE for a client-streaming /
4109 // duplex call. Same delivery shape as Unary —
4110 // complete the oneshot, remove the entry. The
4111 // grant_tx half drops with the entry, which is
4112 // fine (no more grants will arrive after the
4113 // terminal frame).
4114 drop(entry);
4115 if let Some((
4116 _,
4117 (
4118 _,
4119 PendingEntry::ClientStreaming {
4120 terminal_tx,
4121 grant_tx: _,
4122 },
4123 ),
4124 )) = self.senders.remove(&call_id)
4125 {
4126 let _ = terminal_tx.send(resp);
4127 }
4128 }
4129 (_, PendingEntry::Streaming(tx)) => {
4130 let tx = tx.clone();
4131 drop(entry);
4132 self.dispatch_streaming_chunk(&tx, resp, call_id);
4133 }
4134 (_, PendingEntry::Duplex { chunks_tx, .. }) => {
4135 // Same dispatch logic as Streaming — duplex
4136 // response side IS a multi-chunk stream.
4137 let tx = chunks_tx.clone();
4138 drop(entry);
4139 self.dispatch_streaming_chunk(&tx, resp, call_id);
4140 }
4141 }
4142 }
4143
4144 /// Shared response-chunk dispatch used by both
4145 /// `PendingEntry::Streaming` and `PendingEntry::Duplex`. The
4146 /// caller has already verified the target-binding gate and
4147 /// dropped its `entry` ref; this helper does the classify-
4148 /// and-push and removes the entry from the senders map on
4149 /// terminal frames.
4150 fn dispatch_streaming_chunk(
4151 &self,
4152 tx: &tokio::sync::mpsc::UnboundedSender<StreamItem>,
4153 resp: RpcResponsePayload,
4154 call_id: u64,
4155 ) {
4156 let kind = classify_streaming_chunk(&resp);
4157 match kind {
4158 StreamingChunkKind::Continue => {
4159 let _ = tx.send(StreamItem::Chunk(resp.body));
4160 }
4161 StreamingChunkKind::Terminal => {
4162 let item = if resp.status.is_ok() {
4163 if !resp.body.is_empty() {
4164 let _ = tx.send(StreamItem::Chunk(resp.body));
4165 }
4166 StreamItem::End
4167 } else {
4168 StreamItem::Error(resp)
4169 };
4170 let _ = tx.send(item);
4171 self.senders.remove(&call_id);
4172 }
4173 StreamingChunkKind::Unary => {
4174 tracing::warn!(
4175 call_id,
4176 body_len = resp.body.len(),
4177 "rpc client: streaming / duplex consumer received unary-shaped \
4178 response (no nrpc-streaming header); server may have bridged a \
4179 unary path. Bridging to single-chunk + EOF.",
4180 );
4181 if !resp.body.is_empty() {
4182 let _ = tx.send(StreamItem::Chunk(resp.body));
4183 }
4184 let _ = tx.send(StreamItem::End);
4185 self.senders.remove(&call_id);
4186 }
4187 }
4188 }
4189
4190 /// Deliver a request-direction grant credit to the waiter
4191 /// for `call_id`, if it's a client-streaming / duplex entry.
4192 /// Silently no-op for unknown call_ids, for unary entries
4193 /// (caller bug — grant for a unary call makes no sense),
4194 /// and for server-streaming entries (grants apply only to
4195 /// the upload direction).
4196 ///
4197 /// `from_node` is gated by the same target-binding check
4198 /// as `deliver`: a grant from a non-target session peer is
4199 /// dropped (a forged grant on a shared reply channel can't
4200 /// inject credit into a victim's call).
4201 ///
4202 /// Bidi streaming plan (Phase C).
4203 fn deliver_grant(
4204 &self,
4205 call_id: u64,
4206 from_node: super::super::behavior::placement::NodeId,
4207 credits: u32,
4208 ) {
4209 let entry = self.senders.get(&call_id);
4210 let Some(entry) = entry else { return };
4211 let (target_node, _entry_value) = entry.value();
4212 if *target_node != 0 && *target_node != from_node {
4213 tracing::trace!(
4214 call_id,
4215 from_node,
4216 expected = *target_node,
4217 "rpc client: dropping REQUEST_GRANT from non-target session peer"
4218 );
4219 return;
4220 }
4221 match entry.value() {
4222 (_, PendingEntry::ClientStreaming { grant_tx, .. })
4223 | (_, PendingEntry::Duplex { grant_tx, .. }) => {
4224 let _ = grant_tx.send(credits);
4225 }
4226 // Unary / Streaming entries silently ignore — see
4227 // method docs for the rationale.
4228 _ => {}
4229 }
4230 }
4231
4232 /// Test-only: how many pending calls are registered. Used by
4233 /// integration tests to confirm cleanup after happy-path / cancel.
4234 #[cfg(test)]
4235 pub fn pending_count(&self) -> usize {
4236 self.senders.len()
4237 }
4238}
4239
4240impl Default for RpcClientPending {
4241 fn default() -> Self {
4242 Self::new()
4243 }
4244}
4245
4246/// Client-side fold. Decodes RESPONSE events and routes them to
4247/// awaiting oneshots in the shared [`RpcClientPending`].
4248///
4249/// `Mesh::call` clones the same `Arc<RpcClientPending>` to register
4250/// oneshots before publishing REQUESTs.
4251pub struct RpcClientFold {
4252 pending: Arc<RpcClientPending>,
4253}
4254
4255impl RpcClientFold {
4256 /// Construct a client fold that delivers responses through
4257 /// `pending`. Typical pattern:
4258 ///
4259 /// ```ignore
4260 /// let pending = Arc::new(RpcClientPending::new());
4261 /// let fold = RpcClientFold::new(pending.clone());
4262 /// let adapter = CortexAdapter::open(..., fold, ())?;
4263 /// // `pending` is still usable for register / cancel.
4264 /// ```
4265 pub fn new(pending: Arc<RpcClientPending>) -> Self {
4266 Self { pending }
4267 }
4268
4269 /// Production-path entry point. Mesh dispatch calls this with
4270 /// the AEAD-verified session peer's `NodeId` in
4271 /// `ev.from_node`; the pending registry's S-4 binding gate
4272 /// uses it to reject responses from the wrong target.
4273 pub fn apply_inbound(&mut self, ev: &RpcInboundEvent) {
4274 let Some(meta) = (if ev.payload.len() >= EVENT_META_SIZE {
4275 EventMeta::from_bytes(&ev.payload[..EVENT_META_SIZE])
4276 } else {
4277 None
4278 }) else {
4279 tracing::warn!(
4280 payload_len = ev.payload.len(),
4281 "rpc client fold: event payload too short for EventMeta; skipping",
4282 );
4283 return;
4284 };
4285 match meta.dispatch {
4286 DISPATCH_RPC_RESPONSE => {
4287 match RpcResponsePayload::decode(ev.payload.slice(RPC_FRAME_BODY_OFFSET..)) {
4288 Ok(resp) => self.pending.deliver(meta.seq_or_ts, ev.from_node, resp),
4289 Err(e) => {
4290 tracing::warn!(
4291 error = %e,
4292 call_id = meta.seq_or_ts,
4293 "rpc client fold: malformed response payload",
4294 );
4295 }
4296 }
4297 }
4298 DISPATCH_RPC_REQUEST_GRANT => {
4299 // Server granted upload credit for a
4300 // client-streaming / duplex call. Route it to the
4301 // matching pending entry's grant mpsc; non-client-
4302 // streaming entries silently ignore (see
4303 // RpcClientPending::deliver_grant docs).
4304 match decode_request_grant(&ev.payload[RPC_FRAME_BODY_OFFSET..]) {
4305 Some(grant) => {
4306 // The payload's `call_id` MUST agree with
4307 // the EventMeta's `seq_or_ts`: producer
4308 // encodes both to the same value (see
4309 // `RpcRequestGrantPayload::call_id` docs).
4310 // If they disagree, the frame is malformed
4311 // or forged — drop it. Otherwise a peer
4312 // could publish a GRANT whose meta names
4313 // one call but whose payload credits a
4314 // different in-flight call_id.
4315 if grant.call_id != meta.seq_or_ts {
4316 tracing::debug!(
4317 meta_call_id = meta.seq_or_ts,
4318 payload_call_id = grant.call_id,
4319 "rpc client fold: REQUEST_GRANT meta/payload call_id mismatch; dropping",
4320 );
4321 return;
4322 }
4323 if grant.credits == 0 {
4324 return;
4325 }
4326 self.pending
4327 .deliver_grant(grant.call_id, ev.from_node, grant.credits);
4328 }
4329 None => {
4330 tracing::debug!(
4331 call_id = meta.seq_or_ts,
4332 "rpc client fold: malformed REQUEST_GRANT payload"
4333 );
4334 }
4335 }
4336 }
4337 _ => {
4338 // Unknown / unexpected dispatch on the reply
4339 // channel — ignore (a misconfigured publisher
4340 // shouldn't take down the fold).
4341 }
4342 }
4343 }
4344}
4345
4346impl RedexFold<()> for RpcClientFold {
4347 /// Legacy entry point used by loopback / test paths that
4348 /// don't have a session peer to resolve. Calls `deliver`
4349 /// with `from_node = 0`, which the pending registry treats
4350 /// as "no binding" — callers that registered with
4351 /// `target_node = 0` accept it, callers that registered
4352 /// with a real target reject it.
4353 fn apply(&mut self, ev: &RedexEvent, _state: &mut ()) -> Result<(), RedexError> {
4354 let Some(meta) = (if ev.payload.len() >= EVENT_META_SIZE {
4355 EventMeta::from_bytes(&ev.payload[..EVENT_META_SIZE])
4356 } else {
4357 None
4358 }) else {
4359 tracing::warn!(
4360 payload_len = ev.payload.len(),
4361 "rpc client fold: event payload too short for EventMeta; skipping",
4362 );
4363 return Ok(());
4364 };
4365 // Route RESPONSE and REQUEST_GRANT events; ignore other
4366 // dispatches a misconfigured publisher might send. The
4367 // loopback path uses `from_node = 0` which the pending
4368 // registry treats as "no binding" — see the apply_inbound
4369 // production-path counterpart above for the AEAD-verified
4370 // peer routing.
4371 match meta.dispatch {
4372 DISPATCH_RPC_RESPONSE => {
4373 match RpcResponsePayload::decode(ev.payload.slice(RPC_FRAME_BODY_OFFSET..)) {
4374 Ok(resp) => self.pending.deliver(meta.seq_or_ts, 0, resp),
4375 Err(e) => {
4376 // Malformed RESPONSE on the reply channel.
4377 // We can't fabricate a synthetic response
4378 // (the call_id might be valid; we just
4379 // can't tell what it was supposed to
4380 // mean). Log and leave the pending entry
4381 // intact — the caller's deadline /
4382 // cancellation path will eventually clean
4383 // it up.
4384 tracing::warn!(
4385 error = %e,
4386 call_id = meta.seq_or_ts,
4387 "rpc client fold: malformed response payload",
4388 );
4389 }
4390 }
4391 }
4392 DISPATCH_RPC_REQUEST_GRANT => {
4393 match decode_request_grant(&ev.payload[RPC_FRAME_BODY_OFFSET..]) {
4394 Some(grant) => {
4395 // See `apply_inbound` REQUEST_GRANT arm for
4396 // the meta/payload call_id invariant.
4397 if grant.call_id != meta.seq_or_ts {
4398 tracing::debug!(
4399 meta_call_id = meta.seq_or_ts,
4400 payload_call_id = grant.call_id,
4401 "rpc client fold: REQUEST_GRANT meta/payload call_id mismatch; dropping",
4402 );
4403 return Ok(());
4404 }
4405 if grant.credits == 0 {
4406 return Ok(());
4407 }
4408 self.pending.deliver_grant(grant.call_id, 0, grant.credits);
4409 }
4410 None => {
4411 tracing::debug!(
4412 call_id = meta.seq_or_ts,
4413 "rpc client fold: malformed REQUEST_GRANT payload"
4414 );
4415 }
4416 }
4417 }
4418 _ => {}
4419 }
4420 Ok(())
4421 }
4422}
4423
4424#[cfg(test)]
4425mod tests {
4426 use super::*;
4427
4428 fn header(name: &str, value: &[u8]) -> RpcHeader {
4429 (name.to_string(), value.to_vec())
4430 }
4431
4432 // --------------------------------------------------------------------
4433 // OA2-E0.2 P0 — `peek_request_service` boundary contract.
4434 // --------------------------------------------------------------------
4435
4436 /// Build a REQUEST frame `EventMeta ‖ RpcRouteV1(0) ‖
4437 /// RpcRequestPayload{service, ..}` for the peek unit tests.
4438 fn request_frame_for_peek(service: &str) -> Vec<u8> {
4439 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, 0, 1, 0);
4440 let req = RpcRequestPayload {
4441 service: service.to_string(),
4442 deadline_ns: 0,
4443 flags: 0,
4444 headers: vec![],
4445 body: Bytes::from_static(b"body"),
4446 };
4447 let mut buf = Vec::new();
4448 buf.extend_from_slice(&meta.to_bytes());
4449 encode_rpc_route(&mut buf, 0);
4450 req.encode_into(&mut buf);
4451 buf
4452 }
4453
4454 /// The peek reads back exactly the `service` the full encoder
4455 /// wrote — the invariant the serve-bridge equality check relies
4456 /// on (peek and full decode agree on the service).
4457 #[test]
4458 fn peek_request_service_matches_full_decode() {
4459 for name in ["admin", "echo.v1", "x"] {
4460 let frame = request_frame_for_peek(name);
4461 assert_eq!(peek_request_service(&frame), Some(name));
4462 // And it agrees with the authoritative decoder.
4463 let decoded =
4464 RpcRequestPayload::decode(Bytes::from(frame[RPC_FRAME_BODY_OFFSET..].to_vec()))
4465 .expect("decode");
4466 assert_eq!(decoded.service, name);
4467 }
4468 }
4469
4470 /// Unreadable service fields return `None`, mirroring the `Err`
4471 /// arms of `RpcRequestPayload::decode` — the bridge then lets the
4472 /// fold's full decode reject the frame (`UnknownVersion`) rather
4473 /// than silently dropping it.
4474 #[test]
4475 fn peek_request_service_none_on_malformed() {
4476 // Frame with no room for the route/body at all.
4477 let short = EventMeta::new(DISPATCH_RPC_REQUEST, 0, 0, 1, 0).to_bytes();
4478 assert_eq!(peek_request_service(&short), None);
4479
4480 // Route present but zero-length service (decode rejects empty).
4481 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, 0, 1, 0);
4482 let mut buf = Vec::new();
4483 buf.extend_from_slice(&meta.to_bytes());
4484 encode_rpc_route(&mut buf, 0);
4485 buf.push(0u8); // svc_len = 0
4486 assert_eq!(peek_request_service(&buf), None);
4487
4488 // Length byte claims more bytes than remain.
4489 let mut buf = Vec::new();
4490 buf.extend_from_slice(&meta.to_bytes());
4491 encode_rpc_route(&mut buf, 0);
4492 buf.push(5u8); // svc_len = 5 but no bytes follow
4493 assert_eq!(peek_request_service(&buf), None);
4494 }
4495
4496 // --------------------------------------------------------------------
4497 // Status code numbering.
4498 // --------------------------------------------------------------------
4499
4500 /// Status codes have stable wire numbers. A regression that
4501 /// renumbered any of the canonical statuses would break
4502 /// every cross-version caller / server pair on the wire — pin
4503 /// the numbers explicitly so the test catches it before the
4504 /// bug ships.
4505 #[test]
4506 fn status_wire_numbers_are_stable() {
4507 for (status, expected) in [
4508 (RpcStatus::Ok, 0x0000u16),
4509 (RpcStatus::NotFound, 0x0001),
4510 (RpcStatus::Unauthorized, 0x0002),
4511 (RpcStatus::Timeout, 0x0003),
4512 (RpcStatus::Backpressure, 0x0004),
4513 (RpcStatus::Cancelled, 0x0005),
4514 (RpcStatus::Internal, 0x0006),
4515 (RpcStatus::UnknownVersion, 0x0007),
4516 (RpcStatus::CapabilityDenied, 0x0008),
4517 (RpcStatus::AdmissionDenied, 0x0009),
4518 ] {
4519 assert_eq!(status.to_wire(), expected, "{status:?}");
4520 assert_eq!(RpcStatus::from_wire(expected), status);
4521 }
4522 }
4523
4524 /// Reserved numeric range (`0x000A..=0x7FFF`) decodes as
4525 /// `Application(v)` for forward-compat with future canonical
4526 /// assignments. A future status numbered `0x000A` would round-
4527 /// trip via `from_wire(0x000A)` until that variant is added,
4528 /// at which point the variant takes precedence.
4529 #[test]
4530 fn reserved_status_range_decodes_as_application_for_forward_compat() {
4531 let decoded = RpcStatus::from_wire(0x000A);
4532 assert_eq!(decoded, RpcStatus::Application(0x000A));
4533 assert_eq!(decoded.to_wire(), 0x000A);
4534 }
4535
4536 /// Application range (`0x8000..=0xFFFF`) encodes / decodes
4537 /// transparently as `Application(v)`.
4538 #[test]
4539 fn application_status_range_roundtrips() {
4540 for v in [0x8000u16, 0x8001, 0xCAFE, 0xFFFF] {
4541 let s = RpcStatus::from_wire(v);
4542 assert_eq!(s, RpcStatus::Application(v));
4543 assert_eq!(s.to_wire(), v);
4544 }
4545 }
4546
4547 // --------------------------------------------------------------------
4548 // Dispatch byte assignments.
4549 // --------------------------------------------------------------------
4550
4551 /// Pin the `dispatch` byte assignments so a renumber surfaces
4552 /// here before it ships on the wire. These also live in the
4553 /// design doc; this test is the source-of-truth check.
4554 #[test]
4555 fn dispatch_byte_assignments_are_stable() {
4556 assert_eq!(DISPATCH_RPC_REQUEST, 0x10);
4557 assert_eq!(DISPATCH_RPC_RESPONSE, 0x11);
4558 assert_eq!(DISPATCH_RPC_CANCEL, 0x12);
4559 assert_eq!(DISPATCH_RPC_DEADLINE_EXCEEDED, 0x13);
4560 assert_eq!(DISPATCH_RPC_STREAM_GRANT, 0x14);
4561 assert_eq!(DISPATCH_RPC_REQUEST_CHUNK, 0x15);
4562 assert_eq!(DISPATCH_RPC_REQUEST_GRANT, 0x16);
4563 }
4564
4565 /// Regression: encoder bounds. Encoding a service name longer
4566 /// than `MAX_RPC_SERVICE_NAME_LEN` panics in debug, catching
4567 /// the programmer error in tests rather than silently writing
4568 /// a truncated `as u8` length that the receiver decodes as
4569 /// garbage. The matching debug_asserts guard body length,
4570 /// header count, header name length, and header value length.
4571 #[cfg(debug_assertions)]
4572 #[test]
4573 #[should_panic(expected = "service name")]
4574 fn request_encode_panics_on_oversize_service_name() {
4575 let p = RpcRequestPayload {
4576 service: "x".repeat(MAX_RPC_SERVICE_NAME_LEN + 1),
4577 deadline_ns: 0,
4578 flags: 0,
4579 headers: vec![],
4580 body: Bytes::new(),
4581 };
4582 let _ = p.encode();
4583 }
4584
4585 #[cfg(debug_assertions)]
4586 #[test]
4587 #[should_panic(expected = "body length")]
4588 fn request_encode_panics_on_oversize_body() {
4589 let p = RpcRequestPayload {
4590 service: "x".to_string(),
4591 deadline_ns: 0,
4592 flags: 0,
4593 headers: vec![],
4594 body: Bytes::from(vec![0; MAX_RPC_BODY_LEN + 1]),
4595 };
4596 let _ = p.encode();
4597 }
4598
4599 #[cfg(debug_assertions)]
4600 #[test]
4601 #[should_panic(expected = "header name")]
4602 fn request_encode_panics_on_oversize_header_name() {
4603 let p = RpcRequestPayload {
4604 service: "x".to_string(),
4605 deadline_ns: 0,
4606 flags: 0,
4607 headers: vec![("a".repeat(MAX_RPC_HEADER_NAME_LEN + 1), vec![])],
4608 body: Bytes::new(),
4609 };
4610 let _ = p.encode();
4611 }
4612
4613 /// `encoded_len()` must agree with `encode().len()` for every
4614 /// payload shape — pin this so a future codec change can't
4615 /// silently desynchronize the size-budgeting helper from the
4616 /// actual wire size.
4617 #[test]
4618 fn encoded_len_matches_encode_len_for_request_and_response() {
4619 let req = RpcRequestPayload {
4620 service: "echo.v1".to_string(),
4621 deadline_ns: 1_700_000_000_000_000_000,
4622 flags: FLAG_RPC_PROPAGATE_TRACE,
4623 headers: vec![
4624 header("traceparent", b"00-aabb"),
4625 header("idempotency-key", &7u64.to_le_bytes()),
4626 ],
4627 body: Bytes::from_static(b"{\"hello\":\"world\"}"),
4628 };
4629 assert_eq!(req.encoded_len(), req.encode().len());
4630
4631 let resp = RpcResponsePayload {
4632 status: RpcStatus::Application(0x8001),
4633 headers: vec![header("content-type", b"application/json")],
4634 body: Bytes::from_static(b"ok"),
4635 };
4636 assert_eq!(resp.encoded_len(), resp.encode().len());
4637
4638 // Empty edge cases.
4639 let empty_req = RpcRequestPayload {
4640 service: "x".to_string(),
4641 deadline_ns: 0,
4642 flags: 0,
4643 headers: vec![],
4644 body: Bytes::new(),
4645 };
4646 assert_eq!(empty_req.encoded_len(), empty_req.encode().len());
4647 let empty_resp = RpcResponsePayload {
4648 status: RpcStatus::Ok,
4649 headers: vec![],
4650 body: Bytes::new(),
4651 };
4652 assert_eq!(empty_resp.encoded_len(), empty_resp.encode().len());
4653 }
4654
4655 /// Bit 0 of `RpcRequestPayload::flags` is reserved (was the
4656 /// removed `FLAG_RPC_IDEMPOTENT`). Pin: live flag constants
4657 /// must NOT collide with bit 0, so a future re-add can safely
4658 /// reuse it without breaking existing senders.
4659 #[test]
4660 fn flag_bit_assignments_leave_idempotent_slot_reserved() {
4661 // Bit 0 (1 << 0) and bit 3 (1 << 3) are reserved; live flags
4662 // occupy other bits. Pinning the exact assignments here so
4663 // a renumber that collides with bit 0 (future `IDEMPOTENT`
4664 // re-add) or bit 3 (held in reserve for a future protocol
4665 // flag) surfaces in the test suite before it ships.
4666 assert_eq!(FLAG_RPC_STREAMING_RESPONSE, 1 << 1);
4667 assert_eq!(FLAG_RPC_PROPAGATE_TRACE, 1 << 2);
4668 assert_eq!(FLAG_RPC_CLIENT_STREAMING_REQUEST, 1 << 4);
4669 assert_eq!(FLAG_RPC_REQUEST_END, 1 << 5);
4670 for flag in [
4671 FLAG_RPC_STREAMING_RESPONSE,
4672 FLAG_RPC_PROPAGATE_TRACE,
4673 FLAG_RPC_CLIENT_STREAMING_REQUEST,
4674 FLAG_RPC_REQUEST_END,
4675 ] {
4676 assert_eq!(
4677 flag & (1 << 0),
4678 0,
4679 "flag {flag:#06x} collides with reserved bit 0"
4680 );
4681 assert_eq!(
4682 flag & (1 << 3),
4683 0,
4684 "flag {flag:#06x} collides with reserved bit 3"
4685 );
4686 }
4687 }
4688
4689 // --------------------------------------------------------------------
4690 // Bidi streaming (Phase A) — RpcRequestChunkPayload and
4691 // RpcRequestGrantPayload wire-stability tests.
4692 // --------------------------------------------------------------------
4693
4694 /// 1/5 — RequestChunk round-trip with realistic header set and
4695 /// 1 KiB body. Pins the encode/decode loop on the full shape.
4696 #[test]
4697 fn request_chunk_roundtrip_with_headers_and_body() {
4698 let mut headers = Vec::new();
4699 for i in 0..10u8 {
4700 headers.push(header(&format!("x-chunk-meta-{i}"), &[0xAA, 0xBB, i, !i]));
4701 }
4702 let body: Vec<u8> = (0..1024u32).map(|n| (n & 0xFF) as u8).collect();
4703 let p = RpcRequestChunkPayload {
4704 call_id: 0xCAFE_F00D_DEAD_BEEF,
4705 flags: FLAG_RPC_REQUEST_END | FLAG_RPC_PROPAGATE_TRACE,
4706 headers,
4707 body: Bytes::from(body),
4708 };
4709 let bytes = p.encode();
4710 assert_eq!(
4711 p.encoded_len(),
4712 bytes.len(),
4713 "encoded_len must agree with encode().len()"
4714 );
4715 let decoded = RpcRequestChunkPayload::decode(Bytes::from(bytes)).expect("decode");
4716 assert_eq!(decoded, p);
4717 }
4718
4719 /// 2/5 — truncation rejection at every field boundary. The
4720 /// codec must error rather than panic / allocate-unbounded on
4721 /// any short slice.
4722 #[test]
4723 fn request_chunk_decode_rejects_truncation_at_every_boundary() {
4724 let p = RpcRequestChunkPayload {
4725 call_id: 0x1234,
4726 flags: 0,
4727 headers: vec![header("x", b"y")],
4728 body: Bytes::from_static(b"hello"),
4729 };
4730 let full = p.encode();
4731 // Walk every prefix shorter than the full encoding; every
4732 // one must produce a Truncated / TooLarge / InvalidUtf8
4733 // error, not panic.
4734 for n in 0..full.len() {
4735 let prefix = &full[..n];
4736 let result = RpcRequestChunkPayload::decode(Bytes::copy_from_slice(prefix));
4737 assert!(result.is_err(), "n={n}: expected Err, got Ok({:?})", result);
4738 }
4739 // Full length must decode cleanly.
4740 assert!(RpcRequestChunkPayload::decode(Bytes::from(full)).is_ok());
4741 }
4742
4743 /// 3/5 — body length cap rejection. A wire-claimed body length
4744 /// over `MAX_RPC_BODY_LEN` must error rather than try to
4745 /// allocate 4+ MiB of garbage.
4746 #[test]
4747 fn request_chunk_decode_rejects_oversized_body_length() {
4748 // Build a synthetic encoding by hand: small valid prefix
4749 // up to body_len, then claim body_len = MAX_RPC_BODY_LEN + 1.
4750 let mut buf = Vec::new();
4751 buf.put_u64_le(0x42); // call_id
4752 buf.put_u16_le(0); // flags
4753 buf.put_u8(0); // headers count = 0
4754 buf.put_u32_le((MAX_RPC_BODY_LEN + 1) as u32);
4755 // (no body bytes follow — we want the decoder to reject at
4756 // the length check before it even tries to read body bytes)
4757 let err = RpcRequestChunkPayload::decode(Bytes::from(buf))
4758 .expect_err("oversized body length must reject");
4759 match err {
4760 RpcCodecError::TooLarge {
4761 field,
4762 actual,
4763 limit,
4764 } => {
4765 assert_eq!(field, "body");
4766 assert_eq!(actual, MAX_RPC_BODY_LEN + 1);
4767 assert_eq!(limit, MAX_RPC_BODY_LEN);
4768 }
4769 other => panic!("expected TooLarge {{ field=body }}, got {other:?}"),
4770 }
4771 }
4772
4773 /// 4/5 — header count cap rejection. A header count over
4774 /// `MAX_RPC_HEADERS` must error before the per-header decode
4775 /// loop even starts.
4776 #[test]
4777 fn request_chunk_decode_rejects_oversized_header_count() {
4778 let mut buf = Vec::new();
4779 buf.put_u64_le(0x42); // call_id
4780 buf.put_u16_le(0); // flags
4781 buf.put_u8((MAX_RPC_HEADERS + 1) as u8); // over the cap
4782 let err = RpcRequestChunkPayload::decode(Bytes::from(buf))
4783 .expect_err("oversized header count must reject");
4784 match err {
4785 RpcCodecError::TooLarge {
4786 field,
4787 actual,
4788 limit,
4789 } => {
4790 // The shared `decode_headers` helper reports this
4791 // field as "headers".
4792 assert_eq!(field, "headers");
4793 assert_eq!(actual, MAX_RPC_HEADERS + 1);
4794 assert_eq!(limit, MAX_RPC_HEADERS);
4795 }
4796 other => panic!("expected TooLarge {{ field=headers }}, got {other:?}"),
4797 }
4798 }
4799
4800 /// R3-1: a `RequestStream` auto-grant carries the call's
4801 /// AEAD-authenticated `from_node`, so the upload grant is
4802 /// session-scoped — the fold binds each call to its own session, and
4803 /// two calls sharing one entity/origin + caller-chosen call_id
4804 /// produce grants with DISTINCT identities that cannot collapse and
4805 /// refill each other's request semaphore.
4806 ///
4807 /// Red-witness: the pre-R3-1 emit dropped `from_node` (fired
4808 /// `(origin, call_id, 1)`), so both sessions' grants collapsed to the
4809 /// same `(origin, call_id)` — reproduced here by hardcoding
4810 /// `from_node = 0` in the emit, which makes the two captured grants
4811 /// identical and fails the distinctness assertion.
4812 #[tokio::test]
4813 async fn request_stream_auto_grant_carries_the_authenticated_from_node() {
4814 use futures::StreamExt;
4815
4816 const ORIGIN: u64 = 0xBEEF;
4817 const CALL: u64 = 7;
4818 const NODE_A: u64 = 0xAAAA;
4819 const NODE_B: u64 = 0xBBBB;
4820
4821 // A capturing grant emitter (production `RpcRequestGrantEmitter`
4822 // signature) records every `(from_node, origin, call_id, credits)`.
4823 type Grants = Arc<Mutex<Vec<(u64, u64, u64, u32)>>>;
4824 let captured: Grants = Arc::new(Mutex::new(Vec::new()));
4825 let mk_emitter = |sink: Grants| -> RpcRequestGrantEmitter {
4826 Arc::new(move |from_node, origin, call_id, credits| {
4827 sink.lock().push((from_node, origin, call_id, credits));
4828 })
4829 };
4830
4831 // Drive one poll of a RequestStream bound to `node`, over the same
4832 // ORIGIN + CALL, and return the grant it fired.
4833 async fn one_grant(node: u64, emit: RpcRequestGrantEmitter) {
4834 let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
4835 tx.send(Bytes::from_static(b"chunk"))
4836 .await
4837 .expect("queue chunk");
4838 drop(tx);
4839 let mut stream = RequestStream::new(rx, Some(emit), node, ORIGIN, CALL);
4840 assert_eq!(
4841 stream.next().await.as_deref(),
4842 Some(&b"chunk"[..]),
4843 "the stream must yield the queued chunk",
4844 );
4845 }
4846
4847 one_grant(NODE_A, mk_emitter(captured.clone())).await;
4848 one_grant(NODE_B, mk_emitter(captured.clone())).await;
4849
4850 let grants = captured.lock().clone();
4851 assert_eq!(
4852 grants,
4853 vec![(NODE_A, ORIGIN, CALL, 1), (NODE_B, ORIGIN, CALL, 1)],
4854 "each poll fires ONE grant carrying that call's from_node",
4855 );
4856 assert_ne!(
4857 grants[0], grants[1],
4858 "two sessions over the same origin+call_id must produce DISTINCT grant identities",
4859 );
4860 }
4861
4862 /// 5/5 — RequestGrant round-trip + truncation rejection. The
4863 /// payload is fixed-size (12 bytes), so the test surface is
4864 /// "exactly 12 bytes decodes" + "any other length errors".
4865 #[test]
4866 fn request_grant_roundtrip_and_truncation_rejection() {
4867 // Round-trip across the full u32 range corners + an
4868 // arbitrary mid-value.
4869 for (call_id, credits) in [
4870 (0u64, 0u32),
4871 (1, 1),
4872 (0xFFFF_FFFF_FFFF_FFFF, 0xFFFF_FFFF),
4873 (0xCAFE_F00D, 0x10203040),
4874 ] {
4875 let bytes = encode_request_grant(call_id, credits);
4876 assert_eq!(bytes.len(), 12, "request grant is always 12 bytes");
4877 let decoded = decode_request_grant(&bytes).expect("decode");
4878 assert_eq!(decoded.call_id, call_id);
4879 assert_eq!(decoded.credits, credits);
4880 }
4881 // Wrong-length payloads must reject (return None), not
4882 // panic. Empty, short, long, off-by-one each get covered.
4883 assert!(decode_request_grant(&[]).is_none());
4884 assert!(decode_request_grant(&[0u8; 11]).is_none());
4885 assert!(decode_request_grant(&[0u8; 13]).is_none());
4886 }
4887
4888 /// Bonus pin: `parse_request_window_initial` extracts a valid
4889 /// u32 ASCII-decimal header and rejects everything else.
4890 /// Same coverage shape as `parse_stream_window_initial`'s
4891 /// implicit contract, made explicit here so the request-side
4892 /// helper doesn't drift away from the response-side one.
4893 #[test]
4894 fn parse_request_window_initial_matches_response_side_semantics() {
4895 // Happy path.
4896 let headers = vec![header(HEADER_NRPC_REQUEST_WINDOW_INITIAL, b"32")];
4897 assert_eq!(parse_request_window_initial(&headers), Some(32));
4898 // Case-insensitive on header name.
4899 let headers = vec![header("Nrpc-Request-Window-Initial", b"7")];
4900 assert_eq!(parse_request_window_initial(&headers), Some(7));
4901 // Absent.
4902 assert_eq!(parse_request_window_initial(&[]), None);
4903 // Malformed value (non-numeric).
4904 let headers = vec![header(HEADER_NRPC_REQUEST_WINDOW_INITIAL, b"twelve")];
4905 assert_eq!(parse_request_window_initial(&headers), None);
4906 // Malformed value (non-utf8 bytes).
4907 let headers = vec![header(HEADER_NRPC_REQUEST_WINDOW_INITIAL, &[0xFF, 0xFE])];
4908 assert_eq!(parse_request_window_initial(&headers), None);
4909 // Empty value.
4910 let headers = vec![header(HEADER_NRPC_REQUEST_WINDOW_INITIAL, b"")];
4911 assert_eq!(parse_request_window_initial(&headers), None);
4912 }
4913
4914 // --------------------------------------------------------------------
4915 // RpcRequestPayload codec.
4916 // --------------------------------------------------------------------
4917
4918 #[test]
4919 fn request_roundtrip_minimal() {
4920 let p = RpcRequestPayload {
4921 service: "hello".to_string(),
4922 deadline_ns: 0,
4923 flags: 0,
4924 headers: vec![],
4925 body: Bytes::new(),
4926 };
4927 let bytes = p.encode();
4928 let decoded = RpcRequestPayload::decode(Bytes::from(bytes)).unwrap();
4929 assert_eq!(decoded, p);
4930 }
4931
4932 #[test]
4933 fn request_roundtrip_full() {
4934 let p = RpcRequestPayload {
4935 service: "echo.v1".to_string(),
4936 deadline_ns: 1_700_000_000_000_000_000,
4937 flags: FLAG_RPC_PROPAGATE_TRACE,
4938 headers: vec![
4939 header("traceparent", b"00-aabb..."),
4940 header("idempotency-key", &7u64.to_le_bytes()),
4941 header("content-type", b"application/json"),
4942 ],
4943 body: Bytes::from_static(b"{\"hello\":\"world\"}"),
4944 };
4945 let bytes = p.encode();
4946 let decoded = RpcRequestPayload::decode(Bytes::from(bytes)).unwrap();
4947 assert_eq!(decoded, p);
4948 }
4949
4950 #[test]
4951 fn request_decode_rejects_empty_service() {
4952 let bytes = vec![0x00];
4953 let err = RpcRequestPayload::decode(Bytes::from(bytes)).unwrap_err();
4954 assert!(matches!(err, RpcCodecError::Truncated(_)));
4955 }
4956
4957 #[test]
4958 fn request_decode_rejects_oversize_body_length() {
4959 // Forge: service "x", deadline 0, flags 0, no headers,
4960 // body length = MAX_RPC_BODY_LEN + 1 (no body bytes).
4961 let mut bytes = vec![1u8, b'x'];
4962 bytes.extend_from_slice(&0u64.to_le_bytes()); // deadline
4963 bytes.extend_from_slice(&0u16.to_le_bytes()); // flags
4964 bytes.push(0); // 0 headers
4965 bytes.extend_from_slice(&((MAX_RPC_BODY_LEN as u32) + 1).to_le_bytes());
4966 let err = RpcRequestPayload::decode(Bytes::from(bytes)).unwrap_err();
4967 assert!(
4968 matches!(err, RpcCodecError::TooLarge { field, .. } if field == "body"),
4969 "got {err:?}",
4970 );
4971 }
4972
4973 #[test]
4974 fn request_decode_rejects_oversize_headers_count() {
4975 // Forge: service "x", deadline 0, flags 0, headers count =
4976 // MAX_RPC_HEADERS + 1 (no header bytes).
4977 let mut bytes = vec![1u8, b'x'];
4978 bytes.extend_from_slice(&0u64.to_le_bytes());
4979 bytes.extend_from_slice(&0u16.to_le_bytes());
4980 bytes.push((MAX_RPC_HEADERS as u8).wrapping_add(1));
4981 let err = RpcRequestPayload::decode(Bytes::from(bytes)).unwrap_err();
4982 assert!(
4983 matches!(err, RpcCodecError::TooLarge { field, .. } if field == "headers"),
4984 "got {err:?}",
4985 );
4986 }
4987
4988 #[test]
4989 fn request_decode_rejects_truncated_at_each_field() {
4990 // Build a valid payload then truncate at each field
4991 // boundary; every truncation must error rather than silently
4992 // accept partial state.
4993 let p = RpcRequestPayload {
4994 service: "svc".to_string(),
4995 deadline_ns: 1,
4996 flags: 0,
4997 headers: vec![header("h", b"v")],
4998 body: Bytes::from_static(b"body"),
4999 };
5000 let bytes = p.encode();
5001 // Try each prefix length up to but not including the full
5002 // length — every one must be a decode error.
5003 for trim_to in 0..bytes.len() {
5004 let truncated = &bytes[..trim_to];
5005 let result = RpcRequestPayload::decode(Bytes::copy_from_slice(truncated));
5006 assert!(
5007 result.is_err(),
5008 "trim_to={trim_to} of {} must error, got {:?}",
5009 bytes.len(),
5010 result,
5011 );
5012 }
5013 // Full length must succeed.
5014 assert!(RpcRequestPayload::decode(Bytes::from(bytes)).is_ok());
5015 }
5016
5017 // --------------------------------------------------------------------
5018 // RpcResponsePayload codec.
5019 // --------------------------------------------------------------------
5020
5021 #[test]
5022 fn response_roundtrip_ok_with_body() {
5023 let p = RpcResponsePayload {
5024 status: RpcStatus::Ok,
5025 headers: vec![header("content-type", b"application/json")],
5026 body: Bytes::from_static(b"{\"answer\":42}"),
5027 };
5028 let bytes = p.encode();
5029 let decoded = RpcResponsePayload::decode(Bytes::from(bytes)).unwrap();
5030 assert_eq!(decoded, p);
5031 }
5032
5033 #[test]
5034 fn response_roundtrip_application_status() {
5035 let p = RpcResponsePayload {
5036 status: RpcStatus::Application(0xBEEF),
5037 headers: vec![],
5038 body: Bytes::from_static(b"app-specific diagnostic"),
5039 };
5040 let bytes = p.encode();
5041 let decoded = RpcResponsePayload::decode(Bytes::from(bytes)).unwrap();
5042 assert_eq!(decoded.status, RpcStatus::Application(0xBEEF));
5043 assert_eq!(decoded.body, p.body);
5044 }
5045
5046 #[test]
5047 fn response_decode_rejects_empty_buffer() {
5048 let err = RpcResponsePayload::decode(Bytes::new()).unwrap_err();
5049 assert!(matches!(err, RpcCodecError::Truncated(_)));
5050 }
5051
5052 // --------------------------------------------------------------------
5053 // Invariant: encoded sizes are reasonable.
5054 // --------------------------------------------------------------------
5055
5056 /// Wire-size budget regression: a tiny request encodes in a
5057 /// small constant number of bytes plus body. Pre-fix the headers
5058 /// or service-length encoding could have grown unbounded; pin
5059 /// the small-case so a regression in either inflates the
5060 /// minimum.
5061 #[test]
5062 fn request_minimum_wire_size_is_bounded() {
5063 let p = RpcRequestPayload {
5064 service: "x".to_string(),
5065 deadline_ns: 0,
5066 flags: 0,
5067 headers: vec![],
5068 body: Bytes::new(),
5069 };
5070 let size = p.encode().len();
5071 // 1 (svc len) + 1 (svc bytes) + 8 (deadline) + 2 (flags) + 1 (headers count) + 4 (body len) = 17
5072 assert_eq!(size, 17, "minimum request encodes in 17 bytes");
5073 assert_eq!(request_wire_size(&p), RPC_FRAME_BODY_OFFSET + 17);
5074 }
5075
5076 #[test]
5077 fn response_minimum_wire_size_is_bounded() {
5078 let p = RpcResponsePayload {
5079 status: RpcStatus::Ok,
5080 headers: vec![],
5081 body: Bytes::new(),
5082 };
5083 let size = p.encode().len();
5084 // 2 (status) + 1 (headers count) + 4 (body len) = 7
5085 assert_eq!(size, 7, "minimum response encodes in 7 bytes");
5086 assert_eq!(response_wire_size(&p), RPC_FRAME_BODY_OFFSET + 7);
5087 }
5088
5089 // ====================================================================
5090 // RpcServerFold — server-side dispatch behavior.
5091 //
5092 // These tests drive the fold directly with synthetic events
5093 // and observe the emitter callback. The end-to-end story
5094 // (Mesh::serve_rpc + bus + cortex adapter) is integration-
5095 // tested separately once the glue layer lands.
5096 // ====================================================================
5097
5098 use super::super::super::redex::{RedexEntry, RedexEvent};
5099 use std::sync::atomic::AtomicUsize;
5100 use std::time::Duration;
5101
5102 /// Captured-response store. Test-local typedef so the
5103 /// `capturing_emitter` signature stays under the `clippy::
5104 /// type_complexity` lint.
5105 type CapturedResponses = Arc<Mutex<Vec<(u64, u64, RpcResponsePayload)>>>;
5106
5107 /// Build a synthetic RedexEvent carrying an RPC request payload.
5108 /// Tests use this to drive the fold without going through the
5109 /// real ingest/cortex pipeline.
5110 fn rpc_request_event(
5111 caller_origin: u64,
5112 call_id: u64,
5113 payload: RpcRequestPayload,
5114 ) -> RedexEvent {
5115 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, caller_origin, call_id, 0);
5116 let mut buf = Vec::new();
5117 buf.extend_from_slice(&meta.to_bytes());
5118 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
5119 // feed the folds directly (no ingress select), and the folds
5120 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
5121 encode_rpc_route(&mut buf, 0);
5122 buf.extend_from_slice(&payload.encode());
5123 RedexEvent {
5124 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
5125 payload: bytes::Bytes::from(buf),
5126 }
5127 }
5128
5129 fn rpc_cancel_event(caller_origin: u64, call_id: u64) -> RedexEvent {
5130 let meta = EventMeta::new(DISPATCH_RPC_CANCEL, 0, caller_origin, call_id, 0);
5131 let mut buf = meta.to_bytes().to_vec();
5132 // OA2-E0.2: RpcRouteV1 route placeholder (folds skip it).
5133 encode_rpc_route(&mut buf, 0);
5134 RedexEvent {
5135 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
5136 payload: bytes::Bytes::from(buf),
5137 }
5138 }
5139
5140 /// AV-1 item 1: wrap a synthetic frame as an inbound event from
5141 /// the AEAD-authenticated session peer `from_node`, so a test can
5142 /// drive the production `apply_inbound` seam directly. The folds
5143 /// read the caller origin + call_id from the frame's `EventMeta`;
5144 /// `channel_hash` / `origin_hash` are ignored, so only `from_node`
5145 /// and `payload` are load-bearing for the call-identity key.
5146 fn inbound(from_node: u64, frame: bytes::Bytes) -> RpcInboundEvent {
5147 RpcInboundEvent {
5148 channel_hash: 0,
5149 origin_hash: 0,
5150 from_node,
5151 payload: frame,
5152 }
5153 }
5154
5155 /// Captures responses emitted by the fold for assertion in tests.
5156 fn capturing_emitter() -> (RpcResponseEmitter, CapturedResponses) {
5157 let captured: CapturedResponses = Arc::new(Mutex::new(Vec::new()));
5158 let captured_clone = captured.clone();
5159 let emit: RpcResponseEmitter = Arc::new(move |_from_node, origin, call_id, resp| {
5160 captured_clone.lock().push((origin, call_id, resp));
5161 });
5162 (emit, captured)
5163 }
5164
5165 /// A handler that just echoes the request body back as the
5166 /// response body, with `RpcStatus::Ok`.
5167 struct EchoHandler;
5168 #[async_trait::async_trait]
5169 impl RpcHandler for EchoHandler {
5170 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5171 Ok(RpcResponsePayload {
5172 status: RpcStatus::Ok,
5173 headers: vec![],
5174 body: ctx.payload.body,
5175 })
5176 }
5177 }
5178
5179 /// Wait until `pred` is true, polling at 10ms intervals up to
5180 /// `timeout`. Used to await spawned-handler completion in tests
5181 /// without a sleep-and-pray.
5182 async fn wait_until<F: Fn() -> bool>(pred: F, timeout: Duration) -> bool {
5183 let start = std::time::Instant::now();
5184 while start.elapsed() < timeout {
5185 if pred() {
5186 return true;
5187 }
5188 tokio::time::sleep(Duration::from_millis(10)).await;
5189 }
5190 pred()
5191 }
5192
5193 /// Happy path: a REQUEST event triggers the handler; the fold
5194 /// emits a RESPONSE with the handler's payload.
5195 #[tokio::test]
5196 async fn server_fold_request_invokes_handler_and_emits_response() {
5197 let (emit, captured) = capturing_emitter();
5198 let mut fold = RpcServerFold::new(Arc::new(EchoHandler), emit);
5199 let req = RpcRequestPayload {
5200 service: "echo".to_string(),
5201 deadline_ns: 0,
5202 flags: 0,
5203 headers: vec![],
5204 body: Bytes::from_static(b"hello"),
5205 };
5206 let ev = rpc_request_event(0xCAFE, 7, req);
5207 fold.apply(&ev, &mut ()).unwrap();
5208
5209 // Handler runs in tokio::spawn; wait for the emit.
5210 assert!(
5211 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
5212 "expected one emitted response"
5213 );
5214 let captured = captured.lock();
5215 assert_eq!(captured.len(), 1);
5216 let (origin, call_id, resp) = &captured[0];
5217 assert_eq!(*origin, 0xCAFE);
5218 assert_eq!(*call_id, 7);
5219 assert_eq!(resp.status, RpcStatus::Ok);
5220 assert_eq!(resp.body.as_ref(), b"hello");
5221 // In-flight set is cleaned up after the handler completes.
5222 assert!(fold.in_flight_keys().is_empty());
5223 }
5224
5225 /// E1.6: `apply_inbound_admitted` delivers the four-party `Admitted`
5226 /// to the handler via `RpcContext::org_admission` AND strips every
5227 /// `net-org-admission` proof header from the payload the handler
5228 /// sees, preserving the surrounding headers in order. (The public
5229 /// `apply_inbound` path leaves `org_admission` `None` — the other
5230 /// fold tests never set it, and their handlers keep their headers.)
5231 #[tokio::test]
5232 async fn admitted_request_delivers_attribution_and_strips_proof_header() {
5233 use crate::adapter::net::behavior::org::OrgKeypair;
5234 use crate::adapter::net::behavior::org_admission::Admitted;
5235 use crate::adapter::net::behavior::org_call::ORG_ADMISSION_HEADER;
5236 use crate::adapter::net::behavior::org_grant::CapabilityAuthorityId;
5237 use crate::adapter::net::identity::EntityId;
5238
5239 type Seen = Arc<Mutex<Option<(Option<Admitted>, Vec<RpcHeader>)>>>;
5240 struct SpyHandler(Seen);
5241 #[async_trait::async_trait]
5242 impl RpcHandler for SpyHandler {
5243 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5244 *self.0.lock() = Some((ctx.org_admission.clone(), ctx.payload.headers.clone()));
5245 Ok(RpcResponsePayload {
5246 status: RpcStatus::Ok,
5247 headers: vec![],
5248 body: Bytes::new(),
5249 })
5250 }
5251 }
5252
5253 let seen: Seen = Arc::new(Mutex::new(None));
5254 let (emit, _captured) = capturing_emitter();
5255 let mut fold = RpcServerFold::new(Arc::new(SpyHandler(seen.clone())), emit);
5256
5257 let admitted = Admitted {
5258 caller: EntityId::from_bytes([0x24u8; 32]),
5259 acting_org: OrgKeypair::from_bytes([0x77u8; 32]).org_id(),
5260 provider_org: OrgKeypair::from_bytes([0x42u8; 32]).org_id(),
5261 provider: EntityId::from_bytes([0x99u8; 32]),
5262 capability: CapabilityAuthorityId::for_tag("nrpc:oa2-echo"),
5263 };
5264 let req = RpcRequestPayload {
5265 service: "oa2-echo".to_string(),
5266 deadline_ns: 0,
5267 flags: 0,
5268 headers: vec![
5269 ("x-keep".to_string(), b"1".to_vec()),
5270 (ORG_ADMISSION_HEADER.to_string(), b"opaque-proof".to_vec()),
5271 ("y-keep".to_string(), b"2".to_vec()),
5272 ],
5273 body: Bytes::from_static(b"hi"),
5274 };
5275 let frame = rpc_request_event(0xCAFE, 7, req).payload;
5276 fold.apply_inbound_admitted(&inbound(0x61, frame), admitted.clone())
5277 .unwrap();
5278
5279 assert!(
5280 wait_until(|| seen.lock().is_some(), Duration::from_secs(2)).await,
5281 "handler must run for an admitted request",
5282 );
5283 let (got_admission, got_headers) = seen.lock().clone().unwrap();
5284 assert_eq!(
5285 got_admission,
5286 Some(admitted),
5287 "the four-party Admitted must reach the handler",
5288 );
5289 assert_eq!(
5290 got_headers,
5291 vec![
5292 ("x-keep".to_string(), b"1".to_vec()),
5293 ("y-keep".to_string(), b"2".to_vec()),
5294 ],
5295 "the proof header is stripped; surrounding headers stay, in order",
5296 );
5297 }
5298
5299 /// Application error: handler returns
5300 /// `RpcHandlerError::Application` → fold emits a response with
5301 /// `RpcStatus::Application(code)` and the message as body.
5302 #[tokio::test]
5303 async fn server_fold_application_error_maps_to_application_status() {
5304 struct AppErrHandler;
5305 #[async_trait::async_trait]
5306 impl RpcHandler for AppErrHandler {
5307 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5308 Err(RpcHandlerError::Application {
5309 code: 0xBEEF,
5310 message: "bad input".to_string(),
5311 })
5312 }
5313 }
5314 let (emit, captured) = capturing_emitter();
5315 let mut fold = RpcServerFold::new(Arc::new(AppErrHandler), emit);
5316 let req = RpcRequestPayload {
5317 service: "x".to_string(),
5318 deadline_ns: 0,
5319 flags: 0,
5320 headers: vec![],
5321 body: Bytes::new(),
5322 };
5323 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
5324 assert!(wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await);
5325 let captured = captured.lock();
5326 let (_, _, resp) = &captured[0];
5327 assert_eq!(resp.status, RpcStatus::Application(0xBEEF));
5328 assert_eq!(resp.body.as_ref(), b"bad input");
5329 }
5330
5331 /// Internal error: handler returns `RpcHandlerError::Internal`
5332 /// → fold emits `RpcStatus::Internal` with the message body.
5333 #[tokio::test]
5334 async fn server_fold_internal_error_maps_to_internal_status() {
5335 struct IntErrHandler;
5336 #[async_trait::async_trait]
5337 impl RpcHandler for IntErrHandler {
5338 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5339 Err(RpcHandlerError::Internal("db timeout".to_string()))
5340 }
5341 }
5342 let (emit, captured) = capturing_emitter();
5343 let mut fold = RpcServerFold::new(Arc::new(IntErrHandler), emit);
5344 let req = RpcRequestPayload {
5345 service: "x".to_string(),
5346 deadline_ns: 0,
5347 flags: 0,
5348 headers: vec![],
5349 body: Bytes::new(),
5350 };
5351 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
5352 assert!(wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await);
5353 let captured = captured.lock();
5354 let (_, _, resp) = &captured[0];
5355 assert_eq!(resp.status, RpcStatus::Internal);
5356 assert_eq!(resp.body.as_ref(), b"db timeout");
5357 }
5358
5359 /// Handler panic: caught by the fold's `catch_unwind`; surfaces
5360 /// as `RpcStatus::Internal` to the caller. Pre-fix the panic
5361 /// would propagate up the spawned task, log a tokio
5362 /// uncaught-panic message, and silently leave the caller
5363 /// waiting forever.
5364 #[tokio::test]
5365 async fn server_fold_handler_panic_surfaces_as_internal_status() {
5366 struct PanicHandler;
5367 #[async_trait::async_trait]
5368 impl RpcHandler for PanicHandler {
5369 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5370 panic!("kaboom");
5371 }
5372 }
5373 let (emit, captured) = capturing_emitter();
5374 let mut fold = RpcServerFold::new(Arc::new(PanicHandler), emit);
5375 let req = RpcRequestPayload {
5376 service: "x".to_string(),
5377 deadline_ns: 0,
5378 flags: 0,
5379 headers: vec![],
5380 body: Bytes::new(),
5381 };
5382 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
5383 assert!(wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await);
5384 let captured = captured.lock();
5385 let (_, _, resp) = &captured[0];
5386 assert_eq!(resp.status, RpcStatus::Internal);
5387 assert!(
5388 String::from_utf8_lossy(&resp.body).contains("kaboom"),
5389 "panic message must surface in body, got {}",
5390 String::from_utf8_lossy(&resp.body),
5391 );
5392 }
5393
5394 /// Deadline already passed: server short-circuits with
5395 /// `Timeout` without invoking the handler. Pinned via the
5396 /// `with_test_now_ns` clock override so the test doesn't race
5397 /// wall time.
5398 #[tokio::test]
5399 async fn server_fold_deadline_already_passed_short_circuits_to_timeout() {
5400 let invoked = Arc::new(AtomicBool::new(false));
5401 struct CountingHandler {
5402 invoked: Arc<AtomicBool>,
5403 }
5404 #[async_trait::async_trait]
5405 impl RpcHandler for CountingHandler {
5406 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5407 self.invoked.store(true, Ordering::Release);
5408 Ok(RpcResponsePayload {
5409 status: RpcStatus::Ok,
5410 headers: vec![],
5411 body: Bytes::new(),
5412 })
5413 }
5414 }
5415 let (emit, captured) = capturing_emitter();
5416 let mut fold = RpcServerFold::new(
5417 Arc::new(CountingHandler {
5418 invoked: invoked.clone(),
5419 }),
5420 emit,
5421 )
5422 // Use a clock value > DEADLINE_SKEW_TOLERANCE_NS + 1
5423 // (10s + 1ns) so the deadline-passed check fires past the
5424 // skew tolerance window. With now=20s and deadline=1ns,
5425 // (now - 10s) > 1ns.
5426 .with_test_now_ns(20_000_000_000);
5427 let req = RpcRequestPayload {
5428 service: "x".to_string(),
5429 // Deadline well in the past — past the skew tolerance.
5430 deadline_ns: 1_000,
5431 flags: 0,
5432 headers: vec![],
5433 body: Bytes::new(),
5434 };
5435 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
5436 // Emit happens synchronously in the deadline-passed branch
5437 // (no handler spawn).
5438 let captured = captured.lock();
5439 assert_eq!(captured.len(), 1);
5440 let (_, _, resp) = &captured[0];
5441 assert_eq!(resp.status, RpcStatus::Timeout);
5442 assert!(
5443 !invoked.load(Ordering::Acquire),
5444 "handler must NOT be invoked when deadline already passed",
5445 );
5446 }
5447
5448 /// Regression: a deadline that has elapsed by less than
5449 /// `DEADLINE_SKEW_TOLERANCE_NS` does NOT short-circuit. A
5450 /// peer with a slightly-fast clock would otherwise be
5451 /// prematurely timed out before the handler ever ran.
5452 #[tokio::test]
5453 async fn server_fold_deadline_within_skew_tolerance_invokes_handler() {
5454 let invoked = Arc::new(AtomicBool::new(false));
5455 struct CountingHandler {
5456 invoked: Arc<AtomicBool>,
5457 }
5458 #[async_trait::async_trait]
5459 impl RpcHandler for CountingHandler {
5460 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5461 self.invoked.store(true, Ordering::Release);
5462 Ok(RpcResponsePayload {
5463 status: RpcStatus::Ok,
5464 headers: vec![],
5465 body: Bytes::new(),
5466 })
5467 }
5468 }
5469 let (emit, captured) = capturing_emitter();
5470 let mut fold = RpcServerFold::new(
5471 Arc::new(CountingHandler {
5472 invoked: invoked.clone(),
5473 }),
5474 emit,
5475 )
5476 // now = 100s, deadline = 95s → elapsed = 5s, within the
5477 // 10s skew tolerance.
5478 .with_test_now_ns(100_000_000_000);
5479 let req = RpcRequestPayload {
5480 service: "x".to_string(),
5481 deadline_ns: 95_000_000_000,
5482 flags: 0,
5483 headers: vec![],
5484 body: Bytes::new(),
5485 };
5486 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
5487 assert!(
5488 wait_until(|| invoked.load(Ordering::Acquire), Duration::from_secs(1)).await,
5489 "handler must run when deadline is within skew tolerance",
5490 );
5491 let captured = captured.lock();
5492 assert_eq!(captured.len(), 1);
5493 assert_eq!(captured[0].2.status, RpcStatus::Ok);
5494 }
5495
5496 /// CANCEL flips the matching in-flight token. The handler that
5497 /// `select!`s on the cancellation observes the signal and can
5498 /// short-circuit. The fold removes the in-flight entry on
5499 /// CANCEL.
5500 #[tokio::test]
5501 async fn server_fold_cancel_flips_token_and_clears_in_flight() {
5502 let resumed_after_cancel = Arc::new(AtomicBool::new(false));
5503 struct CancelObservingHandler {
5504 resumed: Arc<AtomicBool>,
5505 }
5506 #[async_trait::async_trait]
5507 impl RpcHandler for CancelObservingHandler {
5508 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5509 tokio::select! {
5510 _ = ctx.cancellation.cancelled() => {
5511 self.resumed.store(true, Ordering::Release);
5512 Err(RpcHandlerError::Internal("cancelled by caller".to_string()))
5513 }
5514 _ = tokio::time::sleep(Duration::from_secs(5)) => {
5515 Ok(RpcResponsePayload {
5516 status: RpcStatus::Ok,
5517 headers: vec![],
5518 body: Bytes::from_static(b"slept the full window"),
5519 })
5520 }
5521 }
5522 }
5523 }
5524 let (emit, captured) = capturing_emitter();
5525 let mut fold = RpcServerFold::new(
5526 Arc::new(CancelObservingHandler {
5527 resumed: resumed_after_cancel.clone(),
5528 }),
5529 emit,
5530 );
5531 let req = RpcRequestPayload {
5532 service: "x".to_string(),
5533 deadline_ns: 0,
5534 flags: 0,
5535 headers: vec![],
5536 body: Bytes::new(),
5537 };
5538 fold.apply(&rpc_request_event(1, 42, req), &mut ()).unwrap();
5539 // Wait until the handler's `select!` is parked; then send
5540 // CANCEL.
5541 assert!(
5542 wait_until(
5543 || fold.in_flight_keys().contains(&(0, 1, 42)),
5544 Duration::from_secs(1)
5545 )
5546 .await
5547 );
5548 fold.apply(&rpc_cancel_event(1, 42), &mut ()).unwrap();
5549 // The cancellation is observed by the handler. Even though
5550 // the handler returns `Internal("cancelled by caller")`,
5551 // the fold's CANCEL-wins ordering overrides the response
5552 // with `RpcStatus::Cancelled` so the caller sees the
5553 // documented status code rather than the handler's
5554 // accidental Internal payload.
5555 assert!(
5556 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
5557 "handler should observe cancellation and emit response"
5558 );
5559 assert!(
5560 resumed_after_cancel.load(Ordering::Acquire),
5561 "handler must observe cancellation"
5562 );
5563 let captured = captured.lock();
5564 assert_eq!(captured.len(), 1);
5565 let (_, _, resp) = &captured[0];
5566 assert_eq!(
5567 resp.status,
5568 RpcStatus::Cancelled,
5569 "CANCEL must override handler outcome with RpcStatus::Cancelled"
5570 );
5571 // CANCEL also removes the in-flight entry directly.
5572 // Handler completion removes it again (idempotent).
5573 assert!(fold.in_flight_keys().is_empty());
5574 }
5575
5576 // ====================================================================
5577 // AV-1 item 1 — server-fold call/control identity is bound to the
5578 // AEAD-authenticated session peer `from_node`. Each witness drives
5579 // the production `apply_inbound` seam with a victim frame on one
5580 // session and an adversarial control frame that copies the victim's
5581 // origin + call_id but arrives on a DIFFERENT session. The forged
5582 // frame must miss the `(from_node, origin, call_id)` key and leave
5583 // the victim's call/control state untouched.
5584 // ====================================================================
5585
5586 /// CANCEL hijack (unary). An attacker that copies the victim's
5587 /// origin + call_id onto a CANCEL, but sends it on its own session,
5588 /// must not cancel the victim's in-flight call. Only a CANCEL from
5589 /// the victim's own session cancels it.
5590 #[tokio::test]
5591 async fn unary_fold_foreign_session_cancel_cannot_hijack_a_call() {
5592 const VICTIM: u64 = 0xA;
5593 const ATTACKER: u64 = 0xB;
5594 const ORIGIN: u64 = 0x1111;
5595 const CALL_ID: u64 = 42;
5596 let resumed = Arc::new(AtomicBool::new(false));
5597 struct H {
5598 resumed: Arc<AtomicBool>,
5599 }
5600 #[async_trait::async_trait]
5601 impl RpcHandler for H {
5602 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5603 tokio::select! {
5604 _ = ctx.cancellation.cancelled() => {
5605 self.resumed.store(true, Ordering::Release);
5606 Err(RpcHandlerError::Internal("cancelled".to_string()))
5607 }
5608 _ = tokio::time::sleep(Duration::from_secs(5)) => Ok(RpcResponsePayload {
5609 status: RpcStatus::Ok,
5610 headers: vec![],
5611 body: Bytes::from_static(b"slept"),
5612 }),
5613 }
5614 }
5615 }
5616 let (emit, captured) = capturing_emitter();
5617 let mut fold = RpcServerFold::new(
5618 Arc::new(H {
5619 resumed: resumed.clone(),
5620 }),
5621 emit,
5622 );
5623 let req = RpcRequestPayload {
5624 service: "x".to_string(),
5625 deadline_ns: 0,
5626 flags: 0,
5627 headers: vec![],
5628 body: Bytes::new(),
5629 };
5630 // Victim's REQUEST on the victim's session.
5631 fold.apply_inbound(&inbound(
5632 VICTIM,
5633 rpc_request_event(ORIGIN, CALL_ID, req).payload,
5634 ))
5635 .unwrap();
5636 assert!(
5637 wait_until(
5638 || fold.in_flight_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5639 Duration::from_secs(1)
5640 )
5641 .await
5642 );
5643 // Attacker's forged CANCEL on a DIFFERENT session — must miss.
5644 fold.apply_inbound(&inbound(
5645 ATTACKER,
5646 rpc_cancel_event(ORIGIN, CALL_ID).payload,
5647 ))
5648 .unwrap();
5649 tokio::time::sleep(Duration::from_millis(100)).await;
5650 assert!(
5651 fold.in_flight_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5652 "forged CANCEL from a foreign session must not remove the victim's entry",
5653 );
5654 assert!(
5655 !resumed.load(Ordering::Acquire),
5656 "victim handler must not observe the forged CANCEL",
5657 );
5658 assert!(
5659 captured.lock().is_empty(),
5660 "a hijacked CANCEL must not produce a terminal response",
5661 );
5662 // The victim's OWN CANCEL cancels the call.
5663 fold.apply_inbound(&inbound(VICTIM, rpc_cancel_event(ORIGIN, CALL_ID).payload))
5664 .unwrap();
5665 assert!(
5666 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
5667 "the victim's own CANCEL must cancel the call",
5668 );
5669 assert!(resumed.load(Ordering::Acquire));
5670 assert_eq!(captured.lock()[0].2.status, RpcStatus::Cancelled);
5671 }
5672
5673 /// STREAM_GRANT + CANCEL hijack (server-streaming). A forged
5674 /// STREAM_GRANT from a foreign session must not refill the victim's
5675 /// flow-control window, and a forged CANCEL must not tear the call
5676 /// down.
5677 #[tokio::test]
5678 async fn streaming_fold_foreign_session_cannot_refill_or_cancel() {
5679 const VICTIM: u64 = 0xA;
5680 const ATTACKER: u64 = 0xB;
5681 const ORIGIN: u64 = 0x2222;
5682 const CALL_ID: u64 = 7;
5683 let release = Arc::new(Notify::new());
5684 struct Blocking {
5685 release: Arc<Notify>,
5686 }
5687 #[async_trait::async_trait]
5688 impl RpcStreamingHandler for Blocking {
5689 async fn call(
5690 &self,
5691 _ctx: RpcContext,
5692 _sink: RpcResponseSink,
5693 ) -> Result<(), RpcHandlerError> {
5694 // Never emit a chunk (so the pump consumes no permits);
5695 // hold the call open until released.
5696 self.release.notified().await;
5697 Ok(())
5698 }
5699 }
5700 let (emit, _captured) = capturing_async_emitter();
5701 let mut fold = RpcServerStreamingFold::new(
5702 Arc::new(Blocking {
5703 release: release.clone(),
5704 }),
5705 emit,
5706 );
5707 // Victim REQUEST opting into flow control with an initial
5708 // window of 2.
5709 let req = RpcRequestPayload {
5710 service: "s".to_string(),
5711 deadline_ns: 0,
5712 flags: 0,
5713 headers: vec![(HEADER_NRPC_STREAM_WINDOW_INITIAL.to_string(), b"2".to_vec())],
5714 body: Bytes::new(),
5715 };
5716 fold.apply_inbound(&inbound(
5717 VICTIM,
5718 rpc_request_event(ORIGIN, CALL_ID, req).payload,
5719 ))
5720 .unwrap();
5721 assert!(
5722 wait_until(
5723 || fold.in_flight_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5724 Duration::from_secs(1)
5725 )
5726 .await
5727 );
5728 assert_eq!(
5729 fold.flow_control_permits((VICTIM, ORIGIN, CALL_ID)),
5730 Some(2),
5731 "victim's initial window",
5732 );
5733 // Attacker STREAM_GRANT(5) on its own session — must miss.
5734 fold.apply_inbound(&inbound(
5735 ATTACKER,
5736 rpc_stream_grant_event(ORIGIN, CALL_ID, 5).payload,
5737 ))
5738 .unwrap();
5739 assert_eq!(
5740 fold.flow_control_permits((VICTIM, ORIGIN, CALL_ID)),
5741 Some(2),
5742 "a forged STREAM_GRANT from a foreign session must not refill the victim's window",
5743 );
5744 // Attacker CANCEL on its own session — must miss.
5745 fold.apply_inbound(&inbound(
5746 ATTACKER,
5747 rpc_cancel_event(ORIGIN, CALL_ID).payload,
5748 ))
5749 .unwrap();
5750 tokio::time::sleep(Duration::from_millis(100)).await;
5751 assert!(
5752 fold.in_flight_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5753 "a forged CANCEL from a foreign session must not tear down the victim's stream",
5754 );
5755 // The victim's own STREAM_GRANT(5) DOES refill.
5756 fold.apply_inbound(&inbound(
5757 VICTIM,
5758 rpc_stream_grant_event(ORIGIN, CALL_ID, 5).payload,
5759 ))
5760 .unwrap();
5761 assert_eq!(
5762 fold.flow_control_permits((VICTIM, ORIGIN, CALL_ID)),
5763 Some(7),
5764 "the victim's own STREAM_GRANT must refill its window",
5765 );
5766 release.notify_one();
5767 }
5768
5769 /// REQUEST_CHUNK + CANCEL hijack (client-streaming). A forged
5770 /// REQUEST_CHUNK from a foreign session must not enter the victim's
5771 /// upload stream, and a forged CANCEL must not close it.
5772 #[tokio::test]
5773 async fn client_streaming_fold_foreign_session_cannot_feed_or_cancel() {
5774 const VICTIM: u64 = 0xA;
5775 const ATTACKER: u64 = 0xB;
5776 const ORIGIN: u64 = 0xCAFE;
5777 const CALL_ID: u64 = 7;
5778 let seen = Arc::new(Mutex::new(Vec::new()));
5779 let observed_cancel = Arc::new(AtomicBool::new(false));
5780 let (emit, captured) = capturing_emitter();
5781 let mut fold = RpcStreamingRequestFold::new(
5782 Arc::new(CollectingClientStreamHandler {
5783 seen: seen.clone(),
5784 observed_cancel: observed_cancel.clone(),
5785 }),
5786 emit,
5787 );
5788 // Victim REQUEST (client-streaming flag), initial body "a".
5789 let req = RpcRequestPayload {
5790 service: "agg".to_string(),
5791 deadline_ns: 0,
5792 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST,
5793 headers: vec![],
5794 body: Bytes::from_static(b"a"),
5795 };
5796 fold.apply_inbound(&inbound(
5797 VICTIM,
5798 rpc_request_event(ORIGIN, CALL_ID, req).payload,
5799 ))
5800 .unwrap();
5801 assert!(
5802 wait_until(
5803 || fold.sender_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5804 Duration::from_secs(1)
5805 )
5806 .await
5807 );
5808 // Attacker forges a REQUEST_CHUNK and a CANCEL with the victim's
5809 // origin + call_id, both on its OWN session — both must miss.
5810 fold.apply_inbound(&inbound(
5811 ATTACKER,
5812 rpc_request_chunk_event(ORIGIN, CALL_ID, 0, b"ATTACK".to_vec()).payload,
5813 ))
5814 .unwrap();
5815 fold.apply_inbound(&inbound(
5816 ATTACKER,
5817 rpc_cancel_event(ORIGIN, CALL_ID).payload,
5818 ))
5819 .unwrap();
5820 tokio::time::sleep(Duration::from_millis(100)).await;
5821 assert!(
5822 fold.sender_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5823 "forged frames must not close the victim's upload stream",
5824 );
5825 // Victim feeds a legit chunk and ends its own stream.
5826 fold.apply_inbound(&inbound(
5827 VICTIM,
5828 rpc_request_chunk_event(ORIGIN, CALL_ID, 0, b"b".to_vec()).payload,
5829 ))
5830 .unwrap();
5831 fold.apply_inbound(&inbound(
5832 VICTIM,
5833 rpc_request_chunk_event(ORIGIN, CALL_ID, FLAG_RPC_REQUEST_END, b"c".to_vec()).payload,
5834 ))
5835 .unwrap();
5836 assert!(
5837 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
5838 "expected terminal RESPONSE",
5839 );
5840 let bodies: Vec<Vec<u8>> = seen.lock().iter().map(|b| b.to_vec()).collect();
5841 assert_eq!(
5842 bodies,
5843 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()],
5844 "the attacker's forged chunk must never enter the victim's stream",
5845 );
5846 assert!(
5847 !observed_cancel.load(Ordering::SeqCst),
5848 "the attacker's forged CANCEL must not cancel the victim's stream",
5849 );
5850 }
5851
5852 /// REQUEST_CHUNK + CANCEL hijack (duplex). Same contract as the
5853 /// client-streaming witness, on the duplex fold.
5854 #[tokio::test]
5855 async fn duplex_fold_foreign_session_cannot_feed_or_cancel() {
5856 const VICTIM: u64 = 0xA;
5857 const ATTACKER: u64 = 0xB;
5858 const ORIGIN: u64 = 0xBEEF;
5859 const CALL_ID: u64 = 9;
5860 let seen = Arc::new(Mutex::new(Vec::new()));
5861 let observed_cancel = Arc::new(AtomicBool::new(false));
5862 struct DuplexCollect {
5863 seen: Arc<Mutex<Vec<bytes::Bytes>>>,
5864 observed_cancel: Arc<AtomicBool>,
5865 }
5866 #[async_trait::async_trait]
5867 impl RpcDuplexHandler for DuplexCollect {
5868 async fn call(
5869 &self,
5870 ctx: RpcStreamingContext,
5871 mut requests: RequestStream,
5872 _responses: RpcResponseSink,
5873 ) -> Result<(), RpcHandlerError> {
5874 use futures::StreamExt;
5875 while let Some(chunk) = requests.next().await {
5876 self.seen.lock().push(chunk);
5877 }
5878 if ctx.cancellation.is_cancelled() {
5879 self.observed_cancel.store(true, Ordering::SeqCst);
5880 }
5881 Ok(())
5882 }
5883 }
5884 let (emit, _captured) = capturing_async_emitter();
5885 let mut fold = RpcDuplexFold::new(
5886 Arc::new(DuplexCollect {
5887 seen: seen.clone(),
5888 observed_cancel: observed_cancel.clone(),
5889 }),
5890 emit,
5891 );
5892 let req = RpcRequestPayload {
5893 service: "dx".to_string(),
5894 deadline_ns: 0,
5895 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_STREAMING_RESPONSE,
5896 headers: vec![],
5897 body: Bytes::from_static(b"a"),
5898 };
5899 fold.apply_inbound(&inbound(
5900 VICTIM,
5901 rpc_request_event(ORIGIN, CALL_ID, req).payload,
5902 ))
5903 .unwrap();
5904 assert!(
5905 wait_until(
5906 || fold.sender_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5907 Duration::from_secs(1)
5908 )
5909 .await
5910 );
5911 // Attacker forges a chunk + cancel on its own session — miss.
5912 fold.apply_inbound(&inbound(
5913 ATTACKER,
5914 rpc_request_chunk_event(ORIGIN, CALL_ID, 0, b"ATTACK".to_vec()).payload,
5915 ))
5916 .unwrap();
5917 fold.apply_inbound(&inbound(
5918 ATTACKER,
5919 rpc_cancel_event(ORIGIN, CALL_ID).payload,
5920 ))
5921 .unwrap();
5922 tokio::time::sleep(Duration::from_millis(100)).await;
5923 assert!(
5924 fold.sender_keys().contains(&(VICTIM, ORIGIN, CALL_ID)),
5925 "forged frames must not close the victim's duplex upload stream",
5926 );
5927 fold.apply_inbound(&inbound(
5928 VICTIM,
5929 rpc_request_chunk_event(ORIGIN, CALL_ID, 0, b"b".to_vec()).payload,
5930 ))
5931 .unwrap();
5932 fold.apply_inbound(&inbound(
5933 VICTIM,
5934 rpc_request_chunk_event(ORIGIN, CALL_ID, FLAG_RPC_REQUEST_END, b"c".to_vec()).payload,
5935 ))
5936 .unwrap();
5937 assert!(
5938 wait_until(
5939 || !seen.lock().is_empty() && seen.lock().len() >= 3,
5940 Duration::from_secs(2)
5941 )
5942 .await,
5943 "victim's chunks must reach the handler",
5944 );
5945 let bodies: Vec<Vec<u8>> = seen.lock().iter().map(|b| b.to_vec()).collect();
5946 assert_eq!(
5947 bodies,
5948 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()],
5949 "the attacker's forged chunk must never enter the victim's duplex stream",
5950 );
5951 assert!(
5952 !observed_cancel.load(Ordering::SeqCst),
5953 "the attacker's forged CANCEL must not cancel the victim's duplex stream",
5954 );
5955 }
5956
5957 /// Regression: a duplicate REQUEST for an already-in-flight
5958 /// `(origin_hash, call_id)` must be refused with a synthetic
5959 /// `Internal` response and must NOT spawn a second handler.
5960 /// Without the refusal, two handlers race under the same key
5961 /// and CANCEL handling is broken (CANCEL removes the entry
5962 /// the first handler reinserts, etc.).
5963 #[tokio::test]
5964 async fn server_fold_duplicate_request_refuses_without_double_dispatch() {
5965 let invocations = Arc::new(AtomicUsize::new(0));
5966 struct CountingHandler {
5967 invocations: Arc<AtomicUsize>,
5968 }
5969 #[async_trait::async_trait]
5970 impl RpcHandler for CountingHandler {
5971 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
5972 self.invocations.fetch_add(1, Ordering::SeqCst);
5973 tokio::time::sleep(Duration::from_millis(80)).await;
5974 Ok(RpcResponsePayload {
5975 status: RpcStatus::Ok,
5976 headers: vec![],
5977 body: Bytes::from_static(b"done"),
5978 })
5979 }
5980 }
5981 let (emit, captured) = capturing_emitter();
5982 let mut fold = RpcServerFold::new(
5983 Arc::new(CountingHandler {
5984 invocations: invocations.clone(),
5985 }),
5986 emit,
5987 );
5988 let req = RpcRequestPayload {
5989 service: "x".to_string(),
5990 deadline_ns: 0,
5991 flags: 0,
5992 headers: vec![],
5993 body: Bytes::new(),
5994 };
5995 // First REQUEST — handler spawns and parks in sleep.
5996 fold.apply(&rpc_request_event(1, 99, req.clone()), &mut ())
5997 .unwrap();
5998 assert!(
5999 wait_until(
6000 || fold.in_flight_keys().contains(&(0, 1, 99)),
6001 Duration::from_secs(1)
6002 )
6003 .await
6004 );
6005 // Second REQUEST with same key — must be refused
6006 // synchronously with a synthetic Internal response.
6007 fold.apply(&rpc_request_event(1, 99, req), &mut ()).unwrap();
6008 // The refusal emit happens synchronously in the fold's
6009 // sync emitter path.
6010 let after_dup = captured.lock().clone();
6011 assert_eq!(
6012 after_dup.len(),
6013 1,
6014 "duplicate REQUEST must emit exactly one synthetic refusal",
6015 );
6016 assert_eq!(after_dup[0].2.status, RpcStatus::Internal);
6017 assert!(String::from_utf8_lossy(&after_dup[0].2.body).contains("duplicate"));
6018 // Wait for the first handler to complete.
6019 assert!(
6020 wait_until(|| captured.lock().len() == 2, Duration::from_secs(2)).await,
6021 "first handler should still complete normally"
6022 );
6023 let captured = captured.lock();
6024 assert_eq!(captured.len(), 2);
6025 // The first handler's response is the second emit (Ok).
6026 assert_eq!(captured[1].2.status, RpcStatus::Ok);
6027 assert_eq!(
6028 invocations.load(Ordering::SeqCst),
6029 1,
6030 "duplicate REQUEST must NOT spawn a second handler",
6031 );
6032 }
6033
6034 /// Regression: a CANCEL that fires while the handler is mid-
6035 /// flight must override the handler's outcome with
6036 /// `RpcStatus::Cancelled` even when the handler ignores
6037 /// cancellation and returns `Ok(...)`. Without this, a caller
6038 /// who cancelled would see the handler's accidental success
6039 /// payload and could not tell whether their CANCEL won.
6040 #[tokio::test]
6041 async fn server_fold_cancel_overrides_handler_ok_with_cancelled_status() {
6042 struct IgnoresCancellation;
6043 #[async_trait::async_trait]
6044 impl RpcHandler for IgnoresCancellation {
6045 async fn call(&self, _ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
6046 // Sleep long enough for the test to send CANCEL,
6047 // then return Ok regardless. This models a handler
6048 // that doesn't `select!` on `ctx.cancellation`.
6049 tokio::time::sleep(Duration::from_millis(80)).await;
6050 Ok(RpcResponsePayload {
6051 status: RpcStatus::Ok,
6052 headers: vec![],
6053 body: Bytes::from_static(b"finished despite cancellation"),
6054 })
6055 }
6056 }
6057 let (emit, captured) = capturing_emitter();
6058 let mut fold = RpcServerFold::new(Arc::new(IgnoresCancellation), emit);
6059 let req = RpcRequestPayload {
6060 service: "x".to_string(),
6061 deadline_ns: 0,
6062 flags: 0,
6063 headers: vec![],
6064 body: Bytes::new(),
6065 };
6066 fold.apply(&rpc_request_event(7, 11, req), &mut ()).unwrap();
6067 // Wait until the handler is parked, then send CANCEL well
6068 // before the handler's sleep elapses.
6069 assert!(
6070 wait_until(
6071 || fold.in_flight_keys().contains(&(0, 7, 11)),
6072 Duration::from_secs(1)
6073 )
6074 .await
6075 );
6076 fold.apply(&rpc_cancel_event(7, 11), &mut ()).unwrap();
6077 assert!(
6078 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
6079 "handler should complete and emit response"
6080 );
6081 let captured = captured.lock();
6082 assert_eq!(captured.len(), 1);
6083 let (_, _, resp) = &captured[0];
6084 assert_eq!(
6085 resp.status,
6086 RpcStatus::Cancelled,
6087 "handler that returned Ok despite CANCEL must surface as Cancelled"
6088 );
6089 assert!(fold.in_flight_keys().is_empty());
6090 }
6091
6092 /// CANCEL for an unknown call_id is a no-op (no panic, no
6093 /// stray emission). This is the case where a CANCEL races a
6094 /// handler completion or a duplicate CANCEL arrives.
6095 #[tokio::test]
6096 async fn server_fold_cancel_for_unknown_call_id_is_no_op() {
6097 let (emit, captured) = capturing_emitter();
6098 let mut fold = RpcServerFold::new(Arc::new(EchoHandler), emit);
6099 // CANCEL with no matching REQUEST.
6100 fold.apply(&rpc_cancel_event(1, 999), &mut ()).unwrap();
6101 assert!(captured.lock().is_empty());
6102 assert!(fold.in_flight_keys().is_empty());
6103 }
6104
6105 /// Malformed request payload: fold emits a
6106 /// `RpcStatus::UnknownVersion` response and continues. A
6107 /// regression that returned `Err` here would kill the cortex
6108 /// adapter's tail-and-fold task on the first malformed event,
6109 /// which is the wrong behavior for an RPC server that needs
6110 /// to keep serving past garbage.
6111 #[tokio::test]
6112 async fn server_fold_malformed_payload_emits_unknown_version_and_keeps_going() {
6113 let (emit, captured) = capturing_emitter();
6114 let mut fold = RpcServerFold::new(Arc::new(EchoHandler), emit);
6115 // Build an event with valid meta but a garbage tail (just
6116 // a single 0x00 byte, which fails the service-len check).
6117 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, 7, 1, 0);
6118 let mut buf = Vec::new();
6119 buf.extend_from_slice(&meta.to_bytes());
6120 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6121 // feed the folds directly (no ingress select), and the folds
6122 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6123 encode_rpc_route(&mut buf, 0);
6124 buf.push(0x00); // svc_len = 0 → empty service → Truncated
6125 let ev = RedexEvent {
6126 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6127 payload: bytes::Bytes::from(buf),
6128 };
6129 let result = fold.apply(&ev, &mut ());
6130 assert!(
6131 result.is_ok(),
6132 "fold must NOT return Err on malformed payload (would kill the adapter); got {result:?}"
6133 );
6134 let captured = captured.lock();
6135 assert_eq!(captured.len(), 1);
6136 let (_, _, resp) = &captured[0];
6137 assert_eq!(resp.status, RpcStatus::UnknownVersion);
6138 }
6139
6140 /// Cancellation token roundtrip: `cancel()` sets `is_cancelled`
6141 /// and wakes a parked `cancelled().await`.
6142 #[tokio::test]
6143 async fn cancellation_token_signals_waiters() {
6144 let token = RpcCancellationToken::new();
6145 assert!(!token.is_cancelled());
6146 let token2 = token.clone();
6147 let waiter = tokio::spawn(async move {
6148 token2.cancelled().await;
6149 });
6150 // Give the waiter a chance to park.
6151 tokio::time::sleep(Duration::from_millis(10)).await;
6152 token.cancel();
6153 // Waiter wakes.
6154 tokio::time::timeout(Duration::from_secs(1), waiter)
6155 .await
6156 .expect("waiter must wake within 1s")
6157 .expect("waiter task must not panic");
6158 assert!(token.is_cancelled());
6159 }
6160
6161 // ====================================================================
6162 // W3C Trace Context propagation.
6163 // ====================================================================
6164
6165 /// `build_trace_headers` + `extract_trace_context` round-trip
6166 /// a typical W3C trace context through the request headers.
6167 #[test]
6168 fn trace_context_round_trips_through_headers() {
6169 let tc = TraceContext {
6170 traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string(),
6171 tracestate: "vendor1=opaque-value,vendor2=other".to_string(),
6172 };
6173 let headers = build_trace_headers(&tc);
6174 assert_eq!(headers.len(), 2, "non-empty tracestate emits both headers");
6175 let extracted = extract_trace_context(&headers).expect("must extract");
6176 assert_eq!(extracted, tc);
6177 }
6178
6179 /// Regression for M21: `extract_trace_context` does
6180 /// case-INsensitive matching on the header names, matching the
6181 /// W3C and HTTP conventions. A peer that emits capitalized
6182 /// `Traceparent` or `TRACESTATE` must still be picked up — the
6183 /// previous implementation used `name.as_str() == "traceparent"`
6184 /// and silently dropped any non-lowercase variant.
6185 #[test]
6186 fn extract_trace_context_is_case_insensitive_on_header_names() {
6187 // Capital-T traceparent + uppercase TRACESTATE — both must
6188 // be picked up by the extractor.
6189 let headers = vec![
6190 (
6191 "Traceparent".to_string(),
6192 b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_vec(),
6193 ),
6194 ("TRACESTATE".to_string(), b"vendor=value".to_vec()),
6195 ];
6196 let extracted =
6197 extract_trace_context(&headers).expect("capital-T traceparent must be recognized");
6198 assert_eq!(
6199 extracted.traceparent,
6200 "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
6201 );
6202 assert_eq!(extracted.tracestate, "vendor=value");
6203
6204 // Mixed-case still works.
6205 let headers = vec![
6206 ("traceParent".to_string(), b"00-aa-bb-01".to_vec()),
6207 ("TraceState".to_string(), b"v=1".to_vec()),
6208 ];
6209 let extracted =
6210 extract_trace_context(&headers).expect("mixed-case traceparent must be recognized");
6211 assert_eq!(extracted.traceparent, "00-aa-bb-01");
6212 assert_eq!(extracted.tracestate, "v=1");
6213 }
6214
6215 /// Empty `tracestate` is omitted on the wire (W3C convention)
6216 /// but extracted as empty on the receive side.
6217 #[test]
6218 fn trace_context_empty_tracestate_omitted_from_wire() {
6219 let tc = TraceContext {
6220 traceparent: "00-aa-bb-01".to_string(),
6221 tracestate: String::new(),
6222 };
6223 let headers = build_trace_headers(&tc);
6224 assert_eq!(
6225 headers.len(),
6226 1,
6227 "empty tracestate must NOT be emitted on the wire",
6228 );
6229 assert_eq!(headers[0].0, "traceparent");
6230 let extracted = extract_trace_context(&headers).expect("must extract");
6231 assert_eq!(extracted.traceparent, "00-aa-bb-01");
6232 assert_eq!(extracted.tracestate, "");
6233 }
6234
6235 /// Headers without `traceparent` decode as `None`. Useful for
6236 /// the FLAG_RPC_PROPAGATE_TRACE-set-but-no-headers misuse
6237 /// case — the server gets `None` rather than a bogus context.
6238 #[test]
6239 fn trace_context_missing_traceparent_returns_none() {
6240 let headers = vec![
6241 ("content-type".to_string(), b"application/json".to_vec()),
6242 ("idempotency-key".to_string(), b"abc".to_vec()),
6243 ];
6244 assert!(extract_trace_context(&headers).is_none());
6245 }
6246
6247 /// Server fold populates `RpcContext::trace_context` only when
6248 /// the caller signals `FLAG_RPC_PROPAGATE_TRACE`. End-to-end
6249 /// through the fold's apply path.
6250 #[tokio::test]
6251 async fn server_fold_propagates_trace_context_via_flag() {
6252 struct CapturingHandler {
6253 captured: Arc<Mutex<Option<Option<TraceContext>>>>,
6254 }
6255 #[async_trait::async_trait]
6256 impl RpcHandler for CapturingHandler {
6257 async fn call(&self, ctx: RpcContext) -> Result<RpcResponsePayload, RpcHandlerError> {
6258 *self.captured.lock() = Some(ctx.trace_context.clone());
6259 Ok(RpcResponsePayload {
6260 status: RpcStatus::Ok,
6261 headers: vec![],
6262 body: Bytes::new(),
6263 })
6264 }
6265 }
6266
6267 // Helper: run one request through a fresh fold and return
6268 // what the handler captured for trace_context.
6269 async fn run(req: RpcRequestPayload) -> Option<TraceContext> {
6270 let captured: Arc<Mutex<Option<Option<TraceContext>>>> = Arc::new(Mutex::new(None));
6271 let (emit, _captured_responses) = capturing_emitter();
6272 let handler = Arc::new(CapturingHandler {
6273 captured: captured.clone(),
6274 });
6275 let mut fold = RpcServerFold::new(handler, emit);
6276 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
6277 // Wait for the spawned handler to finish.
6278 assert!(
6279 wait_until(|| captured.lock().is_some(), Duration::from_secs(2)).await,
6280 "handler must run"
6281 );
6282 let observed = captured.lock().take().unwrap();
6283 observed
6284 }
6285
6286 // Case 1: FLAG_RPC_PROPAGATE_TRACE NOT set → trace_context is None.
6287 let req_no_flag = RpcRequestPayload {
6288 service: "x".to_string(),
6289 deadline_ns: 0,
6290 flags: 0,
6291 headers: vec![("traceparent".to_string(), b"00-aa-bb-01".to_vec())],
6292 body: Bytes::new(),
6293 };
6294 assert!(
6295 run(req_no_flag).await.is_none(),
6296 "without the flag, server must NOT extract trace_context"
6297 );
6298
6299 // Case 2: FLAG set + headers present → server gets the context.
6300 let tc = TraceContext {
6301 traceparent: "00-trace-span-01".to_string(),
6302 tracestate: "vendor=value".to_string(),
6303 };
6304 let req_with_flag = RpcRequestPayload {
6305 service: "x".to_string(),
6306 deadline_ns: 0,
6307 flags: FLAG_RPC_PROPAGATE_TRACE,
6308 headers: build_trace_headers(&tc),
6309 body: Bytes::new(),
6310 };
6311 let observed = run(req_with_flag).await.expect("flag set → should be Some");
6312 assert_eq!(observed, tc);
6313
6314 // Case 3: FLAG set but headers missing → None (defensive).
6315 let req_flag_no_headers = RpcRequestPayload {
6316 service: "x".to_string(),
6317 deadline_ns: 0,
6318 flags: FLAG_RPC_PROPAGATE_TRACE,
6319 headers: vec![],
6320 body: Bytes::new(),
6321 };
6322 assert!(
6323 run(req_flag_no_headers).await.is_none(),
6324 "flag set but no headers → server gets None (no synthesis)"
6325 );
6326 }
6327
6328 /// Race: cancel fires AFTER the future is registered but
6329 /// BEFORE the await actually parks. The token's
6330 /// `notified()`-then-check ordering must catch this case
6331 /// without sleeping past the cancellation.
6332 #[tokio::test]
6333 async fn cancellation_token_does_not_miss_cancel_racing_register() {
6334 for _ in 0..50 {
6335 let token = RpcCancellationToken::new();
6336 let token2 = token.clone();
6337 let waiter = tokio::spawn(async move {
6338 token2.cancelled().await;
6339 });
6340 // No sleep — fire cancel as fast as possible against
6341 // the just-spawned waiter. In the worst case the
6342 // waiter has not yet reached `notified()`; it will see
6343 // `is_cancelled() == true` on its first check and
6344 // return immediately. In the other case it parks and
6345 // gets woken by `notify_waiters`.
6346 token.cancel();
6347 tokio::time::timeout(Duration::from_secs(1), waiter)
6348 .await
6349 .expect("waiter must complete within 1s")
6350 .expect("waiter task must not panic");
6351 }
6352 }
6353
6354 // ====================================================================
6355 // RpcClientFold — caller-side response routing.
6356 // ====================================================================
6357
6358 fn rpc_response_event(
6359 caller_origin: u64,
6360 call_id: u64,
6361 payload: RpcResponsePayload,
6362 ) -> RedexEvent {
6363 let meta = EventMeta::new(DISPATCH_RPC_RESPONSE, 0, caller_origin, call_id, 0);
6364 let mut buf = Vec::new();
6365 buf.extend_from_slice(&meta.to_bytes());
6366 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6367 // feed the folds directly (no ingress select), and the folds
6368 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6369 encode_rpc_route(&mut buf, 0);
6370 buf.extend_from_slice(&payload.encode());
6371 RedexEvent {
6372 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6373 payload: bytes::Bytes::from(buf),
6374 }
6375 }
6376
6377 /// Happy path: register a call, drive the matching RESPONSE
6378 /// through the fold, the awaiting receiver gets the payload.
6379 #[tokio::test]
6380 async fn client_fold_routes_response_to_registered_waiter() {
6381 let pending = Arc::new(RpcClientPending::new());
6382 let mut fold = RpcClientFold::new(pending.clone());
6383 let rx = pending.register(42, 0);
6384 assert_eq!(pending.pending_count(), 1);
6385
6386 let resp = RpcResponsePayload {
6387 status: RpcStatus::Ok,
6388 headers: vec![],
6389 body: Bytes::from_static(b"hello back"),
6390 };
6391 fold.apply(&rpc_response_event(0xCAFE, 42, resp.clone()), &mut ())
6392 .unwrap();
6393
6394 // Receiver is completed.
6395 let got = tokio::time::timeout(Duration::from_secs(1), rx)
6396 .await
6397 .expect("receiver must complete within 1s")
6398 .expect("sender must not be dropped");
6399 assert_eq!(got, resp);
6400 // Pending entry cleared after delivery.
6401 assert_eq!(pending.pending_count(), 0);
6402 }
6403
6404 /// RESPONSE for an unknown call_id is a no-op (no panic, no
6405 /// stray side effect). This is the case where a stale RESPONSE
6406 /// arrives after the caller has cancelled or timed out.
6407 #[tokio::test]
6408 async fn client_fold_response_for_unknown_call_id_is_no_op() {
6409 let pending = Arc::new(RpcClientPending::new());
6410 let mut fold = RpcClientFold::new(pending.clone());
6411 let resp = RpcResponsePayload {
6412 status: RpcStatus::Ok,
6413 headers: vec![],
6414 body: Bytes::new(),
6415 };
6416 fold.apply(&rpc_response_event(1, 999, resp), &mut ())
6417 .unwrap();
6418 assert_eq!(pending.pending_count(), 0);
6419 }
6420
6421 /// REQUEST / CANCEL events on the reply channel are ignored
6422 /// rather than producing a stray decode-error or affecting
6423 /// pending state. The reply channel shouldn't carry these in
6424 /// practice (they belong on `<service>.requests`), but a
6425 /// misconfigured publisher must not break the fold.
6426 #[tokio::test]
6427 async fn client_fold_ignores_non_response_dispatches() {
6428 let pending = Arc::new(RpcClientPending::new());
6429 let mut fold = RpcClientFold::new(pending.clone());
6430 let _rx = pending.register(7, 0);
6431
6432 // REQUEST event landing on the caller's reply channel is
6433 // ignored.
6434 let req = RpcRequestPayload {
6435 service: "stray".to_string(),
6436 deadline_ns: 0,
6437 flags: 0,
6438 headers: vec![],
6439 body: Bytes::new(),
6440 };
6441 fold.apply(&rpc_request_event(1, 7, req), &mut ()).unwrap();
6442 // Pending entry untouched.
6443 assert_eq!(pending.pending_count(), 1);
6444
6445 // CANCEL on the reply channel: also ignored.
6446 fold.apply(&rpc_cancel_event(1, 7), &mut ()).unwrap();
6447 assert_eq!(pending.pending_count(), 1);
6448 }
6449
6450 /// `cancel(call_id)` removes the pending entry; a subsequent
6451 /// RESPONSE for that call_id is dropped silently.
6452 #[tokio::test]
6453 async fn client_pending_cancel_drops_subsequent_response() {
6454 let pending = Arc::new(RpcClientPending::new());
6455 let mut fold = RpcClientFold::new(pending.clone());
6456 let rx = pending.register(5, 0);
6457 pending.cancel(5);
6458 assert_eq!(pending.pending_count(), 0);
6459
6460 let resp = RpcResponsePayload {
6461 status: RpcStatus::Ok,
6462 headers: vec![],
6463 body: Bytes::new(),
6464 };
6465 fold.apply(&rpc_response_event(1, 5, resp), &mut ())
6466 .unwrap();
6467
6468 // Receiver was dropped along with the cancel. The previously-
6469 // returned `rx` errors with `Closed`.
6470 let result = tokio::time::timeout(Duration::from_secs(1), rx).await;
6471 let inner = result.expect("must complete within 1s");
6472 assert!(
6473 inner.is_err(),
6474 "receiver after cancel must error (sender dropped)",
6475 );
6476 }
6477
6478 /// Malformed RESPONSE payload: fold returns Ok (does not kill
6479 /// the cortex adapter) and leaves the pending entry intact for
6480 /// the caller's deadline / cancellation path to clean up. Pre-
6481 /// fix a bad payload could either kill the fold or fabricate a
6482 /// synthetic response — both wrong.
6483 #[tokio::test]
6484 async fn client_fold_malformed_response_is_logged_not_fatal() {
6485 let pending = Arc::new(RpcClientPending::new());
6486 let mut fold = RpcClientFold::new(pending.clone());
6487 let rx = pending.register(11, 0);
6488
6489 // Build a malformed RESPONSE: valid meta, garbage tail
6490 // (just `[0xFF]`, which is shorter than the required 2-byte
6491 // status + 1-byte headers count + 4-byte body length).
6492 let meta = EventMeta::new(DISPATCH_RPC_RESPONSE, 0, 1, 11, 0);
6493 let mut buf = Vec::new();
6494 buf.extend_from_slice(&meta.to_bytes());
6495 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6496 // feed the folds directly (no ingress select), and the folds
6497 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6498 encode_rpc_route(&mut buf, 0);
6499 buf.push(0xFF);
6500 let ev = RedexEvent {
6501 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6502 payload: bytes::Bytes::from(buf),
6503 };
6504 let result = fold.apply(&ev, &mut ());
6505 assert!(
6506 result.is_ok(),
6507 "fold must not return Err on malformed response"
6508 );
6509 // Pending entry NOT cleared — the caller's cancellation
6510 // path will eventually clean it up via `cancel(call_id)`.
6511 assert_eq!(pending.pending_count(), 1);
6512 // Receiver is still pending (not delivered, not closed).
6513 assert!(
6514 tokio::time::timeout(Duration::from_millis(50), rx)
6515 .await
6516 .is_err(),
6517 "receiver should still be parked (no delivery, no drop)",
6518 );
6519 }
6520
6521 /// Re-registering the same call_id replaces the prior sender;
6522 /// the prior `Receiver` errors with `RecvError::Closed`. This
6523 /// is the misuse-detection path — call_ids should be unique
6524 /// per (caller, target) for the lifetime of the call, and a
6525 /// clash surfaces as a hard error rather than silently
6526 /// delivering the response to the wrong waiter.
6527 #[tokio::test]
6528 async fn client_pending_re_register_closes_prior_receiver() {
6529 let pending = Arc::new(RpcClientPending::new());
6530 let rx_a = pending.register(99, 0);
6531 let _rx_b = pending.register(99, 0);
6532 // The first receiver is now closed (sender dropped on
6533 // re-insert).
6534 let result = tokio::time::timeout(Duration::from_secs(1), rx_a).await;
6535 let inner = result.expect("must complete within 1s");
6536 assert!(inner.is_err(), "re-register must close prior receiver");
6537 assert_eq!(pending.pending_count(), 1);
6538 }
6539
6540 /// S-4 part 2 regression: a RESPONSE whose wire `from_node`
6541 /// doesn't match the recorded `target_node` must not resolve
6542 /// the call. Without the gate, any peer with publish access
6543 /// to the caller's reply channel could ship a spoofed
6544 /// response (random call_ids from S-4 part 1 narrow the
6545 /// attack surface, but this gate closes the residual case
6546 /// of an attacker who has observed the victim's call_id via
6547 /// some side channel).
6548 #[tokio::test]
6549 async fn client_pending_drops_response_from_wrong_target() {
6550 let pending = Arc::new(RpcClientPending::new());
6551 let rx = pending.register(0xDEAD_BEEF, 0x42);
6552 let resp = RpcResponsePayload {
6553 status: RpcStatus::Ok,
6554 headers: Vec::new(),
6555 body: Bytes::from_static(b"forged"),
6556 };
6557 // Forged from a different session peer — must drop.
6558 pending.deliver(0xDEAD_BEEF, 0x99, resp.clone());
6559 // Receiver is still parked; pending entry is intact.
6560 let parked = tokio::time::timeout(Duration::from_millis(50), rx).await;
6561 assert!(
6562 parked.is_err(),
6563 "forged RESPONSE from wrong target must not resolve the call"
6564 );
6565 assert_eq!(pending.pending_count(), 1);
6566
6567 // Legitimate RESPONSE from the recorded target resolves.
6568 let rx2 = pending.register(0xCAFE, 0x42);
6569 let ok_resp = RpcResponsePayload {
6570 status: RpcStatus::Ok,
6571 headers: Vec::new(),
6572 body: Bytes::from_static(b"ok"),
6573 };
6574 pending.deliver(0xCAFE, 0x42, ok_resp);
6575 let delivered = tokio::time::timeout(Duration::from_millis(50), rx2)
6576 .await
6577 .expect("must complete")
6578 .expect("must receive");
6579 assert_eq!(delivered.body.as_ref(), b"ok");
6580 }
6581
6582 // ====================================================================
6583 // Phase C — RpcClientPending + RpcClientFold for client-streaming.
6584 // ====================================================================
6585
6586 /// Build a REQUEST_GRANT event for tests. Mirror of
6587 /// `rpc_stream_grant_event` for the request direction.
6588 fn rpc_request_grant_event(caller_origin: u64, call_id: u64, credits: u32) -> RedexEvent {
6589 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_GRANT, 0, caller_origin, call_id, 0);
6590 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 12);
6591 buf.extend_from_slice(&meta.to_bytes());
6592 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6593 // feed the folds directly (no ingress select), and the folds
6594 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6595 encode_rpc_route(&mut buf, 0);
6596 buf.extend_from_slice(&encode_request_grant(call_id, credits));
6597 RedexEvent {
6598 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6599 payload: bytes::Bytes::from(buf),
6600 }
6601 }
6602
6603 /// `register_client_streaming` returns two halves: a terminal
6604 /// oneshot and a grant mpsc. A terminal RESPONSE resolves the
6605 /// oneshot (same shape as unary delivery); a REQUEST_GRANT
6606 /// for the same call_id pushes its credit onto the mpsc.
6607 #[tokio::test]
6608 async fn client_pending_client_streaming_routes_terminal_and_grants() {
6609 let pending = Arc::new(RpcClientPending::new());
6610 let (terminal_rx, mut grant_rx) = pending.register_client_streaming(0xCAFE_F00D, 0);
6611 // Push two grants — both should land on the mpsc.
6612 pending.deliver_grant(0xCAFE_F00D, 0, 3);
6613 pending.deliver_grant(0xCAFE_F00D, 0, 7);
6614 assert_eq!(grant_rx.recv().await, Some(3));
6615 assert_eq!(grant_rx.recv().await, Some(7));
6616 // Terminal RESPONSE resolves the oneshot and removes the
6617 // entry. Grant mpsc closes too (its sender drops with
6618 // the entry).
6619 let resp = RpcResponsePayload {
6620 status: RpcStatus::Ok,
6621 headers: vec![],
6622 body: Bytes::from_static(b"done"),
6623 };
6624 pending.deliver(0xCAFE_F00D, 0, resp.clone());
6625 let delivered = tokio::time::timeout(Duration::from_millis(50), terminal_rx)
6626 .await
6627 .expect("terminal must complete")
6628 .expect("terminal must receive");
6629 assert_eq!(delivered.body.as_ref(), b"done");
6630 // Grant mpsc now closed.
6631 assert_eq!(grant_rx.recv().await, None);
6632 assert_eq!(pending.pending_count(), 0);
6633 }
6634
6635 /// REQUEST_GRANT from a non-target session peer is dropped
6636 /// without injecting credit. Same S-4-style binding gate as
6637 /// the RESPONSE delivery path — a forged grant on a shared
6638 /// reply channel can't inflate a victim's credit budget.
6639 #[tokio::test]
6640 async fn client_pending_grant_from_wrong_target_is_dropped() {
6641 let pending = Arc::new(RpcClientPending::new());
6642 let (_terminal_rx, mut grant_rx) = pending.register_client_streaming(0xCAFE_F00D, 0x42);
6643 // Forged grant from a different session peer — must drop.
6644 pending.deliver_grant(0xCAFE_F00D, 0x99, 100);
6645 let parked = tokio::time::timeout(Duration::from_millis(50), grant_rx.recv()).await;
6646 assert!(
6647 parked.is_err(),
6648 "forged REQUEST_GRANT from wrong target must not inject credit"
6649 );
6650 // Legitimate grant from the recorded target lands.
6651 pending.deliver_grant(0xCAFE_F00D, 0x42, 5);
6652 let delivered = tokio::time::timeout(Duration::from_millis(50), grant_rx.recv())
6653 .await
6654 .expect("must complete")
6655 .expect("must receive");
6656 assert_eq!(delivered, 5);
6657 }
6658
6659 /// `deliver_grant` for an unknown call_id is a silent no-op.
6660 /// Same harmless-drop semantics as a STREAM_GRANT for an
6661 /// unknown / non-flow-controlled call (CANCEL/GRANT race is
6662 /// always possible).
6663 #[tokio::test]
6664 async fn client_pending_grant_for_unknown_call_id_is_no_op() {
6665 let pending = Arc::new(RpcClientPending::new());
6666 // No entry registered for this call_id.
6667 pending.deliver_grant(0xDEAD, 0, 42);
6668 // No panics, no entries created.
6669 assert_eq!(pending.pending_count(), 0);
6670 }
6671
6672 /// `deliver_grant` for a unary entry is silently dropped
6673 /// (grants only apply to client-streaming / duplex calls).
6674 #[tokio::test]
6675 async fn client_pending_grant_for_unary_entry_is_no_op() {
6676 let pending = Arc::new(RpcClientPending::new());
6677 let _rx = pending.register(0xDEAD, 0);
6678 pending.deliver_grant(0xDEAD, 0, 42);
6679 // No state changes — entry still pending, no leak.
6680 assert_eq!(pending.pending_count(), 1);
6681 }
6682
6683 /// `RpcClientFold::apply` (legacy / loopback path) routes
6684 /// DISPATCH_RPC_REQUEST_GRANT events through to the matching
6685 /// ClientStreaming entry's grant mpsc. Pins the second
6686 /// dispatch arm the fold gained for Phase C.
6687 #[tokio::test]
6688 async fn client_fold_routes_request_grant_to_registered_waiter() {
6689 let pending = Arc::new(RpcClientPending::new());
6690 let mut fold = RpcClientFold::new(pending.clone());
6691 let (_terminal_rx, mut grant_rx) = pending.register_client_streaming(0xC0DE, 0);
6692 let ev = rpc_request_grant_event(0xCAFE, 0xC0DE, 9);
6693 fold.apply(&ev, &mut ()).unwrap();
6694 let delivered = tokio::time::timeout(Duration::from_millis(50), grant_rx.recv())
6695 .await
6696 .expect("must complete")
6697 .expect("must receive");
6698 assert_eq!(delivered, 9);
6699 }
6700
6701 /// `RpcClientFold::apply` ignores REQUEST_GRANT events whose
6702 /// payload is malformed (wrong length): no panic, no entry
6703 /// state change, fold returns Ok and keeps going. Mirror of
6704 /// the response-side malformed-payload regression.
6705 #[tokio::test]
6706 async fn client_fold_malformed_request_grant_is_logged_not_fatal() {
6707 let pending = Arc::new(RpcClientPending::new());
6708 let mut fold = RpcClientFold::new(pending.clone());
6709 let (_terminal_rx, mut grant_rx) = pending.register_client_streaming(0xC0DE, 0);
6710 // Build a GRANT event whose payload is only 4 bytes
6711 // (truncated — codec needs 12).
6712 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_GRANT, 0, 0xCAFE, 0xC0DE, 0);
6713 let mut buf = Vec::new();
6714 buf.extend_from_slice(&meta.to_bytes());
6715 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6716 // feed the folds directly (no ingress select), and the folds
6717 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6718 encode_rpc_route(&mut buf, 0);
6719 buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
6720 let ev = RedexEvent {
6721 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6722 payload: bytes::Bytes::from(buf),
6723 };
6724 let result = fold.apply(&ev, &mut ());
6725 assert!(
6726 result.is_ok(),
6727 "malformed REQUEST_GRANT must NOT kill the fold"
6728 );
6729 // No credit landed on the mpsc.
6730 let parked = tokio::time::timeout(Duration::from_millis(30), grant_rx.recv()).await;
6731 assert!(
6732 parked.is_err(),
6733 "malformed REQUEST_GRANT must not inject credit"
6734 );
6735 }
6736
6737 /// REQUEST_GRANT frames where the payload `call_id` does NOT
6738 /// agree with `EventMeta::seq_or_ts` must be dropped: the
6739 /// producer is contracted to encode both fields to the same
6740 /// value (see `RpcRequestGrantPayload::call_id` doc), so a
6741 /// mismatch is either a malformed frame or an attempted
6742 /// cross-call credit-injection. Without this check, a peer
6743 /// could publish a GRANT whose meta names one call but whose
6744 /// payload credits a different in-flight call_id.
6745 ///
6746 /// Regression: cubic-dev-ai bot P2 review comment on the
6747 /// `nrpc-streaming` branch.
6748 #[tokio::test]
6749 async fn client_fold_drops_request_grant_with_mismatched_call_ids() {
6750 let pending = Arc::new(RpcClientPending::new());
6751 let mut fold = RpcClientFold::new(pending.clone());
6752 let (_terminal_rx_victim, mut grant_rx_victim) =
6753 pending.register_client_streaming(0xC0DE, 0);
6754 let (_terminal_rx_other, mut grant_rx_other) = pending.register_client_streaming(0xBEEF, 0);
6755
6756 // Build a hand-rolled frame: meta names call 0xC0DE,
6757 // payload encodes credit for call 0xBEEF. Either the
6758 // producer is broken or this is a forged frame; the
6759 // consumer must drop, not deliver.
6760 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_GRANT, 0, 0xCAFE, 0xC0DE, 0);
6761 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 12);
6762 buf.extend_from_slice(&meta.to_bytes());
6763 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6764 // feed the folds directly (no ingress select), and the folds
6765 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6766 encode_rpc_route(&mut buf, 0);
6767 buf.extend_from_slice(&encode_request_grant(0xBEEF, 5));
6768 let ev = RedexEvent {
6769 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6770 payload: bytes::Bytes::from(buf),
6771 };
6772 fold.apply(&ev, &mut ()).unwrap();
6773
6774 let parked_victim =
6775 tokio::time::timeout(Duration::from_millis(30), grant_rx_victim.recv()).await;
6776 assert!(
6777 parked_victim.is_err(),
6778 "mismatched REQUEST_GRANT must not credit the call named in meta",
6779 );
6780 let parked_other =
6781 tokio::time::timeout(Duration::from_millis(30), grant_rx_other.recv()).await;
6782 assert!(
6783 parked_other.is_err(),
6784 "mismatched REQUEST_GRANT must not credit the call named in payload either",
6785 );
6786 }
6787
6788 // ====================================================================
6789 // RpcServerStreamingFold — coverage for the multi-fire emit path.
6790 //
6791 // The streaming fold is the most complex code in this file:
6792 // - Per-call cancellation token (same as unary)
6793 // - Pump task that drains an mpsc and awaits each emit to
6794 // enforce per-call ordering
6795 // - Optional flow-control semaphore (caller-set window +
6796 // STREAM_GRANT credit refills)
6797 // - Terminal-frame emission with CANCEL-wins override
6798 //
6799 // These tests pin each branch: ordered chunks + clean EOF;
6800 // application error after partial stream; panic surfacing as
6801 // Internal; CANCEL flipping the cancellation token AND being
6802 // surfaced as the terminal status; STREAM_GRANT permits;
6803 // duplicate-REQUEST refusal.
6804 // ====================================================================
6805
6806 /// Build an async `RpcAsyncResponseEmitter` that captures every
6807 /// emit into a shared Vec. Streaming fold tests use this to
6808 /// inspect the multi-frame emit pattern.
6809 fn capturing_async_emitter() -> (RpcAsyncResponseEmitter, CapturedResponses) {
6810 let captured: CapturedResponses = Arc::new(Mutex::new(Vec::new()));
6811 let captured_clone = captured.clone();
6812 let emit: RpcAsyncResponseEmitter = Arc::new(move |_from_node, origin, call_id, resp| {
6813 let captured_clone = captured_clone.clone();
6814 Box::pin(async move {
6815 captured_clone.lock().push((origin, call_id, resp));
6816 })
6817 });
6818 (emit, captured)
6819 }
6820
6821 /// Synthesize a STREAM_GRANT event for a `(caller_origin, call_id)`
6822 /// asking for `n` additional credits.
6823 fn rpc_stream_grant_event(caller_origin: u64, call_id: u64, n: u32) -> RedexEvent {
6824 let meta = EventMeta::new(DISPATCH_RPC_STREAM_GRANT, 0, caller_origin, call_id, 0);
6825 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 4);
6826 buf.extend_from_slice(&meta.to_bytes());
6827 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
6828 // feed the folds directly (no ingress select), and the folds
6829 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
6830 encode_rpc_route(&mut buf, 0);
6831 buf.extend_from_slice(&encode_stream_grant(n));
6832 RedexEvent {
6833 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
6834 payload: bytes::Bytes::from(buf),
6835 }
6836 }
6837
6838 /// Streaming handler that emits N chunks and returns Ok. The
6839 /// caller-side test asserts (a) all N chunks arrive in order
6840 /// with the `nrpc-streaming: continue` header, (b) a final
6841 /// terminal frame with `nrpc-streaming: end` follows.
6842 #[tokio::test]
6843 async fn streaming_fold_emits_chunks_in_order_and_clean_terminal() {
6844 struct CountingHandler {
6845 n: usize,
6846 }
6847 #[async_trait::async_trait]
6848 impl RpcStreamingHandler for CountingHandler {
6849 async fn call(
6850 &self,
6851 _ctx: RpcContext,
6852 sink: RpcResponseSink,
6853 ) -> Result<(), RpcHandlerError> {
6854 for i in 0..self.n {
6855 sink.send(format!("chunk-{i}").into_bytes());
6856 }
6857 Ok(())
6858 }
6859 }
6860 let (emit, captured) = capturing_async_emitter();
6861 let mut fold = RpcServerStreamingFold::new(Arc::new(CountingHandler { n: 5 }), emit);
6862 let req = RpcRequestPayload {
6863 service: "stream".to_string(),
6864 deadline_ns: 0,
6865 flags: FLAG_RPC_STREAMING_RESPONSE,
6866 headers: vec![],
6867 body: Bytes::new(),
6868 };
6869 fold.apply(&rpc_request_event(11, 22, req), &mut ())
6870 .unwrap();
6871 // 5 continue chunks + 1 terminal end frame.
6872 assert!(
6873 wait_until(|| captured.lock().len() == 6, Duration::from_secs(2)).await,
6874 "expected 6 frames (5 chunks + terminal end), got {}",
6875 captured.lock().len(),
6876 );
6877 let captured = captured.lock();
6878 for (i, (_, _, resp)) in captured.iter().take(5).enumerate() {
6879 assert_eq!(resp.status, RpcStatus::Ok);
6880 // continue header on every non-terminal chunk
6881 let hdr = resp
6882 .headers
6883 .iter()
6884 .find(|(n, _)| n == HEADER_NRPC_STREAMING)
6885 .expect("streaming header present");
6886 assert_eq!(hdr.1.as_slice(), HEADER_NRPC_STREAMING_CONTINUE);
6887 assert_eq!(resp.body, format!("chunk-{i}").into_bytes());
6888 }
6889 // Terminal frame
6890 let (_, _, term) = captured.last().unwrap();
6891 assert_eq!(term.status, RpcStatus::Ok);
6892 let hdr = term
6893 .headers
6894 .iter()
6895 .find(|(n, _)| n == HEADER_NRPC_STREAMING)
6896 .expect("terminal must have streaming header");
6897 assert_eq!(hdr.1.as_slice(), HEADER_NRPC_STREAMING_END);
6898 assert!(term.body.is_empty());
6899 }
6900
6901 /// Handler returns `Err(Internal)` after sending 2 chunks. Caller
6902 /// must see (a) both chunks with the continue header, (b) a
6903 /// terminal frame carrying `RpcStatus::Internal` (NOT the end
6904 /// marker — the terminal-error path drops the header).
6905 #[tokio::test]
6906 async fn streaming_fold_terminal_error_after_partial_stream() {
6907 struct PartialErrHandler;
6908 #[async_trait::async_trait]
6909 impl RpcStreamingHandler for PartialErrHandler {
6910 async fn call(
6911 &self,
6912 _ctx: RpcContext,
6913 sink: RpcResponseSink,
6914 ) -> Result<(), RpcHandlerError> {
6915 sink.send(b"first".to_vec());
6916 sink.send(b"second".to_vec());
6917 Err(RpcHandlerError::Internal("ran out of fuel".into()))
6918 }
6919 }
6920 let (emit, captured) = capturing_async_emitter();
6921 let mut fold = RpcServerStreamingFold::new(Arc::new(PartialErrHandler), emit);
6922 let req = RpcRequestPayload {
6923 service: "x".to_string(),
6924 deadline_ns: 0,
6925 flags: FLAG_RPC_STREAMING_RESPONSE,
6926 headers: vec![],
6927 body: Bytes::new(),
6928 };
6929 fold.apply(&rpc_request_event(1, 1, req), &mut ()).unwrap();
6930 assert!(
6931 wait_until(|| captured.lock().len() == 3, Duration::from_secs(2)).await,
6932 "expected 2 chunks + 1 terminal error",
6933 );
6934 let captured = captured.lock();
6935 assert_eq!(captured[0].2.body.as_ref(), b"first");
6936 assert_eq!(captured[1].2.body.as_ref(), b"second");
6937 let (_, _, term) = &captured[2];
6938 assert_eq!(term.status, RpcStatus::Internal);
6939 assert!(
6940 String::from_utf8_lossy(&term.body).contains("ran out of fuel"),
6941 "diagnostic must round-trip, got {:?}",
6942 String::from_utf8_lossy(&term.body),
6943 );
6944 }
6945
6946 /// Handler panics. The fold's `catch_unwind` surfaces it as a
6947 /// terminal `RpcStatus::Internal` rather than killing the
6948 /// runtime.
6949 #[tokio::test]
6950 async fn streaming_fold_handler_panic_surfaces_as_internal_terminal() {
6951 struct PanicHandler;
6952 #[async_trait::async_trait]
6953 impl RpcStreamingHandler for PanicHandler {
6954 async fn call(
6955 &self,
6956 _ctx: RpcContext,
6957 _sink: RpcResponseSink,
6958 ) -> Result<(), RpcHandlerError> {
6959 panic!("kaboom in streaming handler");
6960 }
6961 }
6962 let (emit, captured) = capturing_async_emitter();
6963 let mut fold = RpcServerStreamingFold::new(Arc::new(PanicHandler), emit);
6964 let req = RpcRequestPayload {
6965 service: "x".to_string(),
6966 deadline_ns: 0,
6967 flags: FLAG_RPC_STREAMING_RESPONSE,
6968 headers: vec![],
6969 body: Bytes::new(),
6970 };
6971 fold.apply(&rpc_request_event(1, 2, req), &mut ()).unwrap();
6972 assert!(
6973 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
6974 "panic must surface as a terminal frame",
6975 );
6976 let captured = captured.lock();
6977 assert_eq!(captured.len(), 1);
6978 let (_, _, resp) = &captured[0];
6979 assert_eq!(resp.status, RpcStatus::Internal);
6980 assert!(
6981 String::from_utf8_lossy(&resp.body).contains("kaboom"),
6982 "panic message must surface, got {:?}",
6983 String::from_utf8_lossy(&resp.body),
6984 );
6985 }
6986
6987 /// CANCEL during a streaming call overrides the terminal frame
6988 /// with `RpcStatus::Cancelled` — same CANCEL-wins ordering as
6989 /// the unary fold.
6990 #[tokio::test]
6991 async fn streaming_fold_cancel_overrides_terminal_with_cancelled() {
6992 struct CooperativeHandler;
6993 #[async_trait::async_trait]
6994 impl RpcStreamingHandler for CooperativeHandler {
6995 async fn call(
6996 &self,
6997 ctx: RpcContext,
6998 sink: RpcResponseSink,
6999 ) -> Result<(), RpcHandlerError> {
7000 sink.send(b"chunk-0".to_vec());
7001 tokio::select! {
7002 _ = ctx.cancellation.cancelled() => Ok(()),
7003 _ = tokio::time::sleep(Duration::from_secs(5)) => Ok(()),
7004 }
7005 }
7006 }
7007 let (emit, captured) = capturing_async_emitter();
7008 let mut fold = RpcServerStreamingFold::new(Arc::new(CooperativeHandler), emit);
7009 let req = RpcRequestPayload {
7010 service: "x".to_string(),
7011 deadline_ns: 0,
7012 flags: FLAG_RPC_STREAMING_RESPONSE,
7013 headers: vec![],
7014 body: Bytes::new(),
7015 };
7016 fold.apply(&rpc_request_event(7, 13, req), &mut ()).unwrap();
7017 // Wait until at least the first chunk is captured AND the
7018 // handler is parked (in_flight key present), then CANCEL.
7019 assert!(
7020 wait_until(
7021 || !captured.lock().is_empty() && fold.in_flight_keys().contains(&(0, 7, 13)),
7022 Duration::from_secs(2)
7023 )
7024 .await
7025 );
7026 fold.apply(&rpc_cancel_event(7, 13), &mut ()).unwrap();
7027 // Wait for the terminal frame.
7028 assert!(
7029 wait_until(|| captured.lock().len() >= 2, Duration::from_secs(2)).await,
7030 "expected first chunk + terminal frame",
7031 );
7032 let captured = captured.lock();
7033 // First emit was the chunk; the LAST should be the
7034 // Cancelled terminal.
7035 assert_eq!(
7036 captured.last().unwrap().2.status,
7037 RpcStatus::Cancelled,
7038 "CANCEL must override terminal status",
7039 );
7040 }
7041
7042 /// Duplicate REQUEST with the same `(origin, call_id)` is
7043 /// refused with a synthetic Internal terminal frame and does
7044 /// NOT spawn a second handler. Mirror of the unary fold's
7045 /// regression at server_fold_duplicate_request_refuses_*.
7046 #[tokio::test]
7047 async fn streaming_fold_duplicate_request_refuses_without_double_dispatch() {
7048 let invocations = Arc::new(AtomicUsize::new(0));
7049 struct CountingHandler {
7050 invocations: Arc<AtomicUsize>,
7051 }
7052 #[async_trait::async_trait]
7053 impl RpcStreamingHandler for CountingHandler {
7054 async fn call(
7055 &self,
7056 _ctx: RpcContext,
7057 sink: RpcResponseSink,
7058 ) -> Result<(), RpcHandlerError> {
7059 self.invocations.fetch_add(1, Ordering::SeqCst);
7060 tokio::time::sleep(Duration::from_millis(80)).await;
7061 sink.send(b"chunk".to_vec());
7062 Ok(())
7063 }
7064 }
7065 let (emit, captured) = capturing_async_emitter();
7066 let mut fold = RpcServerStreamingFold::new(
7067 Arc::new(CountingHandler {
7068 invocations: invocations.clone(),
7069 }),
7070 emit,
7071 );
7072 let req = RpcRequestPayload {
7073 service: "x".to_string(),
7074 deadline_ns: 0,
7075 flags: FLAG_RPC_STREAMING_RESPONSE,
7076 headers: vec![],
7077 body: Bytes::new(),
7078 };
7079 fold.apply(&rpc_request_event(1, 99, req.clone()), &mut ())
7080 .unwrap();
7081 assert!(
7082 wait_until(
7083 || fold.in_flight_keys().contains(&(0, 1, 99)),
7084 Duration::from_secs(1)
7085 )
7086 .await
7087 );
7088 // Duplicate REQUEST — must emit a synthetic Internal
7089 // terminal and not invoke the handler a second time.
7090 fold.apply(&rpc_request_event(1, 99, req), &mut ()).unwrap();
7091 assert!(
7092 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(1)).await,
7093 "synthetic refusal should be emitted",
7094 );
7095 // First emit (chronologically) is the synthetic refusal.
7096 let refusal = captured.lock()[0].clone();
7097 assert_eq!(refusal.2.status, RpcStatus::Internal);
7098 assert!(String::from_utf8_lossy(&refusal.2.body).contains("duplicate"));
7099 // Wait for the original handler to complete (chunk + terminal).
7100 assert!(
7101 wait_until(|| captured.lock().len() >= 3, Duration::from_secs(2)).await,
7102 "first handler should still complete normally",
7103 );
7104 assert_eq!(
7105 invocations.load(Ordering::SeqCst),
7106 1,
7107 "duplicate REQUEST must NOT spawn a second handler",
7108 );
7109 }
7110
7111 /// STREAM_GRANT for an unknown call_id is silently dropped
7112 /// (no panic, no tracing event escalation). Pin the
7113 /// always-safe behavior so a misbehaving caller (or a CANCEL/
7114 /// GRANT race) can't crash the fold.
7115 #[tokio::test]
7116 async fn streaming_fold_grant_for_unknown_call_id_is_no_op() {
7117 struct NoopHandler;
7118 #[async_trait::async_trait]
7119 impl RpcStreamingHandler for NoopHandler {
7120 async fn call(
7121 &self,
7122 _ctx: RpcContext,
7123 _sink: RpcResponseSink,
7124 ) -> Result<(), RpcHandlerError> {
7125 Ok(())
7126 }
7127 }
7128 let (emit, captured) = capturing_async_emitter();
7129 let mut fold = RpcServerStreamingFold::new(Arc::new(NoopHandler), emit);
7130 let result = fold.apply(&rpc_stream_grant_event(99, 42, 5), &mut ());
7131 assert!(result.is_ok(), "GRANT for unknown call_id must be Ok");
7132 assert!(captured.lock().is_empty(), "no emit for unknown GRANT");
7133 }
7134
7135 /// Regression for M20: the streaming pump's mpsc is bounded
7136 /// at `STREAMING_PUMP_CAPACITY`. A handler that produces
7137 /// chunks faster than the pump drains gets its excess
7138 /// `sink.send(...)` calls silently dropped (matching the
7139 /// "caller cancelled" semantic) — and the metric counter
7140 /// `streaming_chunks_dropped_total` increments.
7141 ///
7142 /// We construct the sink directly with a tiny bounded mpsc
7143 /// (capacity 2) and a metrics handle, then call `send` 5
7144 /// times without a receiver. The first 2 fit in the channel;
7145 /// the next 3 are dropped and counted.
7146 #[tokio::test]
7147 async fn streaming_sink_drops_on_full_and_increments_metric() {
7148 use crate::adapter::net::mesh_rpc_metrics::{RpcMetricsRegistry, ServiceMetricsAtomic};
7149 // Tiny channel to make overflow easy to observe.
7150 let (tx, _rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(2);
7151 let registry = RpcMetricsRegistry::new();
7152 let metrics: Arc<ServiceMetricsAtomic> = registry.for_service("drop_test");
7153 let sink = RpcResponseSink {
7154 inner: tx,
7155 metrics: Some(metrics.clone()),
7156 };
7157 // 5 sends; first 2 buffer, next 3 drop.
7158 for i in 0..5u8 {
7159 sink.send(vec![i]);
7160 }
7161 assert_eq!(
7162 metrics
7163 .streaming_chunks_dropped_total
7164 .load(Ordering::Relaxed),
7165 3,
7166 "expected 3 dropped chunks (capacity=2, sent 5)",
7167 );
7168 }
7169
7170 /// Malformed REQUEST payload on the streaming fold: emits one
7171 /// terminal `UnknownVersion` frame and continues — same
7172 /// keep-the-adapter-alive contract as the unary fold.
7173 #[tokio::test]
7174 async fn streaming_fold_malformed_payload_emits_unknown_version_terminal() {
7175 struct NoopHandler;
7176 #[async_trait::async_trait]
7177 impl RpcStreamingHandler for NoopHandler {
7178 async fn call(
7179 &self,
7180 _ctx: RpcContext,
7181 _sink: RpcResponseSink,
7182 ) -> Result<(), RpcHandlerError> {
7183 Ok(())
7184 }
7185 }
7186 let (emit, captured) = capturing_async_emitter();
7187 let mut fold = RpcServerStreamingFold::new(Arc::new(NoopHandler), emit);
7188 // Garbage tail: valid meta + 0x00 svc_len → Truncated.
7189 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, 1, 1, 0);
7190 let mut buf = Vec::new();
7191 buf.extend_from_slice(&meta.to_bytes());
7192 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
7193 // feed the folds directly (no ingress select), and the folds
7194 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
7195 encode_rpc_route(&mut buf, 0);
7196 buf.push(0x00);
7197 let ev = RedexEvent {
7198 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
7199 payload: bytes::Bytes::from(buf),
7200 };
7201 let result = fold.apply(&ev, &mut ());
7202 assert!(
7203 result.is_ok(),
7204 "malformed payload must NOT kill the adapter",
7205 );
7206 assert!(
7207 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7208 "synthetic UnknownVersion terminal must arrive",
7209 );
7210 let captured = captured.lock();
7211 assert_eq!(captured[0].2.status, RpcStatus::UnknownVersion);
7212 let hdr = captured[0]
7213 .2
7214 .headers
7215 .iter()
7216 .find(|(n, _)| n == HEADER_NRPC_STREAMING);
7217 assert!(hdr.is_some(), "malformed terminal must include end marker");
7218 }
7219
7220 // ====================================================================
7221 // Phase B — RpcStreamingRequestFold (server-side client-streaming)
7222 // ====================================================================
7223
7224 /// Build a REQUEST_CHUNK event for tests. Mirrors
7225 /// `rpc_request_event` / `rpc_stream_grant_event` shape.
7226 fn rpc_request_chunk_event(
7227 caller_origin: u64,
7228 call_id: u64,
7229 flags: u16,
7230 body: Vec<u8>,
7231 ) -> RedexEvent {
7232 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_CHUNK, 0, caller_origin, call_id, 0);
7233 let payload = RpcRequestChunkPayload {
7234 call_id,
7235 flags,
7236 headers: vec![],
7237 body: body.into(),
7238 };
7239 let mut buf = Vec::new();
7240 buf.extend_from_slice(&meta.to_bytes());
7241 // OA2-E0.2: RpcRouteV1 route placeholder — these test frames
7242 // feed the folds directly (no ingress select), and the folds
7243 // skip the route to reach the payload at RPC_FRAME_BODY_OFFSET.
7244 encode_rpc_route(&mut buf, 0);
7245 buf.extend_from_slice(&payload.encode());
7246 RedexEvent {
7247 entry: RedexEntry::new_heap(0, 0, buf.len() as u32, 0, 0),
7248 payload: bytes::Bytes::from(buf),
7249 }
7250 }
7251
7252 /// Collecting client-streaming handler: drains the stream into
7253 /// a Vec, returns an Ok response whose body is the count of
7254 /// chunks seen (8-byte LE). Captured chunk bodies are exposed
7255 /// via the `Arc<Mutex<Vec<Bytes>>>` so tests can assert
7256 /// ordering and content.
7257 struct CollectingClientStreamHandler {
7258 seen: Arc<Mutex<Vec<bytes::Bytes>>>,
7259 observed_cancel: Arc<AtomicBool>,
7260 }
7261 #[async_trait::async_trait]
7262 impl RpcClientStreamingHandler for CollectingClientStreamHandler {
7263 async fn call(
7264 &self,
7265 ctx: RpcStreamingContext,
7266 mut requests: RequestStream,
7267 ) -> Result<RpcResponsePayload, RpcHandlerError> {
7268 use futures::StreamExt;
7269 while let Some(chunk) = requests.next().await {
7270 self.seen.lock().push(chunk);
7271 }
7272 // Re-check cancellation after EOF so the test can
7273 // distinguish "clean REQUEST_END" from "CANCEL closed
7274 // the stream early".
7275 if ctx.cancellation.is_cancelled() {
7276 self.observed_cancel
7277 .store(true, std::sync::atomic::Ordering::SeqCst);
7278 }
7279 let count = self.seen.lock().len() as u64;
7280 Ok(RpcResponsePayload {
7281 status: RpcStatus::Ok,
7282 headers: vec![],
7283 body: Bytes::copy_from_slice(&count.to_le_bytes()),
7284 })
7285 }
7286 }
7287
7288 /// 1/6 — happy path: REQUEST + 3 REQUEST_CHUNKs (last has
7289 /// FLAG_END) delivers 4 bodies to the handler in order; the
7290 /// fold emits exactly one terminal RESPONSE carrying the
7291 /// handler's reply.
7292 #[tokio::test]
7293 async fn streaming_request_fold_collects_all_chunks_and_emits_terminal_response() {
7294 let seen = Arc::new(Mutex::new(Vec::new()));
7295 let observed_cancel = Arc::new(AtomicBool::new(false));
7296 let (emit, captured) = capturing_emitter();
7297 let mut fold = RpcStreamingRequestFold::new(
7298 Arc::new(CollectingClientStreamHandler {
7299 seen: seen.clone(),
7300 observed_cancel: observed_cancel.clone(),
7301 }),
7302 emit,
7303 );
7304 // REQUEST with the client-streaming flag, body = "a".
7305 let req = RpcRequestPayload {
7306 service: "agg".to_string(),
7307 deadline_ns: 0,
7308 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST,
7309 headers: vec![],
7310 body: Bytes::from_static(b"a"),
7311 };
7312 fold.apply(&rpc_request_event(0xCAFE, 7, req), &mut ())
7313 .unwrap();
7314 // Wait until the sender is registered (handler task has
7315 // picked up the request and the apply path completed).
7316 assert!(
7317 wait_until(
7318 || fold.sender_keys().contains(&(0, 0xCAFE, 7)),
7319 Duration::from_secs(1)
7320 )
7321 .await
7322 );
7323 // Three more chunks; last sets FLAG_REQUEST_END.
7324 fold.apply(
7325 &rpc_request_chunk_event(0xCAFE, 7, 0, b"b".to_vec()),
7326 &mut (),
7327 )
7328 .unwrap();
7329 fold.apply(
7330 &rpc_request_chunk_event(0xCAFE, 7, 0, b"c".to_vec()),
7331 &mut (),
7332 )
7333 .unwrap();
7334 fold.apply(
7335 &rpc_request_chunk_event(0xCAFE, 7, FLAG_RPC_REQUEST_END, b"d".to_vec()),
7336 &mut (),
7337 )
7338 .unwrap();
7339 // Handler should observe 4 bodies in order and emit one
7340 // terminal RESPONSE whose body encodes the count.
7341 assert!(
7342 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7343 "expected terminal RESPONSE"
7344 );
7345 let captured = captured.lock();
7346 assert_eq!(captured.len(), 1, "exactly one terminal RESPONSE");
7347 let (origin, call_id, resp) = &captured[0];
7348 assert_eq!(*origin, 0xCAFE);
7349 assert_eq!(*call_id, 7);
7350 assert_eq!(resp.status, RpcStatus::Ok);
7351 assert_eq!(resp.body.as_ref(), 4u64.to_le_bytes());
7352 // And the chunks landed in order.
7353 let seen = seen.lock();
7354 let collected: Vec<&[u8]> = seen.iter().map(|b| b.as_ref()).collect();
7355 assert_eq!(collected, vec![b"a", b"b", b"c", b"d"]);
7356 assert!(
7357 !observed_cancel.load(std::sync::atomic::Ordering::SeqCst),
7358 "clean REQUEST_END must NOT register as a cancellation"
7359 );
7360 }
7361
7362 /// 2/6 — degenerate case: initial REQUEST with both the
7363 /// client-streaming AND request-end flags set. Handler sees
7364 /// exactly one body (the REQUEST's own body) and EOF — the
7365 /// "one-item upload" fast path that saves a trailing CHUNK
7366 /// event.
7367 #[tokio::test]
7368 async fn streaming_request_fold_initial_request_with_end_flag_yields_single_item() {
7369 let seen = Arc::new(Mutex::new(Vec::new()));
7370 let observed_cancel = Arc::new(AtomicBool::new(false));
7371 let (emit, captured) = capturing_emitter();
7372 let mut fold = RpcStreamingRequestFold::new(
7373 Arc::new(CollectingClientStreamHandler {
7374 seen: seen.clone(),
7375 observed_cancel,
7376 }),
7377 emit,
7378 );
7379 let req = RpcRequestPayload {
7380 service: "agg".to_string(),
7381 deadline_ns: 0,
7382 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_REQUEST_END,
7383 headers: vec![],
7384 body: Bytes::from_static(b"only"),
7385 };
7386 fold.apply(&rpc_request_event(1, 42, req), &mut ()).unwrap();
7387 assert!(
7388 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7389 "expected terminal RESPONSE"
7390 );
7391 let captured = captured.lock();
7392 assert_eq!(captured.len(), 1);
7393 assert_eq!(captured[0].2.status, RpcStatus::Ok);
7394 assert_eq!(captured[0].2.body.as_ref(), 1u64.to_le_bytes());
7395 assert_eq!(
7396 seen.lock()
7397 .iter()
7398 .map(|b| b.as_ref())
7399 .collect::<Vec<&[u8]>>(),
7400 vec![b"only" as &[u8]]
7401 );
7402 // Sender must NOT have been registered (initial-REQUEST-
7403 // with-END skips the map insert).
7404 assert!(fold.sender_keys().is_empty());
7405 }
7406
7407 /// 3/6 — CANCEL closes the request stream early, flips the
7408 /// cancellation token, and the spawned task overrides the
7409 /// handler's terminal with `RpcStatus::Cancelled`.
7410 #[tokio::test]
7411 async fn streaming_request_fold_cancel_closes_stream_and_overrides_terminal() {
7412 let seen = Arc::new(Mutex::new(Vec::new()));
7413 let observed_cancel = Arc::new(AtomicBool::new(false));
7414 let (emit, captured) = capturing_emitter();
7415 let mut fold = RpcStreamingRequestFold::new(
7416 Arc::new(CollectingClientStreamHandler {
7417 seen: seen.clone(),
7418 observed_cancel: observed_cancel.clone(),
7419 }),
7420 emit,
7421 );
7422 let req = RpcRequestPayload {
7423 service: "agg".to_string(),
7424 deadline_ns: 0,
7425 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST,
7426 headers: vec![],
7427 body: Bytes::from_static(b"first"),
7428 };
7429 fold.apply(&rpc_request_event(2, 17, req), &mut ()).unwrap();
7430 // Wait for the handler to register, then one in-flight
7431 // CHUNK, then CANCEL before the handler ever finishes
7432 // draining.
7433 assert!(
7434 wait_until(
7435 || fold.sender_keys().contains(&(0, 2, 17)),
7436 Duration::from_secs(1)
7437 )
7438 .await
7439 );
7440 fold.apply(
7441 &rpc_request_chunk_event(2, 17, 0, b"second".to_vec()),
7442 &mut (),
7443 )
7444 .unwrap();
7445 fold.apply(&rpc_cancel_event(2, 17), &mut ()).unwrap();
7446 // Terminal must arrive and must be Cancelled (CANCEL-wins
7447 // ordering, same as the response-side fold).
7448 assert!(
7449 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7450 "expected terminal RESPONSE"
7451 );
7452 let captured = captured.lock();
7453 assert_eq!(captured.len(), 1);
7454 assert_eq!(
7455 captured[0].2.status,
7456 RpcStatus::Cancelled,
7457 "CANCEL must override terminal status"
7458 );
7459 assert!(
7460 observed_cancel.load(std::sync::atomic::Ordering::SeqCst),
7461 "handler must observe cancellation token after stream EOF"
7462 );
7463 // Both maps must be clean post-cancel.
7464 assert!(fold.in_flight_keys().is_empty());
7465 assert!(fold.sender_keys().is_empty());
7466 }
7467
7468 /// 4/6 — handler returns `Err(RpcHandlerError::Application)`
7469 /// → terminal RESPONSE carries the application status code +
7470 /// message body.
7471 #[tokio::test]
7472 async fn streaming_request_fold_application_error_round_trips() {
7473 struct AppErrHandler;
7474 #[async_trait::async_trait]
7475 impl RpcClientStreamingHandler for AppErrHandler {
7476 async fn call(
7477 &self,
7478 _ctx: RpcStreamingContext,
7479 mut requests: RequestStream,
7480 ) -> Result<RpcResponsePayload, RpcHandlerError> {
7481 use futures::StreamExt;
7482 // Drain so the stream's EOF doesn't race the
7483 // error return.
7484 while requests.next().await.is_some() {}
7485 Err(RpcHandlerError::Application {
7486 code: 0xBEEF,
7487 message: "bad input".to_string(),
7488 })
7489 }
7490 }
7491 let (emit, captured) = capturing_emitter();
7492 let mut fold = RpcStreamingRequestFold::new(Arc::new(AppErrHandler), emit);
7493 let req = RpcRequestPayload {
7494 service: "agg".to_string(),
7495 deadline_ns: 0,
7496 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_REQUEST_END,
7497 headers: vec![],
7498 body: Bytes::new(),
7499 };
7500 fold.apply(&rpc_request_event(3, 100, req), &mut ())
7501 .unwrap();
7502 assert!(
7503 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7504 "expected terminal RESPONSE"
7505 );
7506 let captured = captured.lock();
7507 assert_eq!(captured.len(), 1);
7508 assert_eq!(captured[0].2.status, RpcStatus::Application(0xBEEF));
7509 assert_eq!(captured[0].2.body.as_ref(), b"bad input");
7510 }
7511
7512 /// 5/6 — handler panic is caught by `catch_unwind`; terminal
7513 /// surfaces as `Internal` carrying the panic message. Same
7514 /// contract as the existing folds — a misbehaving handler
7515 /// can't take down the cortex adapter.
7516 #[tokio::test]
7517 async fn streaming_request_fold_handler_panic_surfaces_as_internal() {
7518 struct PanickyHandler;
7519 #[async_trait::async_trait]
7520 impl RpcClientStreamingHandler for PanickyHandler {
7521 async fn call(
7522 &self,
7523 _ctx: RpcStreamingContext,
7524 _requests: RequestStream,
7525 ) -> Result<RpcResponsePayload, RpcHandlerError> {
7526 panic!("intentional test panic");
7527 }
7528 }
7529 let (emit, captured) = capturing_emitter();
7530 let mut fold = RpcStreamingRequestFold::new(Arc::new(PanickyHandler), emit);
7531 let req = RpcRequestPayload {
7532 service: "agg".to_string(),
7533 deadline_ns: 0,
7534 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_REQUEST_END,
7535 headers: vec![],
7536 body: Bytes::new(),
7537 };
7538 fold.apply(&rpc_request_event(4, 200, req), &mut ())
7539 .unwrap();
7540 assert!(
7541 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(2)).await,
7542 "expected terminal RESPONSE"
7543 );
7544 let captured = captured.lock();
7545 assert_eq!(captured.len(), 1);
7546 assert_eq!(captured[0].2.status, RpcStatus::Internal);
7547 assert!(
7548 String::from_utf8_lossy(&captured[0].2.body).contains("intentional test panic"),
7549 "panic body should carry the panic message"
7550 );
7551 }
7552
7553 /// 6/6 — duplicate REQUEST with the same `(origin, call_id)`
7554 /// is refused with a synthetic `Internal` terminal frame and
7555 /// does NOT spawn a second handler. Mirror of the regression
7556 /// pinned in the unary + response-streaming folds.
7557 #[tokio::test]
7558 async fn streaming_request_fold_duplicate_request_refuses_without_double_dispatch() {
7559 let invocations = Arc::new(AtomicUsize::new(0));
7560 struct CountingHandler {
7561 invocations: Arc<AtomicUsize>,
7562 }
7563 #[async_trait::async_trait]
7564 impl RpcClientStreamingHandler for CountingHandler {
7565 async fn call(
7566 &self,
7567 _ctx: RpcStreamingContext,
7568 mut requests: RequestStream,
7569 ) -> Result<RpcResponsePayload, RpcHandlerError> {
7570 use futures::StreamExt;
7571 self.invocations.fetch_add(1, Ordering::SeqCst);
7572 // Slow handler to keep the call in-flight while
7573 // the duplicate REQUEST arrives.
7574 tokio::time::sleep(Duration::from_millis(80)).await;
7575 while requests.next().await.is_some() {}
7576 Ok(RpcResponsePayload {
7577 status: RpcStatus::Ok,
7578 headers: vec![],
7579 body: Bytes::new(),
7580 })
7581 }
7582 }
7583 let (emit, captured) = capturing_emitter();
7584 let mut fold = RpcStreamingRequestFold::new(
7585 Arc::new(CountingHandler {
7586 invocations: invocations.clone(),
7587 }),
7588 emit,
7589 );
7590 let req = RpcRequestPayload {
7591 service: "agg".to_string(),
7592 deadline_ns: 0,
7593 flags: FLAG_RPC_CLIENT_STREAMING_REQUEST,
7594 headers: vec![],
7595 body: Bytes::new(),
7596 };
7597 fold.apply(&rpc_request_event(5, 99, req.clone()), &mut ())
7598 .unwrap();
7599 assert!(
7600 wait_until(
7601 || fold.in_flight_keys().contains(&(0, 5, 99)),
7602 Duration::from_secs(1)
7603 )
7604 .await
7605 );
7606 // Duplicate REQUEST: synthetic Internal terminal emitted,
7607 // handler invocation count must stay at 1.
7608 fold.apply(&rpc_request_event(5, 99, req), &mut ()).unwrap();
7609 assert!(
7610 wait_until(|| !captured.lock().is_empty(), Duration::from_secs(1)).await,
7611 "synthetic refusal terminal expected"
7612 );
7613 let refusal = captured.lock()[0].clone();
7614 assert_eq!(refusal.2.status, RpcStatus::Internal);
7615 assert!(String::from_utf8_lossy(&refusal.2.body).contains("duplicate"));
7616 // Finish the first handler so its terminal lands too.
7617 fold.apply(
7618 &rpc_request_chunk_event(5, 99, FLAG_RPC_REQUEST_END, vec![]),
7619 &mut (),
7620 )
7621 .unwrap();
7622 assert!(
7623 wait_until(|| captured.lock().len() >= 2, Duration::from_secs(2)).await,
7624 "first handler should still complete normally"
7625 );
7626 assert_eq!(
7627 invocations.load(Ordering::SeqCst),
7628 1,
7629 "duplicate REQUEST must NOT spawn a second handler",
7630 );
7631 }
7632}