net/adapter/net/mesh_rpc.rs
1//! `Mesh::serve_rpc` / `Mesh::call` glue — the wire-up between
2//! `MeshNode`'s pub/sub + per-channel-hash dispatch hook and the
3//! `cortex::rpc` server / client folds.
4//!
5//! See `docs/misc/NRPC_DESIGN.md` for the full architectural framing.
6//! In short:
7//!
8//! - `serve_rpc(service, handler)` registers an inbound dispatcher
9//! for `<service>.requests`'s channel hash. The dispatcher pushes
10//! inbound REQUEST/CANCEL events through the
11//! [`crate::adapter::net::cortex::RpcServerFold`], which spawns
12//! the user handler. The fold's emit closure publishes RESPONSE
13//! events on `<service>.replies.<caller_origin>` via
14//! [`MeshNode::publish`].
15//!
16//! - `call(target, service, payload, opts)` allocates a `call_id`,
17//! registers a oneshot in the per-Mesh `RpcClientPending`,
18//! subscribes to its own reply channel from `target` (lazy,
19//! cached), publishes the REQUEST envelope on `<service>.requests`,
20//! awaits the oneshot. Drop sends a CANCEL.
21//!
22//! Phase 1 surface — direct entity-to-entity addressing
23//! (`call(target_node_id, ...)`), no service discovery layer yet.
24//! Phase 2 will add `call_service(name, ...)` over the existing
25//! capability-announcement registry.
26
27use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
28use std::sync::Arc;
29use std::time::Instant;
30
31use bytes::Bytes;
32use parking_lot::Mutex;
33use tokio::sync::mpsc;
34use tokio::task::JoinHandle;
35
36use super::channel::{ChannelHash, ChannelId, ChannelName, ChannelPublisher, PublishConfig};
37use super::cortex::{
38 build_trace_headers, encode_request_grant, encode_stream_grant, EventMeta,
39 RpcAsyncResponseEmitter, RpcCancellationToken, RpcClientFold, RpcClientStreamingHandler,
40 RpcContext, RpcDuplexFold, RpcDuplexHandler, RpcHandler, RpcHandlerError, RpcInboundDispatcher,
41 RpcInboundEvent, RpcRequestChunkPayload, RpcRequestGrantEmitter, RpcRequestPayload,
42 RpcResponseEmitter, RpcResponsePayload, RpcServerFold, RpcServerStreamingFold, RpcStatus,
43 RpcStreamingHandler, RpcStreamingRequestFold, StreamItem, TraceContext, DISPATCH_RPC_CANCEL,
44 DISPATCH_RPC_REQUEST, DISPATCH_RPC_REQUEST_CHUNK, DISPATCH_RPC_REQUEST_GRANT,
45 DISPATCH_RPC_STREAM_GRANT, EVENT_META_SIZE, FLAG_RPC_CLIENT_STREAMING_REQUEST,
46 FLAG_RPC_PROPAGATE_TRACE, FLAG_RPC_REQUEST_END, FLAG_RPC_STREAMING_RESPONSE,
47 HEADER_NRPC_REQUEST_WINDOW_INITIAL, HEADER_NRPC_STREAM_WINDOW_INITIAL,
48};
49use super::mesh_rpc_metrics::{CallMetricsGuard, CallOutcome};
50use crate::error::AdapterError;
51
52use super::mesh::MeshNode;
53use super::redex::{RedexEntry, RedexEvent, RedexFold};
54
55// ============================================================================
56// Public types.
57// ============================================================================
58
59/// How `Mesh::call_service` picks a target from the set of nodes
60/// advertising the requested service.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub enum RoutingPolicy {
63 /// Naive round-robin via the per-Mesh `call_id` counter.
64 /// Distributes calls evenly across candidates regardless of
65 /// load. The default.
66 #[default]
67 RoundRobin,
68 /// Pick a candidate at random per call. Stateless, cheap, and
69 /// gives even distribution under independent calls.
70 Random,
71 /// Consistent-hash to a target by `key`. Same `key` always
72 /// hits the same target as long as the candidate set is
73 /// stable. Useful for session affinity (route a given
74 /// conversation / shard / user to the same backend).
75 Sticky {
76 /// Caller-supplied identifier — hash maps this to the
77 /// target. Use a session id, shard key, or conversation
78 /// id depending on the application.
79 key: u64,
80 },
81 /// Pick the candidate with the smallest measured `latency_us`
82 /// per the local `ProximityGraph`. Candidates the proximity
83 /// graph hasn't observed yet (no entity ↔ node_id mapping or
84 /// no pingwave received) sort to the bottom — better to pick
85 /// a known-fast node than gamble on an unknown one.
86 ///
87 /// Falls back deterministically to the first sorted candidate
88 /// when no candidates have proximity data, so a freshly-
89 /// discovered service still routes consistently.
90 LowestLatency,
91}
92
93/// Options for [`MeshNode::call`] and [`MeshNode::call_service`].
94#[derive(Debug, Clone)]
95pub struct CallOptions {
96 /// Hard deadline for the call. The future returned by `call`
97 /// races a `tokio::time::sleep_until`; whichever fires first
98 /// wins. On timeout the caller emits a CANCEL event for
99 /// `call_id` so the server can drop the in-flight handler.
100 /// `None` means no deadline; the caller waits indefinitely
101 /// (or until the future is dropped).
102 pub deadline: Option<Instant>,
103 /// How `call_service` picks a target. Ignored by `call`
104 /// (which takes an explicit `target_node_id`). Default:
105 /// `RoundRobin`.
106 pub routing_policy: RoutingPolicy,
107 /// Skip candidates whose `ProximityGraph` entry reports
108 /// `!is_available()` (i.e. `Unhealthy` or `Unknown`).
109 /// Default `true`. Candidates with no proximity entry at all
110 /// are KEPT — absence of evidence is not evidence of
111 /// unhealth, and a freshly-announced service shouldn't be
112 /// filtered just because pingwaves haven't propagated yet.
113 pub filter_unhealthy: bool,
114 /// W3C Trace Context to propagate to the server. When `Some`,
115 /// the call sets `FLAG_RPC_PROPAGATE_TRACE` on the request and
116 /// emits `traceparent` / `tracestate` headers; the server's
117 /// `RpcContext::trace_context` will be populated with the same
118 /// values. nRPC is transport-only — application code on both
119 /// sides reads / writes this via whatever tracing backend it
120 /// has wired up (tracing-opentelemetry, Datadog, etc.).
121 pub trace_context: Option<TraceContext>,
122 /// Per-call concurrency cap. Future Phase 2 work; v1 ignores
123 /// this and the per-Mesh `RpcClientPending` doesn't bound
124 /// in-flight count.
125 pub max_in_flight_per_target: u32,
126 /// **Streaming responses only.** Initial credit window for
127 /// per-streaming-response flow control. When `Some(n)`, the
128 /// caller emits `nrpc-stream-window-initial: n` on the
129 /// REQUEST and the server's pump task awaits one credit per
130 /// emitted chunk. The returned [`RpcStream`] auto-grants 1
131 /// credit per consumed chunk so the in-flight credit holds
132 /// near `n` (or use [`RpcStream::grant`] for batched / custom
133 /// cadence). `None` (the default) → unbounded: server pumps
134 /// chunks as fast as the publish path can take them
135 /// (back-compat / pre-flow-control behavior). Ignored by
136 /// non-streaming `call` / `call_service`.
137 pub stream_window_initial: Option<u32>,
138 /// **Client-streaming / duplex only.** Initial credit window
139 /// for per-call request-direction flow control. Mirror of
140 /// [`Self::stream_window_initial`] for the upload direction. When
141 /// `Some(n)`, the caller emits `nrpc-request-window-initial: n`
142 /// on the REQUEST and its `send().await` sink awaits one
143 /// credit per pushed chunk; the server refills via
144 /// [`DISPATCH_RPC_REQUEST_GRANT`] events. `None` → unbounded:
145 /// caller's send sink doesn't block (legacy / fast-path).
146 /// Ignored by unary `call` / `call_streaming`.
147 ///
148 /// Bidi streaming plan (Phase C).
149 pub request_window_initial: Option<u32>,
150 /// Caller-supplied request headers. Appended to the wire
151 /// `RpcRequestPayload::headers` after any auto-generated
152 /// headers (trace context, stream-window). Useful for
153 /// application-level metadata the server needs at
154 /// dispatch-time — e.g., the `net-where` predicate
155 /// header (Phase 9b of `CAPABILITY_SYSTEM_SDK_PLAN.md`) that
156 /// services consult for predicate-pushdown filtering.
157 ///
158 /// Each entry is `(name, value_bytes)`. Names use the lowercase
159 /// `cyberdeck-*` / `nrpc-*` convention; the substrate doesn't
160 /// validate names beyond the `MAX_RPC_HEADER_NAME_LEN` cap
161 /// enforced at encode time.
162 ///
163 /// Default: empty.
164 pub request_headers: Vec<(String, Vec<u8>)>,
165 /// Caller-side cancel token. Mint via
166 /// [`MeshNode::reserve_cancel_token`]; pair with
167 /// [`MeshNode::cancel`] from any thread to abort the in-flight
168 /// call. `None` (or `Some(0)` — the "no token" sentinel) → no
169 /// cancel slot is reserved and the call has no external abort
170 /// path beyond Drop-on-future-cancellation.
171 ///
172 /// Honored uniformly by every call shape: `call`, `call_service`,
173 /// `call_streaming`, `call_client_stream`, `call_duplex`. The
174 /// substrate registers the token in a per-mesh cancel registry
175 /// at call construction and removes it on resolution (success,
176 /// error, or Drop). A cancel that fires mid-flight surfaces to
177 /// the caller as [`RpcError::Cancelled`] and emits CANCEL on
178 /// the wire via the existing per-call-shape guards (UnaryCallGuard,
179 /// ClientStreamCallRaw::Drop, DuplexCallRaw::Drop).
180 ///
181 /// Cancel-before-register is race-safe: a cancel that arrives
182 /// in the gap between `reserve_cancel_token` and the call's
183 /// internal register step latches a pre-cancel flag on the
184 /// registry's orphan entry; the subsequent register observes
185 /// it and the call short-circuits to [`RpcError::Cancelled`]
186 /// without ever publishing the REQUEST.
187 pub cancel_token: Option<u64>,
188}
189
190impl Default for CallOptions {
191 fn default() -> Self {
192 Self {
193 deadline: None,
194 routing_policy: RoutingPolicy::default(),
195 filter_unhealthy: true,
196 trace_context: None,
197 max_in_flight_per_target: 64,
198 stream_window_initial: None,
199 request_window_initial: None,
200 request_headers: Vec::new(),
201 cancel_token: None,
202 }
203 }
204}
205
206/// What [`MeshNode::call`] returns on success.
207#[derive(Debug, Clone)]
208pub struct RpcReply {
209 /// Response payload from the server's handler. Caller decodes
210 /// according to its application protocol.
211 pub body: Bytes,
212 /// Headers attached by the server's response.
213 pub headers: Vec<(String, Vec<u8>)>,
214 /// Wall-clock latency from `call(...)` to RESPONSE arrival.
215 pub latency_ns: u64,
216}
217
218/// What [`MeshNode::call`] returns on failure.
219#[derive(Debug, thiserror::Error)]
220pub enum RpcError {
221 /// No subscription / no route to the target. Either
222 /// `target_node_id` is unknown to the local mesh, or the
223 /// caller's reply-channel subscription couldn't be set up.
224 #[error("no route to target {target:#x}: {reason}")]
225 NoRoute {
226 /// Target node id the call was directed at.
227 target: u64,
228 /// Diagnostic — typically the underlying transport error.
229 reason: String,
230 },
231 /// Caller's deadline elapsed before a RESPONSE arrived. The
232 /// caller emits a CANCEL on timeout so the server can drop
233 /// the in-flight handler; this variant is returned to the
234 /// awaiting caller.
235 #[error("timeout after {elapsed_ms}ms")]
236 Timeout {
237 /// Wall-clock milliseconds elapsed before timeout fired.
238 elapsed_ms: u64,
239 },
240 /// Server returned a non-`Ok` status. Body carries the
241 /// server's diagnostic (UTF-8) when available.
242 #[error("server returned status {status:#06x}: {message}")]
243 ServerError {
244 /// Wire-level `RpcStatus` value the server returned.
245 status: u16,
246 /// UTF-8 diagnostic from the response body, when the body
247 /// decodes as valid UTF-8; otherwise hex-truncated.
248 message: String,
249 /// Reply headers from the error response — the wire has always
250 /// carried them (same frame field as success replies); the
251 /// caller used to discard them here. Empty when the server
252 /// attached none. The message stays the human diagnostic;
253 /// headers are the structured sidecar channel (e.g. a
254 /// `net-failure-schematic` verdict).
255 headers: Vec<(String, Vec<u8>)>,
256 },
257 /// Underlying transport error (publish failure, encryption,
258 /// etc.).
259 #[error("transport: {0}")]
260 Transport(#[from] AdapterError),
261 /// Client-local serialization or deserialization failure.
262 /// `direction = Encode` means the typed wrapper failed to
263 /// encode the request before it ever hit the wire;
264 /// `direction = Decode` means the response landed but the
265 /// typed wrapper failed to decode it. Either way this is a
266 /// caller-fixable bug (wrong codec, schema drift, malformed
267 /// `Serialize` impl) — NOT a transient infra failure — so
268 /// retry / circuit-breaker predicates skip it by default.
269 #[error("codec ({direction:?}): {message}")]
270 Codec {
271 /// Which side of the call the codec failure happened on.
272 direction: CodecDirection,
273 /// Decode/encode diagnostic from the underlying serde impl.
274 message: String,
275 },
276 /// v0.4 capability-auth gate denied the call. Either the
277 /// target's latest `CapabilityAnnouncement` does not list
278 /// the requested `nrpc:<service>` tag, or it lists the tag
279 /// with allow-lists the caller does not match. See
280 /// `docs/plans/CAPABILITY_AUTH_PLAN.md` §3 for the model.
281 ///
282 /// Raised by the caller-side gate inside
283 /// [`MeshNode::call_service`] BEFORE the request hits the
284 /// wire, and surfaced by the caller on receipt of a
285 /// `RpcStatus::CapabilityDenied` response (the callee-side
286 /// defense-in-depth path).
287 #[error("capability denied: target {target:#x} does not authorize nrpc:{capability}")]
288 CapabilityDenied {
289 /// Target node id the gate denied.
290 target: u64,
291 /// Service / capability tag (without the `nrpc:` prefix)
292 /// the gate denied.
293 capability: String,
294 },
295 /// Caller-side cancellation fired via
296 /// [`MeshNode::cancel`] with the call's `cancel_token`.
297 /// Triggers a Drop-on-cancel CANCEL frame on the wire so the
298 /// server's in-flight handler observes the cancel; the
299 /// awaiting caller returns this variant. NOT retried by the
300 /// default retry policy — cancellation is caller-driven and
301 /// re-issuing the call defeats the point.
302 #[error("call cancelled by caller")]
303 Cancelled,
304}
305
306/// Which side of the call surfaced a [`RpcError::Codec`] failure.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum CodecDirection {
309 /// Encoding the outbound request failed before the call was issued.
310 Encode,
311 /// Decoding the inbound response failed after the call returned Ok.
312 Decode,
313}
314
315/// RAII handle returned by [`MeshNode::serve_rpc`]. Dropping it
316/// unregisters the inbound dispatcher and removes the service
317/// from the local-services registry (so subsequent
318/// `announce_capabilities` calls stop emitting the
319/// `nrpc:<service>` tag).
320///
321/// **Bridge task lifecycle.** The bridge task that drains the
322/// inbound mpsc into the fold is NOT aborted on Drop. The
323/// `register_rpc_inbound` dispatcher closure owns the only
324/// `mpsc::Sender` clone, so `unregister_rpc_inbound` (which drops
325/// the dispatcher) closes the channel; the bridge's `rx.recv()`
326/// then yields `None` and the task exits cleanly after draining
327/// any queued events. Aborting would race events that are
328/// mid-`fold.lock().apply()` — those events would be killed
329/// without their RESPONSE being emitted, so the corresponding
330/// callers would just time out.
331///
332/// Outstanding handler executions (already-spawned tokio tasks)
333/// continue to completion regardless.
334pub struct ServeHandle {
335 /// Channel hash to unregister on Drop.
336 channel_hash: ChannelHash,
337 /// Service name to remove from `rpc_local_services` on Drop.
338 service: String,
339 /// The bridge task. Held only so callers can introspect /
340 /// detach it; Drop does NOT abort it (see struct doc-comment).
341 /// Detaches naturally when the handle is dropped — the bridge
342 /// exits on its own once the dispatcher's `mpsc::Sender` is
343 /// dropped via `unregister_rpc_inbound`.
344 _bridge: JoinHandle<()>,
345 /// The per-service response drainer task (unary `serve_rpc` only;
346 /// `None` for the streaming/duplex variants, which still spawn per
347 /// emit). Like `_bridge`, held only to detach — it exits on its own
348 /// once the emit closure (the sole `Sender` owner, dropped when the
349 /// bridge task ends and the fold drops) is gone. See §8a.
350 _response_drain: Option<JoinHandle<()>>,
351 /// Hold an Arc back to the mesh so we can unregister on Drop
352 /// without the mesh having to track us.
353 mesh: Arc<MeshNode>,
354}
355
356impl Drop for ServeHandle {
357 fn drop(&mut self) {
358 // Order matters: unregister the dispatcher FIRST so no new
359 // events can land in the bridge's mpsc, THEN drop the
360 // service-tag entry. The bridge task drains any in-flight
361 // events naturally and exits when its `rx.recv()` yields
362 // `None` (which happens as soon as the dispatcher closure
363 // — the sole `tx` owner — is dropped above).
364 self.mesh.unregister_rpc_inbound(self.channel_hash);
365 self.mesh.rpc_local_services_arc().remove(&self.service);
366 }
367}
368
369/// A response ready to publish, handed from a (synchronous) `serve_rpc`
370/// emit closure to the per-service response drainer task. Replaces the
371/// pre-§8a `tokio::spawn`-per-response: the emit closure builds the wire
372/// payload (cheap, sync) and `try_send`s this job; one drain task does the
373/// `.await` publish. The reply `ChannelName` is `Arc<str>` and `payload` is
374/// `Bytes`, so the hand-off is a couple of moves — no copy, no per-response
375/// task allocation/scheduling.
376struct RpcResponseJob {
377 caller_origin: u64,
378 call_id: u64,
379 target_hint: Option<u64>,
380 reply_channel: ChannelName,
381 /// PERF_AUDIT §3.10 — cached
382 /// `ChannelId::new(reply_channel).hash()`, populated by the
383 /// emit closure's `reply_channel_cache` lookup. Pre-fix the
384 /// drainer re-ran xxh3 over the channel name per response;
385 /// the same `OriginKeyedLru` now caches the triple so a
386 /// cache hit is one Arc bump + two `u64` copies.
387 reply_channel_hash: ChannelHash,
388 /// PERF_AUDIT §3.10 — cached
389 /// `MeshNode::publish_stream_id(&reply_channel_id)`.
390 reply_stream_id: u64,
391 payload: Bytes,
392}
393
394/// Cached triple `(ChannelName, ChannelHash, stream_id)` for the
395/// per-caller reply channel. Stored in the per-`serve_rpc`
396/// `OriginKeyedLru` so each subsequent response to the same
397/// caller is one Arc bump on the name + two `u64` copies — no
398/// xxh3, no `publish_stream_id`.
399///
400/// Per PERF_AUDIT §3.10.
401#[derive(Clone)]
402struct CachedReplyChannel {
403 name: ChannelName,
404 hash: ChannelHash,
405 stream_id: u64,
406}
407
408// ============================================================================
409// Streaming caller-side: RpcStream.
410// ============================================================================
411
412/// An open streaming RPC call. Implements `Stream<Item =
413/// Result<Bytes, RpcError>>` — yields chunks as the server emits
414/// them, terminates on a clean stream-end frame OR a non-`Ok`
415/// status (which is yielded as the last `Err` item before the
416/// stream closes).
417///
418/// Dropping the stream emits a CANCEL to the server (best-effort)
419/// and discards the pending entry — any chunks the server emits
420/// after the drop are silently discarded by the client fold.
421pub struct RpcStream {
422 mesh: Arc<MeshNode>,
423 target_node_id: u64,
424 request_channel: ChannelName,
425 /// Cached `ChannelId::new(request_channel).hash()`. Pre-fix
426 /// `spawn_grant_publish` re-ran xxh3 over the channel name on
427 /// every auto/explicit grant — per PERF_AUDIT §3.10 the value
428 /// is invariant for the stream's lifetime, so we cache it once
429 /// at construction.
430 request_channel_hash: ChannelHash,
431 /// Cached `MeshNode::publish_stream_id(&request_channel_id)`.
432 /// Same reasoning as `request_channel_hash`.
433 request_stream_id: u64,
434 self_origin: u64,
435 call_id: u64,
436 inner: tokio::sync::mpsc::UnboundedReceiver<StreamItem>,
437 /// Set true once we've yielded the terminal item (or an
438 /// error). Subsequent polls return `None`.
439 done: bool,
440 /// `Some(_)` if this stream uses flow control (caller set
441 /// `CallOptions::stream_window_initial`). Auto-grant
442 /// accumulates 1 credit per delivered chunk and fires one
443 /// batched `spawn_grant_publish` once the accumulator reaches
444 /// `window / 2` (or 1 for tiny windows). Keeps the server's
445 /// pump fed at roughly the configured rate without the per-
446 /// chunk spawn-storm + AEAD-storm the pre-fix path produced.
447 /// `None` → no flow control; `poll_next` does not emit grants.
448 /// Per PERF_AUDIT_2026_06_10_FULL_CRATE.md §3.3.
449 stream_window: Option<u32>,
450 /// Auto-grant accumulator: chunks delivered since the last
451 /// emitted grant. Flushed at the `window / 2` threshold (see
452 /// the doc on [`Self::stream_window`]).
453 grant_pending: u32,
454 /// Observer-fire bookkeeping. Latched on terminal observation
455 /// in `poll_next`; fired once from `Drop` so the Deck NRPC
456 /// tab + every other `RpcObserver` consumer sees one event
457 /// per streaming-response call.
458 observer: StreamingObserverState,
459 /// v3 cancel-watcher keep-alive (C-S1). Dropping this field
460 /// (on stream Drop) resolves the matching watcher task's
461 /// oneshot receiver with `Err`, telling the watcher to exit
462 /// cleanly + release the registry entry. When the call was
463 /// opened without `cancel_token`, this is a placeholder sender
464 /// with no watcher behind it — drop has no observable effect.
465 _cancel_keep_alive: StreamCancelKeepAlive,
466}
467
468impl RpcStream {
469 /// Server-assigned `call_id`. Useful for trace correlation /
470 /// custom logging at the call site.
471 pub fn call_id(&self) -> u64 {
472 self.call_id
473 }
474
475 /// Whether this stream is flow-controlled (caller set
476 /// `CallOptions::stream_window_initial`). Useful for tests +
477 /// diagnostics; user code typically doesn't need to inspect
478 /// this.
479 pub fn flow_controlled(&self) -> bool {
480 self.stream_window.is_some()
481 }
482
483 /// Explicitly grant `amount` more credits to the server's
484 /// pump. Spawns a fire-and-forget publish; doesn't await
485 /// acknowledgement. **No-op when flow control was not enabled
486 /// for this stream** — the server would silently drop the
487 /// grant anyway, and emitting wire traffic with no purpose
488 /// would just burn bandwidth.
489 ///
490 /// Auto-grant (1 credit per delivered chunk) covers the
491 /// common case; use this for batched cadence (e.g. grant
492 /// `window/2` after every `window/2` chunks consumed) when
493 /// `auto_grant`-style amortization isn't enough.
494 pub fn grant(&self, amount: u32) {
495 if !self.flow_controlled() || amount == 0 {
496 return;
497 }
498 spawn_grant_publish(
499 Arc::clone(&self.mesh),
500 self.target_node_id,
501 self.request_channel_hash,
502 self.request_stream_id,
503 self.self_origin,
504 self.call_id,
505 amount,
506 );
507 }
508}
509
510/// PERF_AUDIT §3.3 — auto-grant coalescing decision for
511/// [`RpcStream::poll_next`]. Accumulates one credit (the chunk
512/// that was just delivered to the consumer) into `pending` and
513/// returns `Some(amount)` when the accumulator reaches the flush
514/// threshold of `window / 2` (clamped to ≥ 1 so a window of 1
515/// degenerates to the pre-fix per-chunk cadence).
516///
517/// Liveness invariant (why no flush-on-drop / timer backstop is
518/// needed): the credits left pending never exceed
519/// `threshold - 1 < window`. The server starts with `window`
520/// credits and `credits = window - (sent - delivered) - pending`,
521/// so whenever the consumer has polled everything that was sent
522/// (the only state in which it could block waiting on the server),
523/// `credits = window - pending >= window - threshold + 1 >= 1` —
524/// the server can always make progress. A consumer that stops
525/// polling stalls the pump by design (that's flow control), and
526/// the chunks already buffered in the stream's mpsc are enough to
527/// carry `pending` across the threshold as soon as it resumes.
528fn accumulate_auto_grant(pending: &mut u32, window: u32) -> Option<u32> {
529 *pending = pending.saturating_add(1);
530 let threshold = (window / 2).max(1);
531 if *pending >= threshold {
532 let amount = *pending;
533 *pending = 0;
534 Some(amount)
535 } else {
536 None
537 }
538}
539
540/// Shared fire-and-forget GRANT-publish helper. Used by
541/// [`RpcStream::grant`] (explicit) and the auto-grant in
542/// [`RpcStream::poll_next`]. Same direct-unicast publish path as
543/// [`spawn_cancel_publish`], just with a different dispatch byte
544/// + a 4-byte u32 payload.
545///
546/// PERF_AUDIT §3.10 — takes `request_channel_hash` and
547/// `request_stream_id` as pre-computed inputs (cached on
548/// `RpcStream`) so the per-chunk grant path doesn't re-run
549/// `ChannelId::new` + xxh3 on every call.
550fn spawn_grant_publish(
551 mesh: Arc<MeshNode>,
552 target: u64,
553 request_channel_hash: ChannelHash,
554 request_stream_id: u64,
555 self_origin: u64,
556 call_id: u64,
557 amount: u32,
558) {
559 tokio::spawn(async move {
560 let meta = EventMeta::new(DISPATCH_RPC_STREAM_GRANT, 0, self_origin, call_id, 0);
561 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 4);
562 buf.extend_from_slice(&meta.to_bytes());
563 buf.extend_from_slice(&encode_stream_grant(amount));
564 let payload = Bytes::from(buf);
565 let _ = mesh
566 .publish_to_peer(
567 target,
568 request_channel_hash,
569 request_stream_id,
570 /* reliable */ true,
571 std::slice::from_ref(&payload),
572 )
573 .await;
574 });
575}
576
577impl futures::Stream for RpcStream {
578 type Item = Result<Bytes, RpcError>;
579
580 fn poll_next(
581 mut self: std::pin::Pin<&mut Self>,
582 cx: &mut std::task::Context<'_>,
583 ) -> std::task::Poll<Option<Self::Item>> {
584 if self.done {
585 return std::task::Poll::Ready(None);
586 }
587 match self.inner.poll_recv(cx) {
588 std::task::Poll::Ready(Some(StreamItem::Chunk(body))) => {
589 // Auto-grant: accumulate 1 credit per delivered
590 // chunk and fire a batched `spawn_grant_publish`
591 // only when the accumulator reaches `window / 2`
592 // (or 1 for tiny windows). Per PERF_AUDIT §3.3 —
593 // pre-fix this spawned one task + one reliable
594 // AEAD packet per chunk, a spawn-storm + AEAD-
595 // storm under bursting; the server side already
596 // fixed the identical shape via
597 // `build_request_grant_emitter` (§3.3 audit text).
598 // Callers needing finer cadence still have
599 // `RpcStream::grant` for explicit batches.
600 if let Some(window) = self.stream_window {
601 let mut pending = self.grant_pending;
602 if let Some(amount) = accumulate_auto_grant(&mut pending, window) {
603 spawn_grant_publish(
604 Arc::clone(&self.mesh),
605 self.target_node_id,
606 self.request_channel_hash,
607 self.request_stream_id,
608 self.self_origin,
609 self.call_id,
610 amount,
611 );
612 }
613 self.grant_pending = pending;
614 }
615 self.observer.add_response_bytes(body.len() as u32);
616 std::task::Poll::Ready(Some(Ok(body)))
617 }
618 std::task::Poll::Ready(Some(StreamItem::End)) => {
619 self.done = true;
620 self.observer.latch_ok();
621 std::task::Poll::Ready(None)
622 }
623 std::task::Poll::Ready(Some(StreamItem::Error(resp))) => {
624 self.done = true;
625 let status = resp.status.to_wire();
626 let message = String::from_utf8(resp.body.to_vec()).unwrap_or_else(|e| {
627 format!("<{} bytes of non-utf8 body>", e.into_bytes().len())
628 });
629 self.observer
630 .latch_error(format!("server returned status {status:#06x}: {message}"));
631 std::task::Poll::Ready(Some(Err(RpcError::ServerError {
632 status,
633 message,
634 headers: resp.headers,
635 })))
636 }
637 std::task::Poll::Ready(None) => {
638 self.done = true;
639 std::task::Poll::Ready(None)
640 }
641 std::task::Poll::Pending => std::task::Poll::Pending,
642 }
643 }
644}
645
646impl Drop for RpcStream {
647 fn drop(&mut self) {
648 // Best-effort CANCEL to the server. Spawn a task because
649 // Drop can't be async; the publish happens off-thread.
650 // Also clear our pending entry so any in-flight chunks
651 // are dropped on arrival.
652 self.mesh.rpc_client_pending_arc().cancel(self.call_id);
653 spawn_cancel_publish(
654 Arc::clone(&self.mesh),
655 self.target_node_id,
656 self.request_channel.clone(),
657 self.self_origin,
658 self.call_id,
659 );
660 // Fire the observer with the latched status (Ok / Error /
661 // Canceled). Idempotent — only the first fire emits.
662 self.observer.fire();
663 }
664}
665
666// ============================================================================
667// Phase C — caller-side client-streaming / duplex primitive.
668// ============================================================================
669
670/// Shared REQUEST_CHUNK-publish helper. Builds the wire frame and
671/// fires through `publish_to_peer` direct-unicast (same routing
672/// pattern as the initial REQUEST — caller knows the target).
673/// PERF_AUDIT §3.10 — accepts pre-computed
674/// `request_channel_hash` and `request_stream_id` (cached on
675/// `ClientStreamCallRaw`) so the per-chunk client-stream send path
676/// doesn't re-run `ChannelId::new` + xxh3 on every chunk.
677async fn publish_request_chunk(
678 mesh: &Arc<MeshNode>,
679 target: u64,
680 request_channel_hash: ChannelHash,
681 request_stream_id: u64,
682 self_origin: u64,
683 chunk: &RpcRequestChunkPayload,
684) -> Result<(), RpcError> {
685 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_CHUNK, 0, self_origin, chunk.call_id, 0);
686 let mut buf = Vec::with_capacity(EVENT_META_SIZE + chunk.encoded_len());
687 buf.extend_from_slice(&meta.to_bytes());
688 chunk.encode_into(&mut buf);
689 let payload = Bytes::from(buf);
690 mesh.publish_to_peer(
691 target,
692 request_channel_hash,
693 request_stream_id,
694 /* reliable */ true,
695 std::slice::from_ref(&payload),
696 )
697 .await
698 .map_err(RpcError::Transport)
699}
700
701/// Internal state of a [`ClientStreamCallRaw`]. The state machine
702/// is small: open the call (initial REQUEST not yet sent), then
703/// send N items (the first becomes the initial REQUEST, subsequent
704/// become REQUEST_CHUNKs), then finish (terminal REQUEST_END
705/// frame). After finish, no further sends are accepted.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707enum ClientStreamState {
708 /// Pending entry registered, reply subscription ensured, but
709 /// the initial REQUEST has NOT been published to the wire yet.
710 /// First `send` flips this to `Sending`.
711 JustOpened,
712 /// Initial REQUEST has been published; subsequent sends ride
713 /// as REQUEST_CHUNKs.
714 Sending,
715 /// `finish` has been called; the terminal REQUEST_END frame
716 /// (or the initial REQUEST with FLAG_END for the degenerate
717 /// zero-send path) has been published. The terminal RESPONSE
718 /// has not necessarily arrived yet — that's awaited on the
719 /// caller's terminal_rx.
720 Finishing,
721 /// Terminal RESPONSE has been delivered. Drop is a no-op.
722 Done,
723}
724
725/// Caller-side handle for a client-streaming (or duplex Phase D)
726/// RPC. Push N items via [`ClientStreamCallRaw::send`], then
727/// [`ClientStreamCallRaw::finish`] to await the terminal RESPONSE.
728///
729/// **Lazy initial REQUEST.** The initial REQUEST is published on
730/// the FIRST `send()` (or on `finish()` if the caller sends nothing
731/// — that's the "zero-item upload" degenerate path that opens and
732/// closes the call in one frame). Constructing the handle does
733/// NOT yet emit any wire traffic beyond the reply-channel
734/// subscription setup.
735///
736/// **Flow control.** When the caller set
737/// [`CallOptions::request_window_initial`] to `Some(n)`, the
738/// handle holds an `n`-permit `Semaphore` that gates `send`. The
739/// server's [`DISPATCH_RPC_REQUEST_GRANT`] events refill the
740/// semaphore. When `None`, `send` doesn't block (caller is on the
741/// unbounded-credit fast path).
742///
743/// **Cancellation.** Dropping the handle BEFORE `finish` returns
744/// `Ok` fires a best-effort CANCEL to the server and clears the
745/// pending entry. Dropping after a successful `finish` is a no-op
746/// (terminal RESPONSE already delivered + entry removed).
747///
748/// Bidi streaming plan (Phase C).
749pub struct ClientStreamCallRaw {
750 mesh: Arc<MeshNode>,
751 target_node_id: u64,
752 request_channel: ChannelName,
753 /// PERF_AUDIT §3.10 — cached `ChannelId::new(request_channel).hash()`
754 /// so per-chunk REQUEST_CHUNK publishes don't re-run xxh3.
755 request_channel_hash: ChannelHash,
756 /// PERF_AUDIT §3.10 — cached
757 /// `MeshNode::publish_stream_id(&request_channel_id)`.
758 request_stream_id: u64,
759 self_origin: u64,
760 call_id: u64,
761 service: String,
762 /// Header set queued for the initial REQUEST. Drained on the
763 /// first publish (either `send` or `finish`).
764 initial_headers: Vec<(String, Vec<u8>)>,
765 /// Flag bits queued for the initial REQUEST. Always carries
766 /// `FLAG_RPC_CLIENT_STREAMING_REQUEST`; may also carry
767 /// `FLAG_RPC_PROPAGATE_TRACE` when the caller supplied a
768 /// trace context.
769 initial_flags: u16,
770 /// `deadline_ns` from `CallOptions::deadline`. Embedded in the
771 /// initial REQUEST.
772 deadline_ns: u64,
773 /// Per-call semaphore for upload credits. `None` when the
774 /// caller didn't opt into flow control (`request_window_initial`
775 /// was `None` on the `CallOptions`).
776 credit_sem: Option<Arc<tokio::sync::Semaphore>>,
777 /// Background task that drains REQUEST_GRANT credits from the
778 /// pending entry's grant mpsc into `credit_sem`. Aborted on
779 /// Drop. `None` when flow control is off.
780 grant_pump: Option<JoinHandle<()>>,
781 /// Single-shot terminal-RESPONSE receiver. Taken by `finish`;
782 /// after that `Drop` doesn't attempt to await again.
783 terminal_rx: Option<tokio::sync::oneshot::Receiver<RpcResponsePayload>>,
784 /// State machine. See [`ClientStreamState`].
785 state: ClientStreamState,
786 /// Wall-clock start (for `RpcReply::latency_ns` reporting).
787 started: Instant,
788 /// Observer-fire bookkeeping. Latched on terminal observation
789 /// in `finish`; fired once from `Drop` so the Deck NRPC tab +
790 /// every `RpcObserver` consumer sees one event per
791 /// client-streaming call.
792 observer: StreamingObserverState,
793 /// v3 cancel-watcher keep-alive (C-S1). Dropping this field
794 /// (on call Drop) tells the watcher task to exit cleanly and
795 /// release the registry entry. See
796 /// [`spawn_stream_cancel_watcher`] for the lifecycle.
797 _cancel_keep_alive: StreamCancelKeepAlive,
798}
799
800impl ClientStreamCallRaw {
801 /// Server-assigned `call_id`. Useful for trace correlation /
802 /// custom logging.
803 pub fn call_id(&self) -> u64 {
804 self.call_id
805 }
806
807 /// Whether this call is flow-controlled (caller set
808 /// `CallOptions::request_window_initial`).
809 pub fn flow_controlled(&self) -> bool {
810 self.credit_sem.is_some()
811 }
812
813 /// Push one body chunk to the server. Encodes as the initial
814 /// REQUEST (first call) or as a REQUEST_CHUNK (subsequent
815 /// calls). When flow control is opted into, awaits one credit
816 /// before publishing.
817 ///
818 /// Returns `Err(RpcError::Codec)` if called after [`Self::finish`].
819 pub async fn send(&mut self, body: Bytes) -> Result<(), RpcError> {
820 match self.state {
821 ClientStreamState::Finishing | ClientStreamState::Done => {
822 return Err(RpcError::Codec {
823 direction: CodecDirection::Encode,
824 message: "send() called after finish()".to_string(),
825 });
826 }
827 _ => {}
828 }
829 // Gate on credit when flow control is opted into.
830 if let Some(sem) = self.credit_sem.as_ref() {
831 let permit = sem.clone().acquire_owned().await.map_err(|_| {
832 RpcError::Transport(AdapterError::Connection("credit semaphore closed".into()))
833 })?;
834 permit.forget();
835 }
836 self.observer.add_request_bytes(body.len() as u32);
837 match self.state {
838 ClientStreamState::JustOpened => {
839 // First send → initial REQUEST.
840 let req = RpcRequestPayload {
841 service: self.service.clone(),
842 deadline_ns: self.deadline_ns,
843 flags: self.initial_flags,
844 headers: std::mem::take(&mut self.initial_headers),
845 body: body.clone(),
846 };
847 self.publish_initial_request(&req).await?;
848 self.state = ClientStreamState::Sending;
849 }
850 ClientStreamState::Sending => {
851 let chunk = RpcRequestChunkPayload {
852 call_id: self.call_id,
853 flags: 0,
854 headers: vec![],
855 body: body.clone(),
856 };
857 publish_request_chunk(
858 &self.mesh,
859 self.target_node_id,
860 self.request_channel_hash,
861 self.request_stream_id,
862 self.self_origin,
863 &chunk,
864 )
865 .await?;
866 }
867 ClientStreamState::Finishing | ClientStreamState::Done => unreachable!(),
868 }
869 Ok(())
870 }
871
872 /// Close the upload direction and await the server's terminal
873 /// RESPONSE. Emits a REQUEST_CHUNK with `FLAG_RPC_REQUEST_END`
874 /// (empty body) if the call has already published its initial
875 /// REQUEST, or an initial REQUEST with both
876 /// `FLAG_RPC_CLIENT_STREAMING_REQUEST` and
877 /// `FLAG_RPC_REQUEST_END` set (the degenerate "zero-item
878 /// upload" path) if nothing was sent.
879 ///
880 /// Consumes the handle — Drop after `finish` is a no-op.
881 pub async fn finish(mut self) -> Result<RpcReply, RpcError> {
882 match self.state {
883 ClientStreamState::JustOpened => {
884 let req = RpcRequestPayload {
885 service: self.service.clone(),
886 deadline_ns: self.deadline_ns,
887 flags: self.initial_flags | FLAG_RPC_REQUEST_END,
888 headers: std::mem::take(&mut self.initial_headers),
889 body: Bytes::new(),
890 };
891 self.publish_initial_request(&req).await?;
892 }
893 ClientStreamState::Sending => {
894 let chunk = RpcRequestChunkPayload {
895 call_id: self.call_id,
896 flags: FLAG_RPC_REQUEST_END,
897 headers: vec![],
898 body: Bytes::new(),
899 };
900 publish_request_chunk(
901 &self.mesh,
902 self.target_node_id,
903 self.request_channel_hash,
904 self.request_stream_id,
905 self.self_origin,
906 &chunk,
907 )
908 .await?;
909 }
910 ClientStreamState::Finishing | ClientStreamState::Done => {
911 return Err(RpcError::Codec {
912 direction: CodecDirection::Encode,
913 message: "finish() called twice".to_string(),
914 });
915 }
916 }
917 self.state = ClientStreamState::Finishing;
918 let terminal_rx = self.terminal_rx.take().ok_or_else(|| {
919 RpcError::Transport(AdapterError::Connection(
920 "terminal receiver already consumed".into(),
921 ))
922 })?;
923 // Honor the deadline if the caller set one.
924 let resp = if self.deadline_ns > 0 {
925 let now = std::time::SystemTime::now()
926 .duration_since(std::time::UNIX_EPOCH)
927 .map(|d| d.as_nanos() as u64)
928 .unwrap_or(0);
929 let remaining = self.deadline_ns.saturating_sub(now);
930 match tokio::time::timeout(std::time::Duration::from_nanos(remaining), terminal_rx)
931 .await
932 {
933 Ok(Ok(r)) => r,
934 Ok(Err(_)) => {
935 let msg = "terminal sender dropped before response arrived";
936 self.observer.latch_error(msg);
937 return Err(RpcError::Transport(AdapterError::Connection(msg.into())));
938 }
939 Err(_elapsed) => {
940 let elapsed_ms = self.started.elapsed().as_millis() as u64;
941 self.observer.latch_timeout();
942 return Err(RpcError::Timeout { elapsed_ms });
943 }
944 }
945 } else {
946 match terminal_rx.await {
947 Ok(r) => r,
948 Err(_) => {
949 let msg = "terminal sender dropped before response arrived";
950 self.observer.latch_error(msg);
951 return Err(RpcError::Transport(AdapterError::Connection(msg.into())));
952 }
953 }
954 };
955 self.state = ClientStreamState::Done;
956 self.observer.add_response_bytes(resp.body.len() as u32);
957 if !resp.status.is_ok() {
958 // String::from_utf8 takes `Vec<u8>`. `Bytes::to_vec()`
959 // matches the prior `resp.body.clone()` semantics (full
960 // copy of the body for the error-formatting path);
961 // bulk-throughput improvement lives on the decode side,
962 // not here.
963 let message = String::from_utf8(resp.body.to_vec())
964 .unwrap_or_else(|e| format!("<{} bytes of non-utf8 body>", e.into_bytes().len()));
965 self.observer.latch_error(format!(
966 "server returned status {:#06x}: {message}",
967 resp.status.to_wire()
968 ));
969 return Err(RpcError::ServerError {
970 status: resp.status.to_wire(),
971 message,
972 headers: resp.headers,
973 });
974 }
975 self.observer.latch_ok();
976 let latency_ns = self.started.elapsed().as_nanos() as u64;
977 Ok(RpcReply {
978 body: resp.body,
979 headers: resp.headers,
980 latency_ns,
981 })
982 }
983
984 async fn publish_initial_request(&self, req: &RpcRequestPayload) -> Result<(), RpcError> {
985 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, self.self_origin, self.call_id, 0);
986 let mut buf = Vec::with_capacity(EVENT_META_SIZE + req.encoded_len());
987 buf.extend_from_slice(&meta.to_bytes());
988 req.encode_into(&mut buf);
989 let payload = Bytes::from(buf);
990 // PERF_AUDIT §3.10 — use the cached hash + stream_id from
991 // construction; no per-publish `ChannelId::new` + xxh3.
992 self.mesh
993 .publish_to_peer(
994 self.target_node_id,
995 self.request_channel_hash,
996 self.request_stream_id,
997 /* reliable */ true,
998 std::slice::from_ref(&payload),
999 )
1000 .await
1001 .map_err(RpcError::Transport)
1002 }
1003}
1004
1005impl Drop for ClientStreamCallRaw {
1006 fn drop(&mut self) {
1007 if let Some(task) = self.grant_pump.take() {
1008 task.abort();
1009 }
1010 // Fire the observer with whatever status was latched
1011 // (Ok / Error / Timeout / Canceled). Idempotent — only
1012 // the first call emits.
1013 self.observer.fire();
1014 if matches!(self.state, ClientStreamState::Done) {
1015 // Successful completion — pending entry already gone,
1016 // no CANCEL needed.
1017 return;
1018 }
1019 self.mesh.rpc_client_pending_arc().cancel(self.call_id);
1020 // Only fire CANCEL on the wire if the server has actually
1021 // seen the initial REQUEST. A `JustOpened` Drop means we
1022 // never published anything; no need to CANCEL a call the
1023 // server doesn't know about.
1024 if !matches!(self.state, ClientStreamState::JustOpened) {
1025 spawn_cancel_publish(
1026 Arc::clone(&self.mesh),
1027 self.target_node_id,
1028 self.request_channel.clone(),
1029 self.self_origin,
1030 self.call_id,
1031 );
1032 }
1033 }
1034}
1035
1036// ============================================================================
1037// Phase D — caller-side duplex primitive.
1038// ============================================================================
1039
1040/// Shared state between a `DuplexSink` and its sibling
1041/// `DuplexStream`. Both halves hold an `Arc<DuplexInner>`; when
1042/// the refcount hits zero (i.e. both halves dropped) the Drop
1043/// fires CANCEL to the server unless the call was cleanly closed
1044/// (`clean_close = true`).
1045struct DuplexInner {
1046 mesh: Arc<MeshNode>,
1047 target_node_id: u64,
1048 request_channel: ChannelName,
1049 /// PERF_AUDIT §3.10 — cached channel-id hash + stream id so
1050 /// per-chunk publishes from the upload side don't re-run
1051 /// `ChannelId::new` + xxh3.
1052 request_channel_hash: ChannelHash,
1053 request_stream_id: u64,
1054 self_origin: u64,
1055 call_id: u64,
1056 /// Whether the initial REQUEST was successfully published.
1057 /// `false` means we never reached the wire — no CANCEL needed
1058 /// (server doesn't know about the call).
1059 initial_sent: std::sync::atomic::AtomicBool,
1060 /// Set true when the call closes cleanly — terminal RESPONSE
1061 /// (or terminal Error) was observed on the response stream.
1062 /// Suppresses CANCEL-on-drop.
1063 clean_close: std::sync::atomic::AtomicBool,
1064 /// Observer-fire bookkeeping. Latched from the various
1065 /// terminal-observation sites (DuplexCall::next /
1066 /// DuplexStream::poll_next yielding End or Error); fired
1067 /// once on Drop. The DuplexCall / DuplexSink / DuplexStream
1068 /// each share access via the surrounding Arc<DuplexInner>.
1069 observer: StreamingObserverState,
1070 /// v3 cancel-watcher keep-alive (C-S1). Lives on
1071 /// `Arc<DuplexInner>` so it survives `into_split` — both
1072 /// halves of the duplex hold the same Arc, so the watcher
1073 /// task exits only when BOTH halves drop (matching the
1074 /// Drop-fires-CANCEL semantics above). Wrapped in `Option`
1075 /// for `mem::take`-style construction patterns; populated
1076 /// once at `call_duplex` time and never cleared.
1077 _cancel_keep_alive: Option<StreamCancelKeepAlive>,
1078}
1079
1080impl Drop for DuplexInner {
1081 fn drop(&mut self) {
1082 self.mesh.rpc_client_pending_arc().cancel(self.call_id);
1083 // Fire the observer with the latched status (Ok / Error /
1084 // Canceled). Idempotent — only the first call emits.
1085 self.observer.fire();
1086 if self.clean_close.load(Ordering::SeqCst) {
1087 return;
1088 }
1089 if !self.initial_sent.load(Ordering::SeqCst) {
1090 return;
1091 }
1092 spawn_cancel_publish(
1093 Arc::clone(&self.mesh),
1094 self.target_node_id,
1095 self.request_channel.clone(),
1096 self.self_origin,
1097 self.call_id,
1098 );
1099 }
1100}
1101
1102/// Send half of a duplex call. Push items via `send`; emit the
1103/// terminal REQUEST_END frame via `finish_sending`. After
1104/// `finish_sending` the upload side is closed but the sibling
1105/// `DuplexStream` continues yielding response chunks until the
1106/// server's terminal frame arrives.
1107///
1108/// Bidi streaming plan (Phase D).
1109pub struct DuplexSink {
1110 inner: Arc<DuplexInner>,
1111 service: String,
1112 initial_headers: Vec<(String, Vec<u8>)>,
1113 initial_flags: u16,
1114 deadline_ns: u64,
1115 credit_sem: Option<Arc<tokio::sync::Semaphore>>,
1116 grant_pump: Option<JoinHandle<()>>,
1117 state: ClientStreamState,
1118}
1119
1120impl DuplexSink {
1121 /// Push one body chunk to the server. Same semantics as
1122 /// [`ClientStreamCallRaw::send`].
1123 pub async fn send(&mut self, body: Bytes) -> Result<(), RpcError> {
1124 match self.state {
1125 ClientStreamState::Finishing | ClientStreamState::Done => {
1126 return Err(RpcError::Codec {
1127 direction: CodecDirection::Encode,
1128 message: "send() called after finish_sending()".to_string(),
1129 });
1130 }
1131 _ => {}
1132 }
1133 if let Some(sem) = self.credit_sem.as_ref() {
1134 let permit = sem.clone().acquire_owned().await.map_err(|_| {
1135 RpcError::Transport(AdapterError::Connection("credit semaphore closed".into()))
1136 })?;
1137 permit.forget();
1138 }
1139 self.inner.observer.add_request_bytes(body.len() as u32);
1140 match self.state {
1141 ClientStreamState::JustOpened => {
1142 let req = RpcRequestPayload {
1143 service: self.service.clone(),
1144 deadline_ns: self.deadline_ns,
1145 flags: self.initial_flags,
1146 headers: std::mem::take(&mut self.initial_headers),
1147 body: body.clone(),
1148 };
1149 self.publish_initial_request(&req).await?;
1150 self.inner.initial_sent.store(true, Ordering::SeqCst);
1151 self.state = ClientStreamState::Sending;
1152 }
1153 ClientStreamState::Sending => {
1154 let chunk = RpcRequestChunkPayload {
1155 call_id: self.inner.call_id,
1156 flags: 0,
1157 headers: vec![],
1158 body: body.clone(),
1159 };
1160 publish_request_chunk(
1161 &self.inner.mesh,
1162 self.inner.target_node_id,
1163 self.inner.request_channel_hash,
1164 self.inner.request_stream_id,
1165 self.inner.self_origin,
1166 &chunk,
1167 )
1168 .await?;
1169 }
1170 ClientStreamState::Finishing | ClientStreamState::Done => unreachable!(),
1171 }
1172 Ok(())
1173 }
1174
1175 /// Close the upload direction. Emits the terminal REQUEST_END
1176 /// frame. The response stream continues until the server's
1177 /// terminal RESPONSE arrives (use the sibling `DuplexStream`).
1178 pub async fn finish_sending(mut self) -> Result<(), RpcError> {
1179 match self.state {
1180 ClientStreamState::JustOpened => {
1181 let req = RpcRequestPayload {
1182 service: self.service.clone(),
1183 deadline_ns: self.deadline_ns,
1184 flags: self.initial_flags | FLAG_RPC_REQUEST_END,
1185 headers: std::mem::take(&mut self.initial_headers),
1186 body: Bytes::new(),
1187 };
1188 self.publish_initial_request(&req).await?;
1189 self.inner.initial_sent.store(true, Ordering::SeqCst);
1190 }
1191 ClientStreamState::Sending => {
1192 let chunk = RpcRequestChunkPayload {
1193 call_id: self.inner.call_id,
1194 flags: FLAG_RPC_REQUEST_END,
1195 headers: vec![],
1196 body: Bytes::new(),
1197 };
1198 publish_request_chunk(
1199 &self.inner.mesh,
1200 self.inner.target_node_id,
1201 self.inner.request_channel_hash,
1202 self.inner.request_stream_id,
1203 self.inner.self_origin,
1204 &chunk,
1205 )
1206 .await?;
1207 }
1208 ClientStreamState::Finishing | ClientStreamState::Done => {
1209 return Err(RpcError::Codec {
1210 direction: CodecDirection::Encode,
1211 message: "finish_sending() called twice".to_string(),
1212 });
1213 }
1214 }
1215 self.state = ClientStreamState::Finishing;
1216 Ok(())
1217 }
1218
1219 /// Server-assigned `call_id`. Same value on the sibling
1220 /// `DuplexStream`.
1221 pub fn call_id(&self) -> u64 {
1222 self.inner.call_id
1223 }
1224
1225 /// Whether this call is flow-controlled on the upload side.
1226 pub fn flow_controlled(&self) -> bool {
1227 self.credit_sem.is_some()
1228 }
1229
1230 async fn publish_initial_request(&self, req: &RpcRequestPayload) -> Result<(), RpcError> {
1231 let meta = EventMeta::new(
1232 DISPATCH_RPC_REQUEST,
1233 0,
1234 self.inner.self_origin,
1235 self.inner.call_id,
1236 0,
1237 );
1238 let mut buf = Vec::with_capacity(EVENT_META_SIZE + req.encoded_len());
1239 buf.extend_from_slice(&meta.to_bytes());
1240 req.encode_into(&mut buf);
1241 let payload = Bytes::from(buf);
1242 // PERF_AUDIT §3.10 — cached hash + stream_id from the
1243 // inner `ClientStreamCallRaw`.
1244 self.inner
1245 .mesh
1246 .publish_to_peer(
1247 self.inner.target_node_id,
1248 self.inner.request_channel_hash,
1249 self.inner.request_stream_id,
1250 /* reliable */ true,
1251 std::slice::from_ref(&payload),
1252 )
1253 .await
1254 .map_err(RpcError::Transport)
1255 }
1256}
1257
1258impl Drop for DuplexSink {
1259 fn drop(&mut self) {
1260 if let Some(task) = self.grant_pump.take() {
1261 task.abort();
1262 }
1263 // The shared DuplexInner's Drop (when refcount hits 0)
1264 // does the CANCEL — nothing to do here beyond aborting
1265 // the grant pump.
1266 }
1267}
1268
1269/// Receive half of a duplex call. Implements `futures::Stream`
1270/// yielding `Result<Bytes, RpcError>` per inbound RESPONSE chunk.
1271/// EOF on terminal Ok; one final `Err(RpcError::ServerError)` on
1272/// terminal non-Ok.
1273///
1274/// Bidi streaming plan (Phase D).
1275pub struct DuplexStream {
1276 inner: Arc<DuplexInner>,
1277 chunks_rx: tokio::sync::mpsc::UnboundedReceiver<StreamItem>,
1278 done: bool,
1279}
1280
1281impl DuplexStream {
1282 /// Server-assigned `call_id`. Same value on the sibling
1283 /// `DuplexSink`.
1284 pub fn call_id(&self) -> u64 {
1285 self.inner.call_id
1286 }
1287}
1288
1289impl futures::Stream for DuplexStream {
1290 type Item = Result<Bytes, RpcError>;
1291
1292 fn poll_next(
1293 mut self: std::pin::Pin<&mut Self>,
1294 cx: &mut std::task::Context<'_>,
1295 ) -> std::task::Poll<Option<Self::Item>> {
1296 if self.done {
1297 return std::task::Poll::Ready(None);
1298 }
1299 match self.chunks_rx.poll_recv(cx) {
1300 std::task::Poll::Ready(Some(StreamItem::Chunk(body))) => {
1301 self.inner.observer.add_response_bytes(body.len() as u32);
1302 std::task::Poll::Ready(Some(Ok(body)))
1303 }
1304 std::task::Poll::Ready(Some(StreamItem::End)) => {
1305 self.done = true;
1306 self.inner.clean_close.store(true, Ordering::SeqCst);
1307 self.inner.observer.latch_ok();
1308 std::task::Poll::Ready(None)
1309 }
1310 std::task::Poll::Ready(Some(StreamItem::Error(resp))) => {
1311 self.done = true;
1312 self.inner.clean_close.store(true, Ordering::SeqCst);
1313 let status = resp.status.to_wire();
1314 let message = String::from_utf8(resp.body.to_vec()).unwrap_or_else(|e| {
1315 format!("<{} bytes of non-utf8 body>", e.into_bytes().len())
1316 });
1317 self.inner
1318 .observer
1319 .latch_error(format!("server returned status {status:#06x}: {message}"));
1320 std::task::Poll::Ready(Some(Err(RpcError::ServerError {
1321 status,
1322 message,
1323 headers: resp.headers,
1324 })))
1325 }
1326 std::task::Poll::Ready(None) => {
1327 self.done = true;
1328 std::task::Poll::Ready(None)
1329 }
1330 std::task::Poll::Pending => std::task::Poll::Pending,
1331 }
1332 }
1333}
1334
1335/// Caller-side handle for a duplex RPC. Combines a `DuplexSink`
1336/// (upload) and `DuplexStream` (download). For application code
1337/// that wants to encode requests in one task and decode responses
1338/// in another, use [`Self::into_split`] to peel off the two halves.
1339///
1340/// Bidi streaming plan (Phase D).
1341pub struct DuplexCallRaw {
1342 sink: DuplexSink,
1343 stream: DuplexStream,
1344}
1345
1346impl DuplexCallRaw {
1347 /// Server-assigned `call_id`.
1348 pub fn call_id(&self) -> u64 {
1349 self.sink.call_id()
1350 }
1351
1352 /// Whether the upload side is flow-controlled.
1353 pub fn flow_controlled(&self) -> bool {
1354 self.sink.flow_controlled()
1355 }
1356
1357 /// Push one body chunk to the server. Delegates to the inner
1358 /// `DuplexSink::send`.
1359 pub async fn send(&mut self, body: Bytes) -> Result<(), RpcError> {
1360 self.sink.send(body).await
1361 }
1362
1363 /// Close the upload direction. Delegates to the inner
1364 /// `DuplexSink::finish_sending` but keeps the receive side
1365 /// alive so the caller can keep polling response chunks.
1366 ///
1367 /// NOTE: consumes the sink half but not the stream half.
1368 /// Internally, we replace `self.sink` with a no-op
1369 /// placeholder so subsequent send() / finish_sending()
1370 /// surface a clear error (`send() after finish_sending()`).
1371 pub async fn finish_sending(&mut self) -> Result<(), RpcError> {
1372 // Take the sink out by swapping in a placeholder whose
1373 // state is `Done` so subsequent sends error cleanly.
1374 let placeholder = DuplexSink {
1375 inner: Arc::clone(&self.sink.inner),
1376 service: String::new(),
1377 initial_headers: Vec::new(),
1378 initial_flags: 0,
1379 deadline_ns: 0,
1380 credit_sem: None,
1381 grant_pump: None,
1382 state: ClientStreamState::Done,
1383 };
1384 let sink = std::mem::replace(&mut self.sink, placeholder);
1385 sink.finish_sending().await
1386 }
1387
1388 /// Pull the next response chunk. `None` on terminal Ok;
1389 /// `Some(Err)` then `None` on terminal non-Ok. Same shape as
1390 /// `futures::StreamExt::next`.
1391 pub async fn next(&mut self) -> Option<Result<Bytes, RpcError>> {
1392 use futures::StreamExt;
1393 self.stream.next().await
1394 }
1395
1396 /// Split into independent send / receive halves. Both halves
1397 /// hold an `Arc<DuplexInner>`; CANCEL fires only when BOTH
1398 /// halves drop without a clean close.
1399 pub fn into_split(self) -> (DuplexSink, DuplexStream) {
1400 (self.sink, self.stream)
1401 }
1402}
1403
1404impl futures::Stream for DuplexCallRaw {
1405 type Item = Result<Bytes, RpcError>;
1406
1407 fn poll_next(
1408 mut self: std::pin::Pin<&mut Self>,
1409 cx: &mut std::task::Context<'_>,
1410 ) -> std::task::Poll<Option<Self::Item>> {
1411 std::pin::Pin::new(&mut self.stream).poll_next(cx)
1412 }
1413}
1414
1415// ============================================================================
1416// Unary call: CANCEL-on-drop guard.
1417// ============================================================================
1418
1419/// RAII guard that fires CANCEL to the server if the unary call
1420/// future is dropped before a response arrives. Without this, a
1421/// `select!`-loser future (e.g. hedge runner-up) would leave the
1422/// server-side handler running to completion — wasting CPU on a
1423/// reply nobody will read.
1424///
1425/// The guard is built *after* the REQUEST has been successfully
1426/// published — if the publish fails, no guard is constructed and
1427/// no CANCEL is sent. On the success path the call function flips
1428/// `completed = true` so Drop becomes a no-op (the server already
1429/// finished and removed its in-flight entry).
1430struct UnaryCallGuard {
1431 pending: Arc<super::cortex::RpcClientPending>,
1432 mesh: Arc<MeshNode>,
1433 target_node_id: u64,
1434 request_channel: ChannelName,
1435 self_origin: u64,
1436 call_id: u64,
1437 /// True after the call resolved Ok or got a definitive
1438 /// non-cancellable Err. Drop checks this — `false` fires
1439 /// CANCEL, `true` is a no-op (still removes the pending
1440 /// entry).
1441 completed: bool,
1442}
1443
1444impl Drop for UnaryCallGuard {
1445 fn drop(&mut self) {
1446 self.pending.cancel(self.call_id);
1447 if !self.completed {
1448 spawn_cancel_publish(
1449 Arc::clone(&self.mesh),
1450 self.target_node_id,
1451 self.request_channel.clone(),
1452 self.self_origin,
1453 self.call_id,
1454 );
1455 }
1456 }
1457}
1458
1459// ============================================================================
1460// Streaming/duplex observer-fire bookkeeping.
1461//
1462// The unary `MeshNode::call` fires `RpcObserver::on_call` at each
1463// terminal return path (see line ~2306). The streaming /
1464// client-streaming / duplex paths have multiple terminal points
1465// (poll_next sees End / Error; finish() returns; Drop without
1466// terminal observation). To avoid sprinkling `fire_rpc_observer_outbound`
1467// at every terminal site, each handle holds a
1468// `StreamingObserverState` that latches the terminal status on
1469// observation and fires exactly once on Drop. The Deck NRPC tab
1470// + every consumer of `RpcObserver` get one event per streaming
1471// / duplex call, same as for unary today.
1472// ============================================================================
1473
1474/// Per-call observer-fire bookkeeping shared between the
1475/// streaming + client-streaming + duplex caller-side handles.
1476/// Latches terminal status on observation; `fire()` (called from
1477/// the handle's Drop) emits one `RpcCallEvent` with the latched
1478/// status (or `Canceled` if nothing latched — i.e. the handle
1479/// was dropped before observing its terminator).
1480///
1481/// Status discriminator:
1482/// 0 = none latched (Drop → Canceled)
1483/// 1 = Ok
1484/// 2 = Error (message in `observer_msg`)
1485/// 3 = Timeout
1486pub(crate) struct StreamingObserverState {
1487 mesh: Arc<MeshNode>,
1488 target_node_id: u64,
1489 service: String,
1490 started: Instant,
1491 request_bytes: AtomicU32,
1492 response_bytes: AtomicU32,
1493 observer_status: AtomicU8,
1494 observer_msg: parking_lot::Mutex<Option<String>>,
1495 fired: AtomicBool,
1496}
1497
1498impl StreamingObserverState {
1499 pub(crate) fn new(
1500 mesh: Arc<MeshNode>,
1501 target_node_id: u64,
1502 service: impl Into<String>,
1503 request_bytes: u32,
1504 ) -> Self {
1505 Self {
1506 mesh,
1507 target_node_id,
1508 service: service.into(),
1509 started: Instant::now(),
1510 request_bytes: AtomicU32::new(request_bytes),
1511 response_bytes: AtomicU32::new(0),
1512 observer_status: AtomicU8::new(0),
1513 observer_msg: parking_lot::Mutex::new(None),
1514 fired: AtomicBool::new(false),
1515 }
1516 }
1517
1518 pub(crate) fn add_request_bytes(&self, n: u32) {
1519 self.request_bytes.fetch_add(n, Ordering::Relaxed);
1520 }
1521
1522 pub(crate) fn add_response_bytes(&self, n: u32) {
1523 self.response_bytes.fetch_add(n, Ordering::Relaxed);
1524 }
1525
1526 pub(crate) fn latch_ok(&self) {
1527 self.observer_status.store(1, Ordering::Relaxed);
1528 }
1529
1530 pub(crate) fn latch_error(&self, msg: impl Into<String>) {
1531 *self.observer_msg.lock() = Some(msg.into());
1532 self.observer_status.store(2, Ordering::Relaxed);
1533 }
1534
1535 pub(crate) fn latch_timeout(&self) {
1536 self.observer_status.store(3, Ordering::Relaxed);
1537 }
1538
1539 /// Fire the observer event. Idempotent — only the first call
1540 /// actually emits; subsequent are no-ops. Called from each
1541 /// streaming handle's Drop.
1542 pub(crate) fn fire(&self) {
1543 if self.fired.swap(true, Ordering::SeqCst) {
1544 return;
1545 }
1546 let status_code = self.observer_status.load(Ordering::Relaxed);
1547 let status = match status_code {
1548 1 => crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Ok,
1549 2 => {
1550 let msg = self.observer_msg.lock().clone().unwrap_or_default();
1551 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Error(msg)
1552 }
1553 3 => crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Timeout,
1554 _ => crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Canceled,
1555 };
1556 self.mesh.fire_rpc_observer_outbound(
1557 self.target_node_id,
1558 &self.service,
1559 self.started.elapsed().as_millis() as u32,
1560 status,
1561 self.request_bytes.load(Ordering::Relaxed),
1562 self.response_bytes.load(Ordering::Relaxed),
1563 );
1564 }
1565}
1566
1567/// Per-call cap on in-flight request-direction credits. Tokio's
1568/// `Semaphore::MAX_PERMITS` is `usize::MAX >> 3`; we cap the
1569/// caller-side accumulator at this value so a misbehaving server
1570/// can't make the caller hold an unbounded outstanding window.
1571/// 1M credits is already orders of magnitude beyond any sane
1572/// request burst — a caller sitting on 1M unconsumed credits is
1573/// either misconfigured or under attack.
1574const REQUEST_GRANT_PER_CALL_CAP: usize = 1_000_000;
1575
1576/// Add `credits` to a caller-side request-direction credit
1577/// semaphore, capped so the accumulator never exceeds
1578/// [`REQUEST_GRANT_PER_CALL_CAP`]. Per-frame cap of `usize::MAX >> 4`
1579/// remains as a second line of defense against pathological frame
1580/// values.
1581fn add_request_grant_credits(sem: &tokio::sync::Semaphore, credits: u32) {
1582 if credits == 0 {
1583 return;
1584 }
1585 let current = sem.available_permits();
1586 let remaining = REQUEST_GRANT_PER_CALL_CAP.saturating_sub(current);
1587 let safe = (credits as usize).min(usize::MAX >> 4).min(remaining);
1588 if safe > 0 {
1589 sem.add_permits(safe);
1590 }
1591}
1592
1593/// Build a coalescing REQUEST_GRANT emitter.
1594///
1595/// Naive emitters `tokio::spawn` one publish task per consumed
1596/// chunk, which becomes a spawn-storm + AEAD-storm under bursting.
1597/// This helper hands back an emitter that pushes `(caller_origin,
1598/// call_id, credits)` into an unbounded mpsc; a single dedicated
1599/// drainer task `try_recv`s the queue to drain whatever is
1600/// immediately available, coalesces credits per call_id, and
1601/// publishes ONE batched REQUEST_GRANT per call per drain cycle.
1602///
1603/// Lifecycle: the drainer task lives as long as any clone of the
1604/// returned emitter (mpsc sender count > 0). When the fold and all
1605/// in-flight handlers release the emitter, `rx.recv` returns `None`
1606/// and the drainer exits naturally.
1607fn build_request_grant_emitter(
1608 mesh: Arc<MeshNode>,
1609 service: String,
1610 server_origin: u64,
1611 diag_tag: &'static str,
1612) -> RpcRequestGrantEmitter {
1613 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(u64, u64, u32)>();
1614 tokio::spawn(async move {
1615 while let Some(first) = rx.recv().await {
1616 let mut summed: std::collections::HashMap<(u64, u64), u32> =
1617 std::collections::HashMap::new();
1618 let (caller, call_id, credits) = first;
1619 summed.insert((caller, call_id), credits);
1620 // Coalesce anything immediately queued behind the first
1621 // wake. Bounded by what the substrate has produced so
1622 // far; doesn't add latency since `try_recv` returns
1623 // immediately when the queue is empty.
1624 while let Ok((caller, call_id, credits)) = rx.try_recv() {
1625 let entry = summed.entry((caller, call_id)).or_insert(0);
1626 *entry = entry.saturating_add(credits);
1627 }
1628 for ((caller, call_id), credits) in summed {
1629 let reply_channel_name = format!("{service}.replies.{caller:016x}");
1630 let reply_channel = match ChannelName::new(&reply_channel_name) {
1631 Ok(c) => c,
1632 Err(e) => {
1633 tracing::warn!(
1634 error = %e,
1635 channel = %reply_channel_name,
1636 tag = diag_tag,
1637 "rpc grant drainer: invalid reply channel name");
1638 continue;
1639 }
1640 };
1641 let meta = EventMeta::new(DISPATCH_RPC_REQUEST_GRANT, 0, server_origin, call_id, 0);
1642 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 12);
1643 buf.extend_from_slice(&meta.to_bytes());
1644 buf.extend_from_slice(&encode_request_grant(call_id, credits));
1645 let publisher = ChannelPublisher::new(reply_channel, PublishConfig::default());
1646 if let Err(e) = mesh.publish(&publisher, Bytes::from(buf)).await {
1647 tracing::warn!(
1648 error = %e,
1649 caller_origin = format!("{:#x}", caller),
1650 call_id,
1651 tag = diag_tag,
1652 "rpc grant drainer: REQUEST_GRANT publish failed");
1653 }
1654 }
1655 }
1656 });
1657 Arc::new(move |caller_origin, call_id, credits| {
1658 // Send failure means the drainer has exited (all sender
1659 // clones dropped, then we somehow cloned a stale one).
1660 // Treat as a no-op — the call is tearing down anyway.
1661 let _ = tx.send((caller_origin, call_id, credits));
1662 })
1663}
1664
1665/// Per-service map from a caller's `origin_hash` (wire field) to the
1666/// AEAD-verified `from_node` of the session that delivered their
1667/// inbound REQUEST. Populated by the serve_rpc bridge tasks at
1668/// REQUEST-receipt time; consulted by [`publish_response_to_caller`]
1669/// to skip the roster fan-out on the response leg.
1670///
1671/// Lives per `serve_rpc*` registration rather than mesh-wide because
1672/// the source-of-truth `MeshNode::origin_hash_to_node` is only safe
1673/// to populate from *signed* capability announcements — populating
1674/// it from unsigned wire `origin_hash` fields would let any session
1675/// peer pre-claim arbitrary origins. This map is bridge-local and
1676/// only used by the matching service's response emit, so a malicious
1677/// peer can at most misdirect responses for THEIR own request — they
1678/// already could.
1679///
1680/// **Bounded** ([`OriginKeyedLru`]): the key is the wire-claimed
1681/// `origin_hash` and the bridge inserts it *before* the capability gate,
1682/// so an unbounded map would let one authed peer spray distinct origins and
1683/// amplify server memory. The LRU caps the footprint; eviction costs only a
1684/// response-path cache miss (roster fallback), never correctness.
1685type RpcOriginNodeCache = Arc<OriginKeyedLru<u64>>;
1686
1687/// Capacity bound for the per-`serve_rpc` caller-keyed caches
1688/// ([`RpcOriginNodeCache`] and the §8b reply-channel cache). Sized for the
1689/// legitimate active-caller working set of a single service; well past it the
1690/// LRU evicts cold origins rather than growing without limit under a
1691/// crafted-origin flood. Each entry is tiny (a `u64` and, for the reply
1692/// cache, an `Arc<str>` channel name), so the whole bound is a few hundred KB
1693/// per service.
1694const RPC_CALLER_CACHE_CAP: usize = 4096;
1695
1696/// Non-zero form of [`RPC_CALLER_CACHE_CAP`], validated at compile time so
1697/// `OriginKeyedLru::new` carries no runtime `unwrap`/`expect`. A zero cap
1698/// would fail the build here rather than panic at startup.
1699const RPC_CALLER_CACHE_CAP_NZ: std::num::NonZeroUsize =
1700 match std::num::NonZeroUsize::new(RPC_CALLER_CACHE_CAP) {
1701 Some(n) => n,
1702 None => panic!("RPC_CALLER_CACHE_CAP must be non-zero"),
1703 };
1704
1705/// Thread-safe, bounded LRU keyed by the wire-claimed caller `origin_hash`.
1706///
1707/// Backs both [`RpcOriginNodeCache`] and the §8b reply-channel cache. Wraps
1708/// `lru::LruCache` (which needs `&mut` even to read, to bump the entry to
1709/// most-recently-used) in a `parking_lot::Mutex`. The per-response lock is
1710/// uncontended in the common case — one fold drives a given service — and is
1711/// far cheaper than the `format!` + `ChannelName` allocation / roster fan-out
1712/// the caches exist to avoid. Eviction is always safe: a miss just recomputes
1713/// the value (channel name) or falls back to the roster lookup.
1714struct OriginKeyedLru<V>(Mutex<lru::LruCache<u64, V>>);
1715
1716impl<V: Clone> OriginKeyedLru<V> {
1717 fn new() -> Self {
1718 Self(Mutex::new(lru::LruCache::new(RPC_CALLER_CACHE_CAP_NZ)))
1719 }
1720
1721 /// Look up `origin`, promoting it to most-recently-used on a hit.
1722 fn get(&self, origin: u64) -> Option<V> {
1723 self.0.lock().get(&origin).cloned()
1724 }
1725
1726 /// Insert / refresh `origin`, evicting the least-recently-used entry
1727 /// when at capacity.
1728 fn insert(&self, origin: u64, value: V) {
1729 self.0.lock().put(origin, value);
1730 }
1731}
1732
1733/// Direct-send a built RESPONSE (or streaming chunk) packet to the
1734/// caller's reply channel, bypassing the roster fan-out path
1735/// [`MeshNode::publish`] uses.
1736///
1737/// **Fast path:** when the bridge has cached the caller's
1738/// `from_node` (i.e. the server processed an inbound REQUEST from
1739/// this caller via an AEAD-authenticated session), or when the
1740/// caller's capability announcement has reached us, the response
1741/// rides `publish_to_peer` — one DashMap lookup instead of roster
1742/// lookup + ACL check + subnet filter + per-recipient `Vec<Bytes>`
1743/// allocation.
1744///
1745/// **Fallback:** when neither lookup resolves — pathological cases
1746/// like a test harness where the caller never announces and the
1747/// bridge cache is empty — fall back to [`MeshNode::publish`] via
1748/// the roster, matching the pre-T1.2 behavior verbatim.
1749/// PERF_AUDIT §3.10 — accepts pre-computed
1750/// `reply_channel_hash` and `reply_stream_id` so the per-response
1751/// path doesn't re-run `ChannelId::new` + xxh3 + `publish_stream_id`
1752/// on every send. The emit closure's `OriginKeyedLru<CachedReplyChannel>`
1753/// caches the triple per caller_origin.
1754async fn publish_response_to_caller(
1755 mesh: &MeshNode,
1756 caller_origin: u64,
1757 target_hint: Option<u64>,
1758 reply_channel: &ChannelName,
1759 reply_channel_hash: ChannelHash,
1760 reply_stream_id: u64,
1761 payload: Bytes,
1762) -> Result<(), AdapterError> {
1763 let resolved = target_hint.or_else(|| mesh.get_node_by_origin_hash(caller_origin));
1764 if let Some(target_node_id) = resolved {
1765 return mesh
1766 .publish_to_peer(
1767 target_node_id,
1768 reply_channel_hash,
1769 reply_stream_id,
1770 /* reliable */ true,
1771 std::slice::from_ref(&payload),
1772 )
1773 .await;
1774 }
1775 // Fallback: roster fan-out. Reached when the caller's origin is
1776 // unknown to both the bridge cache AND the global reverse index.
1777 let publisher = ChannelPublisher::new(reply_channel.clone(), PublishConfig::default());
1778 mesh.publish(&publisher, payload).await.map(|_| ())
1779}
1780
1781/// Shared CANCEL-publish helper: spawn a task that fires a
1782/// CANCEL event for `call_id` to `target` on the request channel.
1783/// Both [`RpcStream::Drop`] and [`UnaryCallGuard::Drop`] use it.
1784fn spawn_cancel_publish(
1785 mesh: Arc<MeshNode>,
1786 target: u64,
1787 request_channel: ChannelName,
1788 self_origin: u64,
1789 call_id: u64,
1790) {
1791 tokio::spawn(async move {
1792 let meta = EventMeta::new(DISPATCH_RPC_CANCEL, 0, self_origin, call_id, 0);
1793 let request_channel_id = ChannelId::new(request_channel);
1794 let request_channel_hash = request_channel_id.hash();
1795 let stream_id = MeshNode::publish_stream_id(&request_channel_id);
1796 let payload = Bytes::from(meta.to_bytes().to_vec());
1797 let _ = mesh
1798 .publish_to_peer(
1799 target,
1800 request_channel_hash,
1801 stream_id,
1802 /* reliable */ true,
1803 std::slice::from_ref(&payload),
1804 )
1805 .await;
1806 });
1807}
1808
1809/// Type alias for the keep-alive sender that streaming-call handles
1810/// store. Its purpose is *only* to signal "stream done" when the
1811/// handle drops: the cancel-watcher task `select!`s on the matching
1812/// receiver, and dropping the sender (which happens on handle Drop)
1813/// resolves the receiver with an `Err` so the watcher exits cleanly.
1814///
1815/// `()` payload because the signal IS the resolution; no data is
1816/// transmitted.
1817type StreamCancelKeepAlive = tokio::sync::oneshot::Sender<()>;
1818
1819/// Spawn a cancel-watcher task for a streaming call (call_streaming,
1820/// call_client_stream, call_duplex). The watcher races
1821/// `cancel_notify.notified()` against the keep-alive oneshot — first
1822/// to fire wins. On cancel, the watcher drops the pending-streaming
1823/// entry (which closes the receiver's mpsc, letting the stream's
1824/// poll_next observe EOF), then releases the registry entry. On
1825/// handle Drop, the keep-alive sender drops, the oneshot resolves
1826/// `Err`, and the watcher exits via the done arm with a registry
1827/// release.
1828///
1829/// When `cancel_token == 0` (the "no token" sentinel), this is a
1830/// no-op: the returned sender is a placeholder whose drop has no
1831/// observable effect, and no task is spawned. Lets the streaming
1832/// call shapes always store a keep-alive on the returned handle
1833/// without branching on whether a token was set.
1834fn spawn_stream_cancel_watcher(
1835 cancel_notify: Arc<tokio::sync::Notify>,
1836 cancel_token: u64,
1837 cancel_registry: Arc<crate::adapter::net::cancel_registry::CancelRegistry>,
1838 pending: Arc<crate::adapter::net::cortex::RpcClientPending>,
1839 call_id: u64,
1840) -> StreamCancelKeepAlive {
1841 let (done_tx, done_rx) = tokio::sync::oneshot::channel();
1842 if cancel_token == 0 {
1843 // No-op fast path. The returned sender is held by the
1844 // handle but never paired with a watcher, so its eventual
1845 // drop has no effect. Avoids spawning a task per
1846 // cancel-less stream.
1847 return done_tx;
1848 }
1849 tokio::spawn(async move {
1850 tokio::select! {
1851 biased;
1852 _ = cancel_notify.notified() => {
1853 // Cancel fired. Drop the pending-stream entry so
1854 // the receiver's mpsc closes (causing the stream's
1855 // poll_next to observe EOF via Ready(None)). The
1856 // handle's Drop will then fire CANCEL on the wire
1857 // via its existing per-shape Drop impl.
1858 pending.cancel(call_id);
1859 cancel_registry.release(cancel_token);
1860 }
1861 _ = done_rx => {
1862 // Stream completed normally — sender dropped on
1863 // handle Drop, recv returns Err. Just release the
1864 // registry entry; no CANCEL emission needed (the
1865 // handle's Drop handles that path itself if it
1866 // wasn't a clean close).
1867 cancel_registry.release(cancel_token);
1868 }
1869 }
1870 });
1871 done_tx
1872}
1873
1874/// One-call helper that registers a cancel-notify against the
1875/// caller's `opts.cancel_token` and spawns the stream cancel
1876/// watcher. Used by every streaming call shape (`call_streaming`,
1877/// `call_client_stream`, `call_duplex`) to keep their bodies free
1878/// of the three-step token/notify/spawn boilerplate.
1879///
1880/// When `opts.cancel_token` is `None` (or `Some(0)`), this is the
1881/// same no-op fast path as [`spawn_stream_cancel_watcher`].
1882fn arm_stream_cancel(
1883 mesh: &Arc<MeshNode>,
1884 opts: &CallOptions,
1885 pending: &Arc<crate::adapter::net::cortex::RpcClientPending>,
1886 call_id: u64,
1887) -> StreamCancelKeepAlive {
1888 let cancel_token = opts.cancel_token.unwrap_or(0);
1889 let cancel_notify = mesh.cancel_registry().register_notify(cancel_token);
1890 spawn_stream_cancel_watcher(
1891 cancel_notify,
1892 cancel_token,
1893 Arc::clone(mesh.cancel_registry()),
1894 Arc::clone(pending),
1895 call_id,
1896 )
1897}
1898
1899/// Side-effects + return value for the unary `call`'s cancel
1900/// branch. Releases the registry entry, records the Transport
1901/// outcome on the metrics guard, fires the Canceled observer
1902/// event, and returns `RpcError::Cancelled`. Both the
1903/// no-deadline and with-deadline `select!` arms invoke this so a
1904/// shape change to the cancel outcome (extra metric, new field
1905/// on the observer event) lands in exactly one place.
1906fn fire_unary_cancel_outcome(
1907 mesh: &Arc<MeshNode>,
1908 metrics_guard: &mut crate::adapter::net::mesh_rpc_metrics::CallMetricsGuard,
1909 cancel_token: u64,
1910 target_node_id: u64,
1911 service: &str,
1912 started_total: Instant,
1913 request_bytes_len: u32,
1914) -> RpcError {
1915 mesh.cancel_registry().release(cancel_token);
1916 metrics_guard.record(crate::adapter::net::mesh_rpc_metrics::CallOutcome::Transport);
1917 mesh.fire_rpc_observer_outbound(
1918 target_node_id,
1919 service,
1920 started_total.elapsed().as_millis() as u32,
1921 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Canceled,
1922 request_bytes_len,
1923 0,
1924 );
1925 RpcError::Cancelled
1926}
1927
1928// ============================================================================
1929// MeshNode extensions.
1930// ============================================================================
1931
1932impl MeshNode {
1933 /// Register an nRPC handler for `service` on this node.
1934 ///
1935 /// Subscribes this node to `<service>.requests` (so the local
1936 /// `register_rpc_inbound` dispatcher feeds inbound REQUEST
1937 /// events into the [`RpcServerFold`]) and wires the fold's
1938 /// RESPONSE-emit callback to publish on
1939 /// `<service>.replies.<caller_origin>` via the existing
1940 /// pub/sub path.
1941 ///
1942 /// **Local-only registration** (Phase 1). Multi-instance
1943 /// services that load-balance via `SubscriptionMode::QueueGroup`
1944 /// require each replica to call `serve_rpc` on its own node;
1945 /// the mesh-level subscriber roster + `dispatch_recipients`
1946 /// then routes one-of-N as designed. Each replica's local
1947 /// `serve_rpc` must use the same service name (which becomes
1948 /// the queue-group identifier).
1949 ///
1950 /// Returns a [`ServeHandle`] whose Drop tears down the
1951 /// registration. Concurrent registrations for the same service
1952 /// on one node return `Err(ServeError::AlreadyServing)`.
1953 pub fn serve_rpc<H: RpcHandler>(
1954 self: &Arc<Self>,
1955 service: &str,
1956 handler: Arc<H>,
1957 ) -> Result<ServeHandle, ServeError> {
1958 let request_channel = ChannelName::new(&format!("{service}.requests"))
1959 .map_err(|e| ServeError::InvalidServiceName(e.to_string()))?;
1960 let channel_hash = request_channel.hash();
1961
1962 // Bridge: a tokio mpsc the inbound dispatcher pushes into.
1963 // The bridge task drains it and runs each event through
1964 // the fold. Bounded so a runaway publisher can't OOM the
1965 // server; over-cap pushes drop the inbound event (which
1966 // surfaces to the caller as a timeout).
1967 let (tx, mut rx) = mpsc::channel::<RpcInboundEvent>(1024);
1968
1969 // T1.2 cache: maps each caller's wire `origin_hash` to the
1970 // AEAD-verified `from_node` of the session that delivered
1971 // its REQUEST. Populated by the bridge below; consumed by
1972 // the emit closure so [`publish_response_to_caller`] can
1973 // skip the roster fan-out on the response leg.
1974 let origin_node_cache: RpcOriginNodeCache = Arc::new(OriginKeyedLru::new());
1975
1976 // Build the emit closure. When the handler completes, the
1977 // fold calls this (synchronously) with `(caller_origin, call_id,
1978 // response)`. §8a: instead of `tokio::spawn`ing a task per response,
1979 // the closure builds the wire payload (cheap, no await) and hands a
1980 // job to a single per-service response drainer task (below), which
1981 // does the `.await` publish. A `tokio::spawn` per response cost
1982 // ~1–2 µs of scheduling on a wake-bound path; a channel send is a
1983 // fraction of that, and the drainer amortizes the wakeup.
1984 let service_for_emit = service.to_string();
1985 let server_origin = self.identity_origin_hash();
1986 let origin_node_cache_for_emit = Arc::clone(&origin_node_cache);
1987 // §8b reply-channel cache: the reply channel name is
1988 // `<service>.replies.<caller_origin:016x>` — deterministic from
1989 // `(service, caller_origin)`, and `service` is fixed for this
1990 // `serve_rpc`, so it varies only by `caller_origin`. `ChannelName` is
1991 // `Arc<str>`, so a cache hit is an Arc bump; this removes the per-
1992 // response `format!` String + `ChannelName::new` (`Arc<str>`) allocation
1993 // (and the per-call `service.clone()`) the emit closure used to pay on
1994 // every response. Keyed by the wire-claimed `caller_origin` and so
1995 // bounded the same way as `origin_node_cache` above — an
1996 // `OriginKeyedLru`, not an unbounded map, so a crafted-origin flood
1997 // can't amplify server memory (a miss just rebuilds the name).
1998 // PERF_AUDIT §3.10 — cache the triple (name, hash, stream_id)
1999 // per caller_origin so the per-response drainer doesn't
2000 // recompute xxh3 + publish_stream_id on every send.
2001 let reply_channel_cache: Arc<OriginKeyedLru<CachedReplyChannel>> =
2002 Arc::new(OriginKeyedLru::new());
2003 // §8a response drainer channel. Bounded like the inbound channel; a
2004 // full channel means the drainer can't keep up, so we drop (the
2005 // caller times out) rather than block the fold.
2006 let (resp_tx, mut resp_rx) = mpsc::channel::<RpcResponseJob>(1024);
2007 let emit: RpcResponseEmitter = Arc::new(move |caller_origin, call_id, resp| {
2008 let target_hint = origin_node_cache_for_emit.get(caller_origin);
2009 // Resolve the reply channel from cache (Arc bump on hit; one
2010 // `format!` + `ChannelName::new` the first time we see a caller).
2011 let cached = match reply_channel_cache.get(caller_origin) {
2012 Some(c) => c,
2013 None => {
2014 let name = format!("{service_for_emit}.replies.{caller_origin:016x}");
2015 match ChannelName::new(&name) {
2016 Ok(channel_name) => {
2017 // Compute hash + stream_id ONCE per caller_origin
2018 // and stash them alongside the name.
2019 let channel_id = ChannelId::new(channel_name.clone());
2020 let triple = CachedReplyChannel {
2021 hash: channel_id.hash(),
2022 stream_id: MeshNode::publish_stream_id(&channel_id),
2023 name: channel_name,
2024 };
2025 reply_channel_cache.insert(caller_origin, triple.clone());
2026 triple
2027 }
2028 Err(e) => {
2029 tracing::warn!(error = %e, channel = %name,
2030 "rpc serve_rpc: invalid reply channel name");
2031 return;
2032 }
2033 }
2034 }
2035 };
2036 // Build the RESPONSE event envelope (24-byte meta + encoded
2037 // payload) synchronously — pure CPU, no await — then hand it to
2038 // the drainer.
2039 let meta = EventMeta::new(
2040 super::cortex::DISPATCH_RPC_RESPONSE,
2041 0,
2042 server_origin,
2043 call_id,
2044 0,
2045 );
2046 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 64);
2047 buf.extend_from_slice(&meta.to_bytes());
2048 resp.encode_into(&mut buf);
2049 if resp_tx
2050 .try_send(RpcResponseJob {
2051 caller_origin,
2052 call_id,
2053 target_hint,
2054 reply_channel: cached.name,
2055 reply_channel_hash: cached.hash,
2056 reply_stream_id: cached.stream_id,
2057 payload: Bytes::from(buf),
2058 })
2059 .is_err()
2060 {
2061 tracing::debug!(
2062 caller_origin = format!("{:#x}", caller_origin),
2063 call_id,
2064 "rpc serve_rpc: response drainer at capacity; dropping response"
2065 );
2066 }
2067 });
2068
2069 // Build the server fold and wrap it in an Arc<Mutex<...>>
2070 // so the bridge task can drive it (the trait takes
2071 // `&mut self`). Attach the per-service metrics handle so
2072 // the spawned handler tasks bump server-side counters.
2073 let metrics_handle = self.rpc_metrics_arc().for_service(service);
2074 // Keep a clone of the emit closure for the callee-side
2075 // capability-auth defense-in-depth path in the bridge
2076 // below — the fold owns its own clone, this one only
2077 // emits the `CapabilityDenied` rejection before the fold
2078 // sees the event.
2079 let emit_for_bridge = Arc::clone(&emit);
2080 // Clone the per-service metrics handle so the bridge can
2081 // bump `capability_denied_total` on gate rejection. The
2082 // fold's own clone (passed via `with_metrics`) handles the
2083 // handler-side counters; this one covers the path BEFORE
2084 // the handler runs, which the fold-side metrics never see.
2085 let metrics_for_bridge = Arc::clone(&metrics_handle);
2086 let fold = Arc::new(Mutex::new(
2087 RpcServerFold::new(handler as Arc<dyn RpcHandler>, emit).with_metrics(metrics_handle),
2088 ));
2089
2090 // Register the inbound dispatcher. Push into the mpsc;
2091 // the bridge task does the actual fold work.
2092 let dispatcher: RpcInboundDispatcher = Arc::new(move |ev| {
2093 // Best-effort send — over-cap means the bridge can't
2094 // keep up; drop and let the caller time out. Logging
2095 // here would spam.
2096 let _ = tx.try_send(ev);
2097 });
2098 // Register the service in `rpc_local_services` and refresh
2099 // the self-indexed announcement BEFORE installing the
2100 // dispatcher so the callee-side gate (in the bridge below)
2101 // sees a self-announcement carrying `nrpc:<service>` the
2102 // moment the first inbound event lands. Without this, the
2103 // gate was either silently permissive (no self-ann) or
2104 // silently denying (self-ann from a prior
2105 // `announce_capabilities` that pre-dated this service's
2106 // registration). See `docs/misc/CODE_REVIEW_2026_05_19_CAPABILITY_AUTH.md`
2107 // H1 + H2.
2108 self.rpc_local_services_arc().insert(service.to_string());
2109 self.index_self_with_local_services();
2110
2111 if self
2112 .register_rpc_inbound(channel_hash, dispatcher)
2113 .is_some()
2114 {
2115 return Err(ServeError::AlreadyServing(service.to_string()));
2116 }
2117
2118 // Spawn the bridge task. It reads inbound events, runs
2119 // the v0.4 capability-auth callee-side gate (defense in
2120 // depth — the caller-side gate inside `call_service`
2121 // covers the well-behaved client path), and on accept
2122 // feeds them to the fold.
2123 let mesh_for_bridge = Arc::clone(self);
2124 let service_for_bridge = service.to_string();
2125 let origin_node_cache_for_bridge = Arc::clone(&origin_node_cache);
2126 let bridge = tokio::spawn(async move {
2127 let tag = format!("nrpc:{}", service_for_bridge);
2128 use crate::adapter::net::behavior::fold::capability_bridge;
2129 while let Some(inbound) = rx.recv().await {
2130 // T1.2 cache populate. `from_node` is the
2131 // AEAD-verified session peer; `origin_hash` is the
2132 // wire-claimed entity (untrusted on its own but
2133 // bound here to a session peer we just authed).
2134 // `from_node == 0` is the loopback/test sentinel —
2135 // skip the insert so the response path falls back
2136 // to the roster lookup instead of trying to send to
2137 // node 0.
2138 if inbound.from_node != 0 {
2139 origin_node_cache_for_bridge.insert(inbound.origin_hash, inbound.from_node);
2140 }
2141 // Defense-in-depth check. Skip only when the wire
2142 // session resolved no NodeId (`from_node == 0` is
2143 // the loopback / test sentinel per
2144 // `RpcInboundEvent::from_node` — production wire
2145 // delivery drops events that fail NodeId
2146 // resolution rather than passing 0). The cold-
2147 // start "no self-ann" skip the original
2148 // implementation carried was a permissive hole;
2149 // `index_self_with_local_services` above
2150 // guarantees a self-ann exists before the
2151 // dispatcher is wired, so denying when the gate
2152 // says no is now the safe failure mode.
2153 let self_node = mesh_for_bridge.node_id();
2154 let from_node = inbound.from_node;
2155 if from_node != 0
2156 && !capability_bridge::may_execute(
2157 mesh_for_bridge.capability_fold(),
2158 self_node,
2159 &tag,
2160 from_node,
2161 )
2162 {
2163 // Decode the EventMeta so we can address the
2164 // caller's reply channel (keyed on
2165 // `caller_origin`) and tag the response with
2166 // the correct `call_id`. A garbled meta means
2167 // the request would have been rejected by the
2168 // fold's own decode path too; drop silently
2169 // to match the existing skip-on-malformed
2170 // behavior there.
2171 let Some(meta) = (if inbound.payload.len() >= EVENT_META_SIZE {
2172 EventMeta::from_bytes(&inbound.payload[..EVENT_META_SIZE])
2173 } else {
2174 None
2175 }) else {
2176 continue;
2177 };
2178 let resp = super::cortex::RpcResponsePayload {
2179 status: RpcStatus::CapabilityDenied,
2180 headers: vec![],
2181 body: Bytes::from(format!(
2182 "callee-side capability-auth gate denied nrpc:{}",
2183 service_for_bridge
2184 )),
2185 };
2186 // Server-side metrics: bump `capability_denied_total`
2187 // on the per-service counter. The fold-side
2188 // metrics never see this path (the handler isn't
2189 // invoked), so without this bump a noisy
2190 // unauthorized caller is invisible to operators
2191 // watching `nrpc_handler_invocations_total` —
2192 // the dashboard sees "0 requests" while the
2193 // caller sees `CapabilityDenied`.
2194 metrics_for_bridge
2195 .capability_denied_total
2196 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2197 (emit_for_bridge)(meta.origin_hash, meta.seq_or_ts, resp);
2198 continue;
2199 }
2200 let payload = inbound.payload;
2201 let entry = RedexEntry::new_heap(0, 0, payload.len() as u32, 0, 0);
2202 let ev = RedexEvent { entry, payload };
2203 if let Err(e) = fold.lock().apply(&ev, &mut ()) {
2204 tracing::warn!(error = %e, "rpc serve_rpc: fold apply error");
2205 }
2206 }
2207 });
2208
2209 // §8a response drainer. Drains `resp_rx` and does the `.await`
2210 // publish that the emit closure used to `tokio::spawn` per response.
2211 // Exits on its own when `resp_tx` (held only by the emit closure,
2212 // which the fold owns) is dropped — i.e. when the bridge task ends
2213 // and the fold drops, the same teardown that stops `_bridge`.
2214 let response_drain_mesh = Arc::clone(self);
2215 let response_drain = tokio::spawn(async move {
2216 while let Some(job) = resp_rx.recv().await {
2217 if let Err(e) = publish_response_to_caller(
2218 &response_drain_mesh,
2219 job.caller_origin,
2220 job.target_hint,
2221 &job.reply_channel,
2222 job.reply_channel_hash,
2223 job.reply_stream_id,
2224 job.payload,
2225 )
2226 .await
2227 {
2228 tracing::warn!(
2229 error = %e,
2230 caller_origin = format!("{:#x}", job.caller_origin),
2231 call_id = job.call_id,
2232 "rpc serve_rpc: response publish failed"
2233 );
2234 }
2235 }
2236 });
2237
2238 // Spawn an async re-announce so peers also learn about
2239 // the new service without the operator having to call
2240 // `announce_capabilities` manually. The local self-index
2241 // already happened above; this is purely for peer
2242 // visibility (the broadcast path also re-runs the
2243 // self-index, which is a cheap version bump).
2244 let mesh_for_announce = Arc::clone(self);
2245 let service_for_log = service.to_string();
2246 tokio::spawn(async move {
2247 let baseline = mesh_for_announce.user_caps_snapshot();
2248 if let Err(e) = mesh_for_announce.announce_capabilities(baseline).await {
2249 tracing::warn!(
2250 error = %e,
2251 service = %service_for_log,
2252 "serve_rpc: auto re-announce failed",
2253 );
2254 }
2255 });
2256
2257 Ok(ServeHandle {
2258 channel_hash,
2259 service: service.to_string(),
2260 _bridge: bridge,
2261 _response_drain: Some(response_drain),
2262 mesh: Arc::clone(self),
2263 })
2264 }
2265
2266 /// Streaming variant of [`Self::serve_rpc`]. The handler
2267 /// receives an [`RpcResponseSink`](super::cortex::RpcResponseSink)
2268 /// it writes chunks to via `sink.send(body)`; returning
2269 /// `Ok(())` closes the stream cleanly, `Err(_)` closes with
2270 /// an error frame.
2271 ///
2272 /// Wire-level identical to the unary path apart from the
2273 /// per-chunk `nrpc-streaming` header markers
2274 /// (`continue` / `end`). Same auto-registration of
2275 /// `<service>.requests` + `<service>.replies.` prefix.
2276 pub fn serve_rpc_streaming<H: RpcStreamingHandler>(
2277 self: &Arc<Self>,
2278 service: &str,
2279 handler: Arc<H>,
2280 ) -> Result<ServeHandle, ServeError> {
2281 let request_channel = ChannelName::new(&format!("{service}.requests"))
2282 .map_err(|e| ServeError::InvalidServiceName(e.to_string()))?;
2283 let channel_hash = request_channel.hash();
2284 let (tx, mut rx) = tokio::sync::mpsc::channel::<RpcInboundEvent>(1024);
2285
2286 // T1.2 cache: bridge populates from inbound.from_node, emit
2287 // closure consults to skip roster fan-out. See the unary
2288 // serve_rpc above for the full rationale.
2289 let origin_node_cache: RpcOriginNodeCache = Arc::new(OriginKeyedLru::new());
2290
2291 let mesh_for_emit = Arc::clone(self);
2292 let service_for_emit = service.to_string();
2293 let server_origin = self.identity_origin_hash();
2294 let origin_node_cache_for_emit = Arc::clone(&origin_node_cache);
2295 // Async emit so the streaming fold's pump can `.await` each
2296 // publish — guarantees per-call chunk ordering on the wire.
2297 let emit: RpcAsyncResponseEmitter = Arc::new(move |caller_origin, call_id, resp| {
2298 let mesh = Arc::clone(&mesh_for_emit);
2299 let service = service_for_emit.clone();
2300 let target_hint = origin_node_cache_for_emit.get(caller_origin);
2301 Box::pin(async move {
2302 let reply_channel_name = format!("{service}.replies.{caller_origin:016x}");
2303 let reply_channel = match ChannelName::new(&reply_channel_name) {
2304 Ok(c) => c,
2305 Err(e) => {
2306 tracing::warn!(error = %e, channel = %reply_channel_name,
2307 "rpc serve_rpc_streaming: invalid reply channel name");
2308 return;
2309 }
2310 };
2311 let meta = EventMeta::new(
2312 super::cortex::DISPATCH_RPC_RESPONSE,
2313 0,
2314 server_origin,
2315 call_id,
2316 0,
2317 );
2318 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 64);
2319 buf.extend_from_slice(&meta.to_bytes());
2320 resp.encode_into(&mut buf);
2321 // PERF_AUDIT §3.10: compute hash + stream_id at the
2322 // call site. These legacy streaming paths don't yet
2323 // cache the triple via `OriginKeyedLru<CachedReplyChannel>`;
2324 // wiring them up is a follow-up — for now the
2325 // compute happens here per response, same as the
2326 // pre-fix in-function shape.
2327 let reply_channel_id = ChannelId::new(reply_channel.clone());
2328 let reply_channel_hash = reply_channel_id.hash();
2329 let reply_stream_id = MeshNode::publish_stream_id(&reply_channel_id);
2330 if let Err(e) = publish_response_to_caller(
2331 &mesh,
2332 caller_origin,
2333 target_hint,
2334 &reply_channel,
2335 reply_channel_hash,
2336 reply_stream_id,
2337 Bytes::from(buf),
2338 )
2339 .await
2340 {
2341 tracing::warn!(error = %e,
2342 caller_origin = format!("{:#x}", caller_origin),
2343 call_id,
2344 "rpc serve_rpc_streaming: chunk publish failed");
2345 }
2346 })
2347 });
2348
2349 // Attach per-service metrics so the spawned handler tasks
2350 // + pump task bump server-side counters (including the
2351 // streaming-only `streaming_chunks_emitted_total`).
2352 let metrics_handle = self.rpc_metrics_arc().for_service(service);
2353 let fold = Arc::new(Mutex::new(
2354 RpcServerStreamingFold::new(handler as Arc<dyn RpcStreamingHandler>, emit)
2355 .with_metrics(metrics_handle),
2356 ));
2357 let dispatcher: RpcInboundDispatcher = Arc::new(move |ev| {
2358 let _ = tx.try_send(ev);
2359 });
2360 if self
2361 .register_rpc_inbound(channel_hash, dispatcher)
2362 .is_some()
2363 {
2364 return Err(ServeError::AlreadyServing(service.to_string()));
2365 }
2366 let origin_node_cache_for_bridge = Arc::clone(&origin_node_cache);
2367 let bridge = tokio::spawn(async move {
2368 while let Some(inbound) = rx.recv().await {
2369 if inbound.from_node != 0 {
2370 origin_node_cache_for_bridge.insert(inbound.origin_hash, inbound.from_node);
2371 }
2372 let payload = inbound.payload;
2373 let entry = RedexEntry::new_heap(0, 0, payload.len() as u32, 0, 0);
2374 let ev = RedexEvent { entry, payload };
2375 if let Err(e) = fold.lock().apply(&ev, &mut ()) {
2376 tracing::warn!(error = %e, "rpc serve_rpc_streaming: fold apply error");
2377 }
2378 }
2379 });
2380 self.rpc_local_services_arc().insert(service.to_string());
2381 Ok(ServeHandle {
2382 channel_hash,
2383 service: service.to_string(),
2384 _bridge: bridge,
2385 // Streaming/duplex variants still spawn per emit (§8a covers the
2386 // unary hot path); no drainer.
2387 _response_drain: None,
2388 mesh: Arc::clone(self),
2389 })
2390 }
2391
2392 /// Register a client-streaming nRPC handler for `service`.
2393 /// Mirror of [`Self::serve_rpc_streaming`] but using the
2394 /// request-side fold ([`RpcStreamingRequestFold`]) — the
2395 /// handler receives one stream of REQUEST_CHUNK bodies and
2396 /// emits one terminal RESPONSE.
2397 ///
2398 /// Wires two emit callbacks:
2399 /// - A sync [`RpcResponseEmitter`] for the terminal RESPONSE
2400 /// (single emit per call, no ordering concern).
2401 /// - An [`RpcRequestGrantEmitter`] for upload-direction
2402 /// credit grants, which publishes [`DISPATCH_RPC_REQUEST_GRANT`]
2403 /// events on the caller's reply channel.
2404 ///
2405 /// Bidi streaming plan (Phase C).
2406 pub fn serve_rpc_client_stream<H: RpcClientStreamingHandler>(
2407 self: &Arc<Self>,
2408 service: &str,
2409 handler: Arc<H>,
2410 ) -> Result<ServeHandle, ServeError> {
2411 let request_channel = ChannelName::new(&format!("{service}.requests"))
2412 .map_err(|e| ServeError::InvalidServiceName(e.to_string()))?;
2413 let channel_hash = request_channel.hash();
2414 let (tx, mut rx) = tokio::sync::mpsc::channel::<RpcInboundEvent>(1024);
2415
2416 // T1.2 cache — see serve_rpc above for full rationale.
2417 let origin_node_cache: RpcOriginNodeCache = Arc::new(OriginKeyedLru::new());
2418
2419 let mesh_for_emit = Arc::clone(self);
2420 let service_for_emit = service.to_string();
2421 let server_origin = self.identity_origin_hash();
2422
2423 // Terminal RESPONSE emitter — sync because there's only
2424 // one RESPONSE per call (no per-call ordering concern that
2425 // would require an async-await between chunks).
2426 let emit_resp_mesh = Arc::clone(&mesh_for_emit);
2427 let emit_resp_service = service_for_emit.clone();
2428 let origin_node_cache_for_emit = Arc::clone(&origin_node_cache);
2429 let emit_resp: RpcResponseEmitter = Arc::new(move |caller_origin, call_id, resp| {
2430 let mesh = Arc::clone(&emit_resp_mesh);
2431 let service = emit_resp_service.clone();
2432 let target_hint = origin_node_cache_for_emit.get(caller_origin);
2433 tokio::spawn(async move {
2434 let reply_channel_name = format!("{service}.replies.{caller_origin:016x}");
2435 let reply_channel = match ChannelName::new(&reply_channel_name) {
2436 Ok(c) => c,
2437 Err(e) => {
2438 tracing::warn!(error = %e, channel = %reply_channel_name,
2439 "rpc serve_rpc_client_stream: invalid reply channel name");
2440 return;
2441 }
2442 };
2443 let meta = EventMeta::new(
2444 super::cortex::DISPATCH_RPC_RESPONSE,
2445 0,
2446 server_origin,
2447 call_id,
2448 0,
2449 );
2450 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 64);
2451 buf.extend_from_slice(&meta.to_bytes());
2452 resp.encode_into(&mut buf);
2453 // PERF_AUDIT §3.10: compute hash + stream_id at the
2454 // call site. These legacy streaming paths don't yet
2455 // cache the triple via `OriginKeyedLru<CachedReplyChannel>`;
2456 // wiring them up is a follow-up — for now the
2457 // compute happens here per response, same as the
2458 // pre-fix in-function shape.
2459 let reply_channel_id = ChannelId::new(reply_channel.clone());
2460 let reply_channel_hash = reply_channel_id.hash();
2461 let reply_stream_id = MeshNode::publish_stream_id(&reply_channel_id);
2462 if let Err(e) = publish_response_to_caller(
2463 &mesh,
2464 caller_origin,
2465 target_hint,
2466 &reply_channel,
2467 reply_channel_hash,
2468 reply_stream_id,
2469 Bytes::from(buf),
2470 )
2471 .await
2472 {
2473 tracing::warn!(error = %e,
2474 caller_origin = format!("{:#x}", caller_origin),
2475 call_id,
2476 "rpc serve_rpc_client_stream: terminal RESPONSE publish failed");
2477 }
2478 });
2479 });
2480
2481 // REQUEST_GRANT emitter — coalesces per-chunk credits into
2482 // a single drainer task that batches by call_id. Avoids the
2483 // tokio::spawn-per-emit storm under bursting.
2484 let emit_grant = build_request_grant_emitter(
2485 Arc::clone(&mesh_for_emit),
2486 service_for_emit.clone(),
2487 server_origin,
2488 "serve_rpc_client_stream",
2489 );
2490
2491 let metrics_handle = self.rpc_metrics_arc().for_service(service);
2492 let fold = Arc::new(Mutex::new(
2493 RpcStreamingRequestFold::new(handler as Arc<dyn RpcClientStreamingHandler>, emit_resp)
2494 .with_grant_emitter(emit_grant)
2495 .with_metrics(metrics_handle),
2496 ));
2497 let dispatcher: RpcInboundDispatcher = Arc::new(move |ev| {
2498 let _ = tx.try_send(ev);
2499 });
2500 if self
2501 .register_rpc_inbound(channel_hash, dispatcher)
2502 .is_some()
2503 {
2504 return Err(ServeError::AlreadyServing(service.to_string()));
2505 }
2506 let origin_node_cache_for_bridge = Arc::clone(&origin_node_cache);
2507 let bridge = tokio::spawn(async move {
2508 while let Some(inbound) = rx.recv().await {
2509 if inbound.from_node != 0 {
2510 origin_node_cache_for_bridge.insert(inbound.origin_hash, inbound.from_node);
2511 }
2512 let payload = inbound.payload;
2513 let entry = RedexEntry::new_heap(0, 0, payload.len() as u32, 0, 0);
2514 let ev = RedexEvent { entry, payload };
2515 if let Err(e) = fold.lock().apply(&ev, &mut ()) {
2516 tracing::warn!(error = %e,
2517 "rpc serve_rpc_client_stream: fold apply error");
2518 }
2519 }
2520 });
2521 self.rpc_local_services_arc().insert(service.to_string());
2522 Ok(ServeHandle {
2523 channel_hash,
2524 service: service.to_string(),
2525 _bridge: bridge,
2526 // Streaming/duplex variants still spawn per emit (§8a covers the
2527 // unary hot path); no drainer.
2528 _response_drain: None,
2529 mesh: Arc::clone(self),
2530 })
2531 }
2532
2533 /// Client-streaming variant of [`Self::call`]. Returns a
2534 /// [`ClientStreamCallRaw`] handle the caller pushes N items
2535 /// into via `send`, then `finish` to await the terminal
2536 /// RESPONSE.
2537 ///
2538 /// **Lazy initial REQUEST.** This method does NOT publish a
2539 /// REQUEST to the wire. It only ensures the caller's reply
2540 /// subscription is set up and registers the pending entry; the
2541 /// initial REQUEST is emitted by the first `send` (or by
2542 /// `finish` for the zero-item degenerate path).
2543 ///
2544 /// Sets `FLAG_RPC_CLIENT_STREAMING_REQUEST` on the initial
2545 /// REQUEST so the server's request-streaming fold knows to
2546 /// open a request-side stream. Optional `request_window_initial`
2547 /// header opts into upload-direction flow control.
2548 ///
2549 /// Bidi streaming plan (Phase C).
2550 pub async fn call_client_stream(
2551 self: &Arc<Self>,
2552 target_node_id: u64,
2553 service: &str,
2554 opts: CallOptions,
2555 ) -> Result<ClientStreamCallRaw, RpcError> {
2556 // `request_window_initial = Some(0)` would deadlock the
2557 // caller: every `send` awaits a credit, but the initial
2558 // REQUEST is lazy (not emitted until the first send), so
2559 // the server never sees the call and never publishes a
2560 // GRANT. Reject up front — `None` means "unbounded credit",
2561 // any positive value opts into flow control.
2562 if matches!(opts.request_window_initial, Some(0)) {
2563 return Err(RpcError::Codec {
2564 direction: CodecDirection::Encode,
2565 message: "request_window_initial must be None or >= 1; Some(0) deadlocks send"
2566 .to_string(),
2567 });
2568 }
2569 // T1.3: per-service route cache (see PERF_AUDIT
2570 // 2026-05-19). One DashMap::get + Arc::clone instead of
2571 // 2 format! + 2 ChannelName::new + xxhash per call.
2572 let route = self.rpc_route_or_no_route(target_node_id, service)?;
2573 let self_origin = self.identity_origin_hash();
2574 self.ensure_reply_subscription(
2575 target_node_id,
2576 service,
2577 route.reply_channel.clone(),
2578 route.reply_hash,
2579 )
2580 .await?;
2581
2582 let call_id = mint_random_call_id();
2583 let pending = self.rpc_client_pending();
2584 let (terminal_rx, mut grant_rx) =
2585 pending.register_client_streaming(call_id, target_node_id);
2586
2587 // Build the header set + flags we'll queue for the initial
2588 // REQUEST (deferred to the first send / finish).
2589 let mut initial_flags = FLAG_RPC_CLIENT_STREAMING_REQUEST;
2590 let mut initial_headers: Vec<(String, Vec<u8>)> = Vec::new();
2591 if let Some(tc) = opts.trace_context.as_ref() {
2592 initial_flags |= FLAG_RPC_PROPAGATE_TRACE;
2593 initial_headers.extend(build_trace_headers(tc));
2594 }
2595 if let Some(window) = opts.request_window_initial {
2596 initial_headers.push((
2597 HEADER_NRPC_REQUEST_WINDOW_INITIAL.to_string(),
2598 window.to_string().into_bytes(),
2599 ));
2600 }
2601 initial_headers.extend(opts.request_headers.iter().cloned());
2602
2603 // Per-call credit semaphore when flow control is opted in.
2604 // Initial permits = the caller's declared window. Refilled
2605 // by REQUEST_GRANT events arriving on the reply channel,
2606 // pumped through `grant_rx` by the spawned `grant_pump`.
2607 let credit_sem = opts
2608 .request_window_initial
2609 .map(|n| Arc::new(tokio::sync::Semaphore::new(n as usize)));
2610 let grant_pump = credit_sem.as_ref().map(|sem| {
2611 let sem = Arc::clone(sem);
2612 tokio::spawn(async move {
2613 while let Some(credits) = grant_rx.recv().await {
2614 add_request_grant_credits(&sem, credits);
2615 }
2616 })
2617 });
2618
2619 let deadline_ns = opts.deadline.map(instant_to_unix_nanos).unwrap_or(0);
2620 let observer = StreamingObserverState::new(Arc::clone(self), target_node_id, service, 0);
2621 let cancel_keep_alive = arm_stream_cancel(self, &opts, &pending, call_id);
2622 Ok(ClientStreamCallRaw {
2623 mesh: Arc::clone(self),
2624 target_node_id,
2625 request_channel: route.request_channel.clone(),
2626 request_channel_hash: route.request_channel_hash,
2627 request_stream_id: route.request_stream_id,
2628 self_origin,
2629 call_id,
2630 service: service.to_string(),
2631 initial_headers,
2632 initial_flags,
2633 deadline_ns,
2634 credit_sem,
2635 grant_pump,
2636 terminal_rx: Some(terminal_rx),
2637 state: ClientStreamState::JustOpened,
2638 started: Instant::now(),
2639 observer,
2640 _cancel_keep_alive: cancel_keep_alive,
2641 })
2642 }
2643
2644 /// Register a duplex nRPC handler for `service`. Composes
2645 /// [`Self::serve_rpc_client_stream`] (request-side stream)
2646 /// with [`Self::serve_rpc_streaming`] (response-side multi-
2647 /// fire emit) via [`RpcDuplexFold`].
2648 ///
2649 /// Wires THREE emit callbacks:
2650 /// - Async [`RpcAsyncResponseEmitter`] for response chunks +
2651 /// the terminal frame (per-call ordering required because
2652 /// the response side is multi-fire).
2653 /// - [`RpcRequestGrantEmitter`] for upload-direction credit
2654 /// grants (one per consumed request chunk when flow
2655 /// control is opted into).
2656 ///
2657 /// Bidi streaming plan (Phase D).
2658 pub fn serve_rpc_duplex<H: RpcDuplexHandler>(
2659 self: &Arc<Self>,
2660 service: &str,
2661 handler: Arc<H>,
2662 ) -> Result<ServeHandle, ServeError> {
2663 let request_channel = ChannelName::new(&format!("{service}.requests"))
2664 .map_err(|e| ServeError::InvalidServiceName(e.to_string()))?;
2665 let channel_hash = request_channel.hash();
2666 let (tx, mut rx) = tokio::sync::mpsc::channel::<RpcInboundEvent>(1024);
2667
2668 // T1.2 cache — see serve_rpc above for full rationale.
2669 let origin_node_cache: RpcOriginNodeCache = Arc::new(OriginKeyedLru::new());
2670
2671 let mesh_for_emit = Arc::clone(self);
2672 let service_for_emit = service.to_string();
2673 let server_origin = self.identity_origin_hash();
2674
2675 // Async response emitter — per-call ordering matters here
2676 // because the response side is multi-fire (same rationale
2677 // as serve_rpc_streaming).
2678 let emit_resp_mesh = Arc::clone(&mesh_for_emit);
2679 let emit_resp_service = service_for_emit.clone();
2680 let origin_node_cache_for_emit = Arc::clone(&origin_node_cache);
2681 let emit_resp: RpcAsyncResponseEmitter = Arc::new(move |caller_origin, call_id, resp| {
2682 let mesh = Arc::clone(&emit_resp_mesh);
2683 let service = emit_resp_service.clone();
2684 let target_hint = origin_node_cache_for_emit.get(caller_origin);
2685 Box::pin(async move {
2686 let reply_channel_name = format!("{service}.replies.{caller_origin:016x}");
2687 let reply_channel = match ChannelName::new(&reply_channel_name) {
2688 Ok(c) => c,
2689 Err(e) => {
2690 tracing::warn!(error = %e, channel = %reply_channel_name,
2691 "rpc serve_rpc_duplex: invalid reply channel name");
2692 return;
2693 }
2694 };
2695 let meta = EventMeta::new(
2696 super::cortex::DISPATCH_RPC_RESPONSE,
2697 0,
2698 server_origin,
2699 call_id,
2700 0,
2701 );
2702 let mut buf = Vec::with_capacity(EVENT_META_SIZE + 64);
2703 buf.extend_from_slice(&meta.to_bytes());
2704 resp.encode_into(&mut buf);
2705 // PERF_AUDIT §3.10: compute hash + stream_id at the
2706 // call site. These legacy streaming paths don't yet
2707 // cache the triple via `OriginKeyedLru<CachedReplyChannel>`;
2708 // wiring them up is a follow-up — for now the
2709 // compute happens here per response, same as the
2710 // pre-fix in-function shape.
2711 let reply_channel_id = ChannelId::new(reply_channel.clone());
2712 let reply_channel_hash = reply_channel_id.hash();
2713 let reply_stream_id = MeshNode::publish_stream_id(&reply_channel_id);
2714 if let Err(e) = publish_response_to_caller(
2715 &mesh,
2716 caller_origin,
2717 target_hint,
2718 &reply_channel,
2719 reply_channel_hash,
2720 reply_stream_id,
2721 Bytes::from(buf),
2722 )
2723 .await
2724 {
2725 tracing::warn!(error = %e,
2726 caller_origin = format!("{:#x}", caller_origin),
2727 call_id,
2728 "rpc serve_rpc_duplex: chunk publish failed");
2729 }
2730 })
2731 });
2732
2733 // Request-direction grant emitter — same coalescing
2734 // drainer shape as serve_rpc_client_stream.
2735 let emit_grant = build_request_grant_emitter(
2736 Arc::clone(&mesh_for_emit),
2737 service_for_emit.clone(),
2738 server_origin,
2739 "serve_rpc_duplex",
2740 );
2741
2742 let metrics_handle = self.rpc_metrics_arc().for_service(service);
2743 let fold = Arc::new(Mutex::new(
2744 RpcDuplexFold::new(handler as Arc<dyn RpcDuplexHandler>, emit_resp)
2745 .with_grant_emitter(emit_grant)
2746 .with_metrics(metrics_handle),
2747 ));
2748 let dispatcher: RpcInboundDispatcher = Arc::new(move |ev| {
2749 let _ = tx.try_send(ev);
2750 });
2751 if self
2752 .register_rpc_inbound(channel_hash, dispatcher)
2753 .is_some()
2754 {
2755 return Err(ServeError::AlreadyServing(service.to_string()));
2756 }
2757 let origin_node_cache_for_bridge = Arc::clone(&origin_node_cache);
2758 let bridge = tokio::spawn(async move {
2759 while let Some(inbound) = rx.recv().await {
2760 if inbound.from_node != 0 {
2761 origin_node_cache_for_bridge.insert(inbound.origin_hash, inbound.from_node);
2762 }
2763 let payload = inbound.payload;
2764 let entry = RedexEntry::new_heap(0, 0, payload.len() as u32, 0, 0);
2765 let ev = RedexEvent { entry, payload };
2766 if let Err(e) = fold.lock().apply(&ev, &mut ()) {
2767 tracing::warn!(error = %e,
2768 "rpc serve_rpc_duplex: fold apply error");
2769 }
2770 }
2771 });
2772 self.rpc_local_services_arc().insert(service.to_string());
2773 Ok(ServeHandle {
2774 channel_hash,
2775 service: service.to_string(),
2776 _bridge: bridge,
2777 // Streaming/duplex variants still spawn per emit (§8a covers the
2778 // unary hot path); no drainer.
2779 _response_drain: None,
2780 mesh: Arc::clone(self),
2781 })
2782 }
2783
2784 /// Duplex variant of [`Self::call`]. Returns a
2785 /// [`DuplexCallRaw`] handle with both upload (`send`,
2786 /// `finish_sending`) and download (`next`, or impl
2787 /// `futures::Stream`) surfaces. Use `into_split` to peel off
2788 /// the two halves for the "encoder task + decoder task"
2789 /// shape.
2790 ///
2791 /// Initial REQUEST sets BOTH `FLAG_RPC_CLIENT_STREAMING_REQUEST`
2792 /// AND `FLAG_RPC_STREAMING_RESPONSE`. Lazy publish — the
2793 /// initial REQUEST flies on the first `send` (or on
2794 /// `finish_sending` for the zero-item degenerate path).
2795 ///
2796 /// Bidi streaming plan (Phase D).
2797 pub async fn call_duplex(
2798 self: &Arc<Self>,
2799 target_node_id: u64,
2800 service: &str,
2801 opts: CallOptions,
2802 ) -> Result<DuplexCallRaw, RpcError> {
2803 // Same deadlock guard as `call_client_stream`: Some(0)
2804 // means "send must await a credit that can never arrive"
2805 // because the initial REQUEST is lazy.
2806 if matches!(opts.request_window_initial, Some(0)) {
2807 return Err(RpcError::Codec {
2808 direction: CodecDirection::Encode,
2809 message: "request_window_initial must be None or >= 1; Some(0) deadlocks send"
2810 .to_string(),
2811 });
2812 }
2813 // T1.3: per-service route cache (see PERF_AUDIT
2814 // 2026-05-19). One DashMap::get + Arc::clone instead of
2815 // 2 format! + 2 ChannelName::new + xxhash per call.
2816 let route = self.rpc_route_or_no_route(target_node_id, service)?;
2817 let self_origin = self.identity_origin_hash();
2818 self.ensure_reply_subscription(
2819 target_node_id,
2820 service,
2821 route.reply_channel.clone(),
2822 route.reply_hash,
2823 )
2824 .await?;
2825
2826 let call_id = mint_random_call_id();
2827 let pending = self.rpc_client_pending();
2828 let (chunks_rx, mut grant_rx) = pending.register_duplex(call_id, target_node_id);
2829
2830 let mut initial_flags = FLAG_RPC_CLIENT_STREAMING_REQUEST | FLAG_RPC_STREAMING_RESPONSE;
2831 let mut initial_headers: Vec<(String, Vec<u8>)> = Vec::new();
2832 if let Some(tc) = opts.trace_context.as_ref() {
2833 initial_flags |= FLAG_RPC_PROPAGATE_TRACE;
2834 initial_headers.extend(build_trace_headers(tc));
2835 }
2836 if let Some(window) = opts.request_window_initial {
2837 initial_headers.push((
2838 HEADER_NRPC_REQUEST_WINDOW_INITIAL.to_string(),
2839 window.to_string().into_bytes(),
2840 ));
2841 }
2842 if let Some(window) = opts.stream_window_initial {
2843 initial_headers.push((
2844 HEADER_NRPC_STREAM_WINDOW_INITIAL.to_string(),
2845 window.to_string().into_bytes(),
2846 ));
2847 }
2848 initial_headers.extend(opts.request_headers.iter().cloned());
2849
2850 let credit_sem = opts
2851 .request_window_initial
2852 .map(|n| Arc::new(tokio::sync::Semaphore::new(n as usize)));
2853 let grant_pump = credit_sem.as_ref().map(|sem| {
2854 let sem = Arc::clone(sem);
2855 tokio::spawn(async move {
2856 while let Some(credits) = grant_rx.recv().await {
2857 add_request_grant_credits(&sem, credits);
2858 }
2859 })
2860 });
2861
2862 let deadline_ns = opts.deadline.map(instant_to_unix_nanos).unwrap_or(0);
2863 let observer = StreamingObserverState::new(Arc::clone(self), target_node_id, service, 0);
2864 // Cancel keep-alive lives on the shared Arc<DuplexInner>
2865 // so it survives into_split — the watcher exits only when
2866 // BOTH the sink AND stream halves drop, matching the
2867 // existing CANCEL-on-drop semantics.
2868 let cancel_keep_alive = arm_stream_cancel(self, &opts, &pending, call_id);
2869 let inner = Arc::new(DuplexInner {
2870 mesh: Arc::clone(self),
2871 target_node_id,
2872 request_channel: route.request_channel.clone(),
2873 request_channel_hash: route.request_channel_hash,
2874 request_stream_id: route.request_stream_id,
2875 self_origin,
2876 call_id,
2877 initial_sent: std::sync::atomic::AtomicBool::new(false),
2878 clean_close: std::sync::atomic::AtomicBool::new(false),
2879 observer,
2880 _cancel_keep_alive: Some(cancel_keep_alive),
2881 });
2882 let sink = DuplexSink {
2883 inner: Arc::clone(&inner),
2884 service: service.to_string(),
2885 initial_headers,
2886 initial_flags,
2887 deadline_ns,
2888 credit_sem,
2889 grant_pump,
2890 state: ClientStreamState::JustOpened,
2891 };
2892 let stream = DuplexStream {
2893 inner,
2894 chunks_rx,
2895 done: false,
2896 };
2897 Ok(DuplexCallRaw { sink, stream })
2898 }
2899
2900 /// Streaming variant of [`Self::call`]. Returns an
2901 /// [`RpcStream`] that yields chunks (as `Result<Bytes, RpcError>`)
2902 /// until the server closes the stream.
2903 ///
2904 /// Sets `FLAG_RPC_STREAMING_RESPONSE` on the request so the
2905 /// server's streaming fold knows to expect multi-fire emits.
2906 /// Same lazy reply-subscription + direct-unicast REQUEST
2907 /// as the unary `call` path.
2908 ///
2909 /// Cancellation: dropping the returned `RpcStream` emits a
2910 /// CANCEL to the server (best-effort) and discards any
2911 /// in-flight chunks.
2912 pub async fn call_streaming(
2913 self: &Arc<Self>,
2914 target_node_id: u64,
2915 service: &str,
2916 payload: Bytes,
2917 opts: CallOptions,
2918 ) -> Result<RpcStream, RpcError> {
2919 // `stream_window_initial = Some(0)` would deadlock the
2920 // RESPONSE direction by default: server's pump awaits one
2921 // credit per chunk, the caller's auto-grant only fires on
2922 // consumed chunks, and the first chunk can never be
2923 // delivered. `None` means "unbounded credit"; any positive
2924 // value opts into flow control. Reject up front — symmetric
2925 // with the request-direction guard in `call_client_stream`.
2926 if matches!(opts.stream_window_initial, Some(0)) {
2927 return Err(RpcError::Codec {
2928 direction: CodecDirection::Encode,
2929 message: "stream_window_initial must be None or >= 1; Some(0) deadlocks the response pump"
2930 .to_string(),
2931 });
2932 }
2933 // T1.3: per-service route cache. One DashMap::get + Arc::clone
2934 // on the hot path instead of 2 format! + 2 ChannelName::new +
2935 // xxhash per call.
2936 let route = self.rpc_route_or_no_route(target_node_id, service)?;
2937 let self_origin = self.identity_origin_hash();
2938 self.ensure_reply_subscription(
2939 target_node_id,
2940 service,
2941 route.reply_channel.clone(),
2942 route.reply_hash,
2943 )
2944 .await?;
2945
2946 let call_id = mint_random_call_id();
2947 let pending = self.rpc_client_pending();
2948 // S-4 part 2: bind the pending entry to the wire-session
2949 // peer the request is dispatched to. The fold's deliver
2950 // gate rejects RESPONSE frames whose from_node doesn't
2951 // match, so a leaked call_id alone can't spoof a reply.
2952 let rx = pending.register_streaming(call_id, target_node_id);
2953
2954 // Build the REQUEST: STREAMING_RESPONSE flag plus optional
2955 // trace-context headers / propagate-trace flag, same as
2956 // unary `call`. Plus the optional flow-control header
2957 // (`nrpc-stream-window-initial`) when the caller opted in
2958 // via `CallOptions::stream_window_initial`.
2959 let mut flags = FLAG_RPC_STREAMING_RESPONSE;
2960 let mut headers = Vec::new();
2961 if let Some(tc) = opts.trace_context.as_ref() {
2962 flags |= FLAG_RPC_PROPAGATE_TRACE;
2963 headers.extend(build_trace_headers(tc));
2964 }
2965 if let Some(window) = opts.stream_window_initial {
2966 headers.push((
2967 HEADER_NRPC_STREAM_WINDOW_INITIAL.to_string(),
2968 window.to_string().into_bytes(),
2969 ));
2970 }
2971 // Append caller-supplied request headers (Phase 9b — same
2972 // semantics as the unary `call` path).
2973 headers.extend(opts.request_headers.iter().cloned());
2974 let req = RpcRequestPayload {
2975 service: service.to_string(),
2976 deadline_ns: opts.deadline.map(instant_to_unix_nanos).unwrap_or(0),
2977 flags,
2978 headers,
2979 body: payload.clone(),
2980 };
2981 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, self_origin, call_id, 0);
2982 let mut buf = Vec::with_capacity(EVENT_META_SIZE + req.body.len() + 32);
2983 buf.extend_from_slice(&meta.to_bytes());
2984 req.encode_into(&mut buf);
2985
2986 let payload_bytes = Bytes::from(buf);
2987 if let Err(e) = self
2988 .publish_to_peer(
2989 target_node_id,
2990 route.request_channel_hash,
2991 route.request_stream_id,
2992 /* reliable */ true,
2993 std::slice::from_ref(&payload_bytes),
2994 )
2995 .await
2996 {
2997 pending.cancel(call_id);
2998 return Err(RpcError::Transport(e));
2999 }
3000
3001 let request_bytes_len = payload_bytes.len() as u32;
3002 // Cancel keep-alive lives on the returned RpcStream so the
3003 // watcher exits cleanly when the stream drops without cancel.
3004 let cancel_keep_alive = arm_stream_cancel(self, &opts, &pending, call_id);
3005 Ok(RpcStream {
3006 mesh: Arc::clone(self),
3007 target_node_id,
3008 request_channel: route.request_channel.clone(),
3009 // PERF_AUDIT §3.10 — cache the channel hash + stream
3010 // id from `route` so per-chunk grants in `poll_next`
3011 // don't re-run `ChannelId::new` + xxh3.
3012 request_channel_hash: route.request_channel_hash,
3013 request_stream_id: route.request_stream_id,
3014 self_origin,
3015 call_id,
3016 inner: rx,
3017 done: false,
3018 stream_window: opts.stream_window_initial,
3019 grant_pending: 0,
3020 _cancel_keep_alive: cancel_keep_alive,
3021 observer: StreamingObserverState::new(
3022 Arc::clone(self),
3023 target_node_id,
3024 service,
3025 request_bytes_len,
3026 ),
3027 })
3028 }
3029
3030 /// Find every node currently advertising `service` via the
3031 /// `nrpc:<service>` capability tag. Returns node IDs in
3032 /// roster order; the caller picks one (or use [`Self::call_service`]
3033 /// for the round-robin shortcut).
3034 ///
3035 /// Pre-Phase 2: requires the target nodes to have called
3036 /// `serve_rpc` AND `announce_capabilities` so the
3037 /// `nrpc:<service>` tag has propagated through capability
3038 /// announcements. The local node's own services are NOT
3039 /// automatically included (callers don't typically invoke
3040 /// themselves via the network — for in-process invocation,
3041 /// the user has the handler directly).
3042 pub fn find_service_nodes(&self, service: &str) -> Vec<u64> {
3043 use crate::adapter::net::behavior::capability::CapabilityFilter;
3044 use crate::adapter::net::behavior::fold::capability_bridge;
3045 let tag = format!("nrpc:{service}");
3046 let filter = CapabilityFilter::default().require_tag(tag);
3047 capability_bridge::find_nodes_matching(self.capability_fold(), &filter)
3048 }
3049
3050 /// Issue an RPC call to `service`, picking one node from
3051 /// those advertising the `nrpc:<service>` tag in the local
3052 /// capability index according to `opts.routing_policy`.
3053 ///
3054 /// Returns `RpcError::NoRoute` if no nodes advertise the
3055 /// service (or if `opts.filter_unhealthy` is set and every
3056 /// candidate is unavailable per the local `ProximityGraph`).
3057 pub async fn call_service(
3058 self: &Arc<Self>,
3059 service: &str,
3060 payload: Bytes,
3061 opts: CallOptions,
3062 ) -> Result<RpcReply, RpcError> {
3063 let mut candidates = self.find_service_nodes(service);
3064 if candidates.is_empty() {
3065 return Err(RpcError::NoRoute {
3066 target: 0,
3067 reason: format!(
3068 "no nodes advertise `nrpc:{service}` (have any servers \
3069 for this service called serve_rpc + announce_capabilities?)"
3070 ),
3071 });
3072 }
3073
3074 // Health filtering. Skip candidates the proximity graph
3075 // marks unhealthy (`!is_available()`). Candidates with no
3076 // proximity entry at all are KEPT — absence of evidence
3077 // is not evidence of unhealth, and a freshly-announced
3078 // service shouldn't be filtered just because pingwaves
3079 // haven't propagated yet.
3080 //
3081 // The bridge: each candidate's session-layer `node_id: u64`
3082 // is mapped to the entity-layer `[u8; 32]` via
3083 // `MeshNode::entity_id_for_node`. The proximity graph is
3084 // keyed on the entity id.
3085 if opts.filter_unhealthy {
3086 let proximity = self.proximity_graph();
3087 candidates.retain(|node_id| match self.entity_id_for_node(*node_id) {
3088 Some(entity_id) => match proximity.get_node(&entity_id) {
3089 Some(node) => node.is_available(),
3090 None => true, // no proximity data → keep
3091 },
3092 None => true, // no entity-id mapping → keep
3093 });
3094 if candidates.is_empty() {
3095 return Err(RpcError::NoRoute {
3096 target: 0,
3097 reason: format!(
3098 "every node advertising `nrpc:{service}` is marked \
3099 unhealthy by the local proximity graph",
3100 ),
3101 });
3102 }
3103 }
3104
3105 // Sort once so consistent-hash policies (Sticky) produce
3106 // a stable ordering across calls regardless of how the
3107 // capability index returned the candidates, and so the
3108 // LowestLatency-with-no-proximity-data fallback is
3109 // deterministic. Cheap — the candidate set is typically
3110 // small.
3111 candidates.sort_unstable();
3112
3113 // v0.4 capability-auth caller-side gate. Filter the
3114 // candidate set BEFORE target selection so the routing
3115 // policy never picks a peer the caller can't actually
3116 // reach. Pre-fix `select_target` could pick a denied
3117 // candidate even when authorized peers existed in the
3118 // set, and the resulting `CapabilityDenied` masked the
3119 // fact that the call would have succeeded against B or
3120 // C. Each candidate's own announcement lists
3121 // `nrpc:<service>` (otherwise it wouldn't be a
3122 // `find_service_nodes` candidate), so the gate's
3123 // `has_tag` arm short-circuits in the common case; the
3124 // new work is the allow-list scan. Permissive
3125 // announcements (all three lists empty) admit any
3126 // caller — the byte-identity wire-compat tests pin that
3127 // an unmodified peer's announcement stays unrestricted.
3128 // See `docs/plans/CAPABILITY_AUTH_PLAN.md` §3.
3129 let tag = format!("nrpc:{service}");
3130 use crate::adapter::net::behavior::fold::capability_bridge;
3131 let self_id = self.node_id();
3132 let any_candidate = candidates[0];
3133 let fold = self.capability_fold();
3134 // PERF_AUDIT §4.2 — batch the per-candidate gate so the
3135 // fold read lock is taken once and the caller's subnet +
3136 // groups are parsed once, not N times.
3137 let verdicts = capability_bridge::may_execute_batch(fold, &candidates, &tag, self_id);
3138 let mut iter = verdicts.into_iter();
3139 candidates.retain(|_| iter.next().unwrap_or(false));
3140 if candidates.is_empty() {
3141 return Err(RpcError::CapabilityDenied {
3142 // No authorized target; surface one of the
3143 // originally-advertised candidates so the caller
3144 // can correlate the denial with a real peer. The
3145 // semantic is "no peer advertising `nrpc:<service>`
3146 // authorizes this caller" — `any_candidate` is a
3147 // representative, not necessarily the strictest.
3148 target: any_candidate,
3149 capability: service.to_string(),
3150 });
3151 }
3152
3153 let target = self.select_target(&candidates, &opts.routing_policy);
3154 self.call(target, service, payload, opts).await
3155 }
3156
3157 /// Capability-routed server-streaming call. Same routing as
3158 /// [`call_service`] — capability-fold lookup, health filter,
3159 /// routing-policy sort, capability-auth gate, target selection —
3160 /// but the terminal step is [`call_streaming`] instead of
3161 /// [`call`]. Returns the substrate's `RpcStream` so callers can
3162 /// drive an `async for chunk in stream:` loop.
3163 ///
3164 /// Use cases: an agent invoking a long-running tool that emits
3165 /// progress + a terminal result, a fan-out subscriber that wants
3166 /// streaming chunks from whatever node currently advertises the
3167 /// service, any consumer that today reaches for
3168 /// `find_service_nodes` → manual target selection → `call_streaming`
3169 /// and ends up re-implementing the cap-auth gate `call_service`
3170 /// already enforces.
3171 ///
3172 /// Honors `CallOptions::cancel_token` (v3) and
3173 /// `CallOptions::deadline` exactly like `call_streaming`.
3174 ///
3175 /// [`call_service`]: Self::call_service
3176 /// [`call_streaming`]: Self::call_streaming
3177 /// [`call`]: Self::call
3178 pub async fn call_service_streaming(
3179 self: &Arc<Self>,
3180 service: &str,
3181 payload: Bytes,
3182 opts: CallOptions,
3183 ) -> Result<RpcStream, RpcError> {
3184 let mut candidates = self.find_service_nodes(service);
3185 if candidates.is_empty() {
3186 return Err(RpcError::NoRoute {
3187 target: 0,
3188 reason: format!(
3189 "no nodes advertise `nrpc:{service}` (have any servers \
3190 for this service called serve_rpc + announce_capabilities?)"
3191 ),
3192 });
3193 }
3194
3195 // Health filter — mirrors `call_service`. Candidates with no
3196 // proximity entry are kept (absence of evidence ≠ evidence of
3197 // unhealth); only candidates the proximity graph marks
3198 // explicitly unavailable get dropped.
3199 if opts.filter_unhealthy {
3200 let proximity = self.proximity_graph();
3201 candidates.retain(|node_id| match self.entity_id_for_node(*node_id) {
3202 Some(entity_id) => match proximity.get_node(&entity_id) {
3203 Some(node) => node.is_available(),
3204 None => true,
3205 },
3206 None => true,
3207 });
3208 if candidates.is_empty() {
3209 return Err(RpcError::NoRoute {
3210 target: 0,
3211 reason: format!(
3212 "every node advertising `nrpc:{service}` is marked \
3213 unhealthy by the local proximity graph",
3214 ),
3215 });
3216 }
3217 }
3218
3219 // Deterministic ordering so Sticky / LowestLatency-fallback
3220 // pick stably across calls — mirrors `call_service`.
3221 candidates.sort_unstable();
3222
3223 // v0.4 capability-auth caller-side gate. Same as `call_service`:
3224 // filter the candidate set BEFORE target selection so the
3225 // routing policy never picks a peer the caller can't reach.
3226 let tag = format!("nrpc:{service}");
3227 use crate::adapter::net::behavior::fold::capability_bridge;
3228 let self_id = self.node_id();
3229 let any_candidate = candidates[0];
3230 let fold = self.capability_fold();
3231 // PERF_AUDIT §4.2 — batch the per-candidate gate. See the
3232 // mirror site at `:3093`.
3233 let verdicts = capability_bridge::may_execute_batch(fold, &candidates, &tag, self_id);
3234 let mut iter = verdicts.into_iter();
3235 candidates.retain(|_| iter.next().unwrap_or(false));
3236 if candidates.is_empty() {
3237 return Err(RpcError::CapabilityDenied {
3238 target: any_candidate,
3239 capability: service.to_string(),
3240 });
3241 }
3242
3243 let target = self.select_target(&candidates, &opts.routing_policy);
3244 self.call_streaming(target, service, payload, opts).await
3245 }
3246
3247 /// Select a single target from `candidates` according to
3248 /// `policy`. Caller has already ensured `candidates` is
3249 /// non-empty and sorted (so `Sticky` is consistent across
3250 /// calls).
3251 fn select_target(&self, candidates: &[u64], policy: &RoutingPolicy) -> u64 {
3252 match policy {
3253 RoutingPolicy::RoundRobin => {
3254 // `fetch_add(1)` on a dedicated cursor — NOT a
3255 // `load(call_id)` — so two concurrent
3256 // `call_service` invocations always observe
3257 // distinct values and pick distinct targets.
3258 let n = self
3259 .rpc_round_robin_cursor_arc()
3260 .fetch_add(1, Ordering::Relaxed);
3261 candidates[(n as usize) % candidates.len()]
3262 }
3263 RoutingPolicy::Random => {
3264 // Lightweight RNG via a fresh fetch_add (same
3265 // counter, separate per-call value) mixed through
3266 // xxh3. Sufficient for load distribution;
3267 // not cryptographically random.
3268 let n = self
3269 .rpc_round_robin_cursor_arc()
3270 .fetch_add(1, Ordering::Relaxed);
3271 let mixed = xxhash_rust::xxh3::xxh3_64(&n.to_le_bytes());
3272 candidates[(mixed as usize) % candidates.len()]
3273 }
3274 RoutingPolicy::Sticky { key } => {
3275 // Consistent-hash to a position in the (sorted)
3276 // candidate list. Same key + same candidate set =
3277 // same target. A change to the candidate set
3278 // (server failover) reshuffles roughly 1/N of keys.
3279 let h = xxhash_rust::xxh3::xxh3_64(&key.to_le_bytes());
3280 candidates[(h as usize) % candidates.len()]
3281 }
3282 RoutingPolicy::LowestLatency => {
3283 // Walk candidates, look up each via the bridge
3284 // → proximity graph, pick the smallest
3285 // `latency_us`. Candidates without a proximity
3286 // entry (no observed pingwave or no entity-id
3287 // mapping yet) are treated as `u64::MAX` so they
3288 // sort to the bottom — a known-fast node beats an
3289 // unknown one.
3290 //
3291 // Determinism on tie / no-data: `best_node` starts
3292 // at `candidates[0]` (the lexicographically first
3293 // sorted candidate), so all-ties or all-unknown
3294 // collapse to that consistent fallback.
3295 let proximity = self.proximity_graph();
3296 let mut best_node = candidates[0];
3297 let mut best_latency = u64::MAX;
3298 for &node_id in candidates {
3299 let lat = self
3300 .entity_id_for_node(node_id)
3301 .and_then(|eid| proximity.get_node(&eid))
3302 .map(|n| n.latency_us)
3303 .unwrap_or(u64::MAX);
3304 if lat < best_latency {
3305 best_latency = lat;
3306 best_node = node_id;
3307 }
3308 }
3309 best_node
3310 }
3311 }
3312 }
3313
3314 /// Issue an RPC call to `target_node_id` for `service`.
3315 ///
3316 /// Phase 1 — direct entity-to-entity addressing. The caller
3317 /// specifies which target to send to; service discovery (the
3318 /// "find me a healthy instance of X" lookup) is Phase 2.
3319 ///
3320 /// Lazily subscribes the local node's `RpcClientFold` to
3321 /// `<service>.replies.<self_origin>` from `target_node_id` on
3322 /// the first call to that (target, service) pair. The
3323 /// subscription is reused across subsequent calls.
3324 ///
3325 /// On `opts.deadline` expiring OR the future being dropped,
3326 /// emits a CANCEL event so the server can drop the in-flight
3327 /// handler.
3328 pub async fn call(
3329 self: &Arc<Self>,
3330 target_node_id: u64,
3331 service: &str,
3332 payload: Bytes,
3333 mut opts: CallOptions,
3334 ) -> Result<RpcReply, RpcError> {
3335 // `started_total` brackets the entire call for the
3336 // `RpcObserver` latency field; the substrate-internal
3337 // `started` further down (set after the subscription
3338 // setup) drives the existing `RpcReply::latency_ns`
3339 // accounting so observers and Prometheus metrics
3340 // measure slightly different spans but stay consistent
3341 // within their own surface.
3342 let started_total = Instant::now();
3343 let request_bytes_len = payload.len() as u32;
3344 // Per-service route cache: one `DashMap::get(&str)` +
3345 // `Arc::clone` on the hot path instead of 2 `format!` +
3346 // 2 `ChannelName::new` + xxhash per call (T1.3 perf audit
3347 // — `docs/misc/PERF_AUDIT_2026_05_19_NRPC.md`).
3348 let route = self.rpc_route_or_no_route(target_node_id, service)?;
3349 let self_origin = self.identity_origin_hash();
3350
3351 // Caller-side metrics guard. Bumps `in_flight` immediately;
3352 // each early-return path calls `metrics_guard.record(...)`
3353 // with the outcome, and Drop records the latency + bumps
3354 // the matching counter. A future dropped before any
3355 // `record(...)` call (e.g. a hedge loser) leaves the guard
3356 // with `outcome = None` so `in_flight` decrements but no
3357 // outcome is double-counted.
3358 let metrics_registry = self.rpc_metrics_arc();
3359 let mut metrics_guard = CallMetricsGuard::new(metrics_registry.for_service(service));
3360
3361 // Lazy reply-channel subscription. Once per (target, service).
3362 // Reply channel + hash come from the cached `RpcRoute`; we
3363 // only `.clone()` the `ChannelName` (cheap — internally an
3364 // Arc<str>) instead of building it from scratch.
3365 if let Err(e) = self
3366 .ensure_reply_subscription(
3367 target_node_id,
3368 service,
3369 route.reply_channel.clone(),
3370 route.reply_hash,
3371 )
3372 .await
3373 {
3374 metrics_guard.record(CallOutcome::NoRoute);
3375 self.fire_rpc_observer_outbound(
3376 target_node_id,
3377 service,
3378 started_total.elapsed().as_millis() as u32,
3379 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Error(e.to_string()),
3380 request_bytes_len,
3381 0,
3382 );
3383 return Err(e);
3384 }
3385
3386 // Allocate a fresh call_id. Random u64 from getrandom; a
3387 // sequential counter would let any session peer that
3388 // observed one of their own call_ids predict the next-
3389 // allocated ids and ship spoofed RESPONSE frames on the
3390 // victim's reply channel. Random u64 collides with
3391 // probability 2^-64 per call and is unguessable from
3392 // another peer's perspective.
3393 let call_id = mint_random_call_id();
3394
3395 // Register the oneshot before publishing the REQUEST so a
3396 // very-fast RESPONSE doesn't arrive before we're ready.
3397 // S-4 part 2: bind the pending entry to target_node_id so
3398 // the fold's deliver gate rejects spoofed RESPONSE frames
3399 // arriving from any other session peer.
3400 let pending = self.rpc_client_pending();
3401 let rx = pending.register(call_id, target_node_id);
3402
3403 // Build the REQUEST envelope. If a trace context is set,
3404 // emit `traceparent` / `tracestate` headers and signal
3405 // via `FLAG_RPC_PROPAGATE_TRACE` so the server's fold
3406 // populates `RpcContext::trace_context`.
3407 let (flags, mut headers) = match opts.trace_context.as_ref() {
3408 Some(tc) => (FLAG_RPC_PROPAGATE_TRACE, build_trace_headers(tc)),
3409 None => (0u16, Vec::new()),
3410 };
3411 // Append caller-supplied request headers (e.g. the
3412 // `net-where` predicate header for Phase 9b
3413 // predicate-pushdown). Auto-generated headers come first
3414 // so name collisions resolve to caller-overrides via the
3415 // server-side `predicate_from_rpc_headers` first-match
3416 // semantics.
3417 // PERF_AUDIT §3.11 — `opts` is owned by this function and
3418 // its `request_headers` are unused after this point;
3419 // `Vec::append(&mut other)` drains `other` into `headers`
3420 // with zero allocation, vs the pre-fix
3421 // `.iter().cloned()` which deep-cloned each
3422 // `(String, Vec<u8>)` pair into a fresh entry.
3423 headers.append(&mut opts.request_headers);
3424 let req = RpcRequestPayload {
3425 service: service.to_string(),
3426 deadline_ns: opts.deadline.map(instant_to_unix_nanos).unwrap_or(0),
3427 flags,
3428 headers,
3429 body: payload.clone(),
3430 };
3431 let meta = EventMeta::new(DISPATCH_RPC_REQUEST, 0, self_origin, call_id, 0);
3432 let mut buf = Vec::with_capacity(EVENT_META_SIZE + req.body.len() + 32);
3433 buf.extend_from_slice(&meta.to_bytes());
3434 req.encode_into(&mut buf);
3435
3436 // Send the REQUEST directly to `target_node_id` via
3437 // `publish_to_peer`, bypassing the local subscriber roster
3438 // lookup. The roster-based `Mesh::publish` would consult
3439 // `dispatch_recipients(channel)` against the caller's local
3440 // roster, which has no knowledge of who serves this service
3441 // (no Subscribe message ever propagated from the server back
3442 // to the caller — `serve_rpc` is local-only). For Phase 1
3443 // direct addressing we know the target, so direct-send is
3444 // the right primitive.
3445 //
3446 // The receiver routes via the per-channel-hash dispatcher
3447 // hook (channel_hash is stamped on the wire by
3448 // publish_to_peer).
3449 let started = Instant::now();
3450 // Request channel hash + stream_id come from the cached
3451 // route — no `ChannelId::new` clone + xxhash per call.
3452 let payload_bytes = Bytes::from(buf);
3453 if let Err(e) = self
3454 .publish_to_peer(
3455 target_node_id,
3456 route.request_channel_hash,
3457 route.request_stream_id,
3458 /* reliable */ true,
3459 std::slice::from_ref(&payload_bytes),
3460 )
3461 .await
3462 {
3463 pending.cancel(call_id);
3464 // Distinguish "I don't know how to reach this peer"
3465 // from a generic transport blip: when the publish path
3466 // surfaces a no-session error, that's NoRoute (the
3467 // routing layer's job, retry won't help). Other
3468 // transport errors stay as Transport so retry is
3469 // applicable.
3470 let err = if classify_publish_no_session(&e) {
3471 metrics_guard.record(CallOutcome::NoRoute);
3472 RpcError::NoRoute {
3473 target: target_node_id,
3474 reason: e.to_string(),
3475 }
3476 } else {
3477 metrics_guard.record(CallOutcome::Transport);
3478 RpcError::Transport(e)
3479 };
3480 self.fire_rpc_observer_outbound(
3481 target_node_id,
3482 service,
3483 started_total.elapsed().as_millis() as u32,
3484 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Error(err.to_string()),
3485 request_bytes_len,
3486 0,
3487 );
3488 return Err(err);
3489 }
3490
3491 // From here on, the REQUEST is in flight on the server.
3492 // Wrap the rest of the call in an RAII guard whose Drop
3493 // fires CANCEL if `guard.completed` isn't set — covering:
3494 // - the call future being dropped mid-flight (e.g. hedge
3495 // loser, select!-cancelled future, caller awaiting a
3496 // `JoinHandle` that gets cancelled).
3497 // - the timeout path (we leave `completed=false` so Drop
3498 // handles CANCEL emission; no need for a separate
3499 // `send_rpc_cancel` call).
3500 // - the cancel_token path (same: leave completed=false,
3501 // Drop emits CANCEL).
3502 let mut guard = UnaryCallGuard {
3503 pending: Arc::clone(&pending),
3504 mesh: Arc::clone(self),
3505 target_node_id,
3506 request_channel: route.request_channel.clone(),
3507 self_origin,
3508 call_id,
3509 completed: false,
3510 };
3511
3512 // Substrate cancel-token plumbing (v3 / C-S1). When the
3513 // caller set `opts.cancel_token`, register a Notify against
3514 // the per-mesh cancel_registry. The select! arm below
3515 // observes the cancel signal and short-circuits to
3516 // RpcError::Cancelled, leaving guard.completed = false so
3517 // Drop fires CANCEL on the wire. Release the registry
3518 // entry once the call resolves so the registry doesn't
3519 // grow unboundedly.
3520 let cancel_token = opts.cancel_token.unwrap_or(0);
3521 let cancel_notify = self.cancel_registry().register_notify(cancel_token);
3522
3523 // Race the receiver against the deadline AND the cancel
3524 // signal. Each branch lifts to the same outcome shape
3525 // (Result<Result<RpcResponsePayload, _>, Elapsed>) so the
3526 // existing post-match logic stays unchanged for the ok /
3527 // timeout paths; the cancel arm returns early via
3528 // fire_unary_cancel_outcome — leaves guard.completed=false
3529 // so Drop emits CANCEL on the wire.
3530 let outcome: Result<Result<RpcResponsePayload, _>, tokio::time::error::Elapsed> =
3531 match opts.deadline {
3532 None => {
3533 tokio::select! {
3534 biased;
3535 _ = cancel_notify.notified() => {
3536 return Err(fire_unary_cancel_outcome(
3537 self,
3538 &mut metrics_guard,
3539 cancel_token,
3540 target_node_id,
3541 service,
3542 started_total,
3543 request_bytes_len,
3544 ));
3545 }
3546 r = rx => Ok(r),
3547 }
3548 }
3549 Some(deadline) => {
3550 let timeout_at = deadline.saturating_duration_since(Instant::now());
3551 tokio::select! {
3552 biased;
3553 _ = cancel_notify.notified() => {
3554 return Err(fire_unary_cancel_outcome(
3555 self,
3556 &mut metrics_guard,
3557 cancel_token,
3558 target_node_id,
3559 service,
3560 started_total,
3561 request_bytes_len,
3562 ));
3563 }
3564 r = tokio::time::timeout(timeout_at, rx) => r,
3565 }
3566 }
3567 };
3568
3569 // Whichever non-cancel path won, release the registry
3570 // entry. Idempotent if the cancel arm already released.
3571 self.cancel_registry().release(cancel_token);
3572
3573 let resp = match outcome {
3574 Ok(Ok(resp)) => {
3575 guard.completed = true;
3576 resp
3577 }
3578 Ok(Err(_recv_err)) => {
3579 // Sender dropped externally — pending entry is
3580 // already gone (someone else removed it). Mark
3581 // completed so Drop doesn't fire a useless CANCEL
3582 // for a server that's no longer tracking this id.
3583 guard.completed = true;
3584 metrics_guard.record(CallOutcome::Transport);
3585 let err = RpcError::Transport(AdapterError::Connection(
3586 "rpc client pending sender dropped (no response will arrive)".into(),
3587 ));
3588 self.fire_rpc_observer_outbound(
3589 target_node_id,
3590 service,
3591 started_total.elapsed().as_millis() as u32,
3592 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Error(
3593 err.to_string(),
3594 ),
3595 request_bytes_len,
3596 0,
3597 );
3598 return Err(err);
3599 }
3600 Err(_elapsed) => {
3601 // Timeout: leave `completed=false` so Drop emits
3602 // CANCEL automatically; surface Timeout to caller.
3603 metrics_guard.record(CallOutcome::Timeout);
3604 self.fire_rpc_observer_outbound(
3605 target_node_id,
3606 service,
3607 started_total.elapsed().as_millis() as u32,
3608 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Timeout,
3609 request_bytes_len,
3610 0,
3611 );
3612 return Err(RpcError::Timeout {
3613 elapsed_ms: started.elapsed().as_millis() as u64,
3614 });
3615 }
3616 };
3617
3618 // Map the wire status onto the public Result type.
3619 if resp.status.is_ok() {
3620 metrics_guard.record(CallOutcome::Ok);
3621 let response_bytes_len = resp.body.len() as u32;
3622 self.fire_rpc_observer_outbound(
3623 target_node_id,
3624 service,
3625 started_total.elapsed().as_millis() as u32,
3626 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Ok,
3627 request_bytes_len,
3628 response_bytes_len,
3629 );
3630 Ok(RpcReply {
3631 body: resp.body,
3632 headers: resp.headers,
3633 latency_ns: started.elapsed().as_nanos() as u64,
3634 })
3635 } else {
3636 metrics_guard.record(CallOutcome::ServerError);
3637 let status = resp.status.to_wire();
3638 let response_bytes_len = resp.body.len() as u32;
3639 let message = String::from_utf8(resp.body.to_vec())
3640 .unwrap_or_else(|e| format!("<{} bytes of non-utf8 body>", e.into_bytes().len()));
3641 self.fire_rpc_observer_outbound(
3642 target_node_id,
3643 service,
3644 started_total.elapsed().as_millis() as u32,
3645 crate::adapter::net::cortex::rpc_observer::RpcCallStatus::Error(message.clone()),
3646 request_bytes_len,
3647 response_bytes_len,
3648 );
3649 // v0.4 capability-auth: callee-side defense-in-depth
3650 // surfaces as a wire `CapabilityDenied` status. Map it
3651 // back to the typed `RpcError::CapabilityDenied` so
3652 // application code sees the same variant regardless of
3653 // which side of the gate fired.
3654 if matches!(resp.status, RpcStatus::CapabilityDenied) {
3655 return Err(RpcError::CapabilityDenied {
3656 target: target_node_id,
3657 capability: service.to_string(),
3658 });
3659 }
3660 Err(RpcError::ServerError {
3661 status,
3662 message,
3663 headers: resp.headers,
3664 })
3665 }
3666 }
3667
3668 // ----------------------------------------------------------------
3669 // Internal helpers.
3670 // ----------------------------------------------------------------
3671
3672 /// Lazy-subscribe `reply_channel` from `target_node_id` and
3673 /// register an inbound dispatcher that drives the per-Mesh
3674 /// `RpcClientFold`. Idempotent — subsequent calls for the
3675 /// same (target, service) pair are no-ops.
3676 ///
3677 /// **Bounded** at [`MAX_REPLY_SUBSCRIPTIONS`]: a caller talking
3678 /// to many short-lived (target, service) pairs would otherwise
3679 /// grow the registry indefinitely. Past the cap we refuse the
3680 /// new subscription with `NoRoute` rather than evict an
3681 /// existing one (eviction could rip out a healthy in-flight
3682 /// reply path).
3683 ///
3684 /// **Dispatcher reuse**: the reply-channel name embeds the
3685 /// CALLER's `self_origin`, NOT the target's, so a single
3686 /// caller talking to multiple servers for the same service
3687 /// reuses the same reply channel (same hash). We register the
3688 /// dispatcher only if the slot is unoccupied; subsequent
3689 /// (target, service) pairs that hash to the same slot are
3690 /// allowed to share the existing dispatcher (which routes to
3691 /// the same per-Mesh `pending` map regardless of target). A
3692 /// genuine cross-service hash collision is detected at
3693 /// `serve_rpc` time (the AlreadyServing path) for the server
3694 /// side; on the caller side here, sharing the dispatcher is
3695 /// the correct behavior because all RESPONSE events route
3696 /// through the same `RpcClientPending` keyed by `call_id`.
3697 async fn ensure_reply_subscription(
3698 self: &Arc<Self>,
3699 target_node_id: u64,
3700 service: &str,
3701 reply_channel: ChannelName,
3702 reply_hash: ChannelHash,
3703 ) -> Result<(), RpcError> {
3704 let registry = self.rpc_reply_subscriptions_arc();
3705 // PERF_AUDIT §3.5 — DashMap keyed by
3706 // `(target, xxh3_64(service))`. Pre-fix this was a global
3707 // `Mutex<Vec<(u64, String)>>` that every concurrent RPC
3708 // caller took on every call to scan the Vec with a String
3709 // compare per entry — all callers serialized on it. Now the
3710 // hot path is one shard-local read with a single String
3711 // compare against the slot's stored service name (xxh3 is
3712 // not collision-free; see `reply_subscription_covers`).
3713 let service_hash = xxhash_rust::xxh3::xxh3_64(service.as_bytes());
3714 if reply_subscription_covers(®istry, target_node_id, service_hash, service) {
3715 return Ok(());
3716 }
3717 // Cap the registry. `len()` on DashMap is approximate under
3718 // concurrent churn (it sums shard counts under shard reads,
3719 // not a global lock), which is exactly the semantics we
3720 // want here — the cap is a soft guard against a runaway
3721 // caller, not a precise invariant. Past the cap, new
3722 // entries are refused.
3723 if registry.len() >= MAX_REPLY_SUBSCRIPTIONS {
3724 return Err(RpcError::NoRoute {
3725 target: target_node_id,
3726 reason: format!(
3727 "reply-subscription registry at cap ({} entries); refusing new \
3728 (target={target_node_id:#x}, service={service:?}). Caller should \
3729 reuse an existing target+service pair or shrink the active set.",
3730 MAX_REPLY_SUBSCRIPTIONS,
3731 ),
3732 });
3733 }
3734
3735 // Subscribe to our own reply channel from the target so the
3736 // target's roster has us as a subscriber when the server's
3737 // emit closure publishes the RESPONSE.
3738 self.subscribe_channel(target_node_id, reply_channel.clone())
3739 .await
3740 .map_err(|e| RpcError::NoRoute {
3741 target: target_node_id,
3742 reason: e.to_string(),
3743 })?;
3744
3745 // Register the inbound dispatcher only if the slot is
3746 // unoccupied. The reply-channel name embeds *self_origin*,
3747 // not the target, so multiple targets serving the same
3748 // service share one reply channel + one dispatcher. The
3749 // existing dispatcher routes to the same per-Mesh
3750 // `RpcClientPending` keyed by call_id, so reuse is safe.
3751 if !self.rpc_inbound_dispatcher_registered(reply_hash) {
3752 let pending = self.rpc_client_pending();
3753 let fold = Arc::new(Mutex::new(RpcClientFold::new(pending)));
3754 // S-4 part 2: use `apply_inbound` so the wire-session
3755 // peer's NodeId (resolved in mesh.rs's dispatch site)
3756 // flows into the fold's deliver gate. The legacy
3757 // `RedexFold::apply` shim delivers with from_node=0,
3758 // which would defeat the binding.
3759 let dispatcher: RpcInboundDispatcher = Arc::new(move |ev| {
3760 fold.lock().apply_inbound(&ev);
3761 });
3762 // Race-safe: a concurrent caller might have just
3763 // registered between our check and our insert. In that
3764 // case `register_rpc_inbound` returns the prior
3765 // dispatcher; our new fresh fold is dropped here, and
3766 // the prior dispatcher (which routes to the same
3767 // shared `pending`) keeps doing the job. No collision
3768 // — both folds are functionally equivalent.
3769 if let Some(prev) = self.register_rpc_inbound(reply_hash, dispatcher) {
3770 // Roll back: keep the prior dispatcher (it's
3771 // already wired to the same shared pending map).
3772 let _ = self.register_rpc_inbound(reply_hash, prev);
3773 }
3774 }
3775
3776 let _ = reply_hash; // captured into the dispatcher above; surfaced for debug
3777 // `insert` is idempotent — a concurrent caller that beat us
3778 // to it just overwrote the slot with the identical value.
3779 // On a genuine xxh3 collision between two service names on
3780 // the same target, the slot flips to whichever service
3781 // subscribed last and the other re-subscribes on its next
3782 // call (idempotent, correct, merely un-cached). Cap drift
3783 // past MAX_REPLY_SUBSCRIPTIONS during a concurrent insert
3784 // race is bounded by the number of concurrent callers,
3785 // which operators tune separately.
3786 registry.insert((target_node_id, service_hash), Arc::from(service));
3787 Ok(())
3788 }
3789}
3790
3791/// PERF_AUDIT §3.5 — hot-path membership check for the
3792/// reply-subscription registry. Returns `true` only when the slot
3793/// for `(target, xxh3(service))` exists AND the stored service
3794/// name matches exactly. xxh3_64 is neither collision-free nor
3795/// cryptographic; a hash-only hit that skipped the subscribe for
3796/// a *different* service would silently drop that service's
3797/// replies — the reply channel name embeds the service, so being
3798/// in the target's roster for the colliding service's channel
3799/// does nothing for this one. Verifying the stored name turns a
3800/// collision into a per-call re-subscribe (idempotent, harmless)
3801/// instead of a correctness bug.
3802fn reply_subscription_covers(
3803 registry: &dashmap::DashMap<(u64, u64), Arc<str>>,
3804 target_node_id: u64,
3805 service_hash: u64,
3806 service: &str,
3807) -> bool {
3808 registry
3809 .get(&(target_node_id, service_hash))
3810 .is_some_and(|entry| entry.value().as_ref() == service)
3811}
3812
3813/// Hard cap on the number of distinct (target_node_id, service)
3814/// pairs the caller-side reply-subscription registry will hold.
3815/// Past the cap, the lazy-subscribe path inside [`MeshNode::call`]
3816/// refuses new entries with [`RpcError::NoRoute`]. 1024 is
3817/// generous for any realistic deployment — a caller that needs
3818/// more should reuse existing reply paths.
3819pub const MAX_REPLY_SUBSCRIPTIONS: usize = 1024;
3820
3821/// Mint a random 64-bit call_id. Used as the correlation token
3822/// for REQUEST/RESPONSE pairing. The fold keys pending oneshots on
3823/// this value; any session peer with publish access to the reply
3824/// channel could ship a forged RESPONSE if it could guess the
3825/// value. Sequential u64s are predictable from any peer that
3826/// observes a single allocation; random u64s collide with 2^-64
3827/// probability per call and are unpredictable to observing peers.
3828///
3829/// **PERF_AUDIT §3.8** — pre-fix this called `getrandom::fill` for
3830/// 8 bytes per RPC — one OS entropy syscall per call
3831/// (BCryptGenRandom on Windows, ~200-400 ns; somewhat cheaper on
3832/// Linux). Now each thread refills a small pool of raw OS entropy
3833/// ([`CALL_ID_ENTROPY_POOL_BYTES`]) with a single `getrandom`
3834/// syscall and hands out 8 bytes per call, amortizing the syscall
3835/// across [`CALL_ID_ENTROPY_POOL_BYTES`]/8 mints.
3836///
3837/// Every minted id is still raw OS entropy — NOT the output of a
3838/// userspace PRNG — so the unpredictability-to-peers property is
3839/// byte-for-byte identical to the pre-§3.8 per-call fill. (An
3840/// earlier draft of this fix streamed ids from a thread-local
3841/// SplitMix64; that was unsound for this threat model: call_ids
3842/// are sent to callees by design, and SplitMix64's output
3843/// finalizer is a public bijection, so a single observed id
3844/// reveals the generator state and with it every FUTURE call_id
3845/// minted on that thread — letting one callee forge responses to
3846/// races on calls addressed to other peers. Raw pooled entropy
3847/// has no such state to recover.)
3848///
3849/// If the pool refill fails, falls back to a process-global
3850/// monotonic counter rather than returning `0`: two concurrent
3851/// callers that both minted `0` would `register(0, …)` over each
3852/// other, so the first caller's oneshot closes with
3853/// `RecvError::Closed` (a spurious `Transport` error, not the clean
3854/// timeout the all-distinct path yields). The counter keeps ids
3855/// distinct (predictable on entropy failure, but the S-4
3856/// `from_node` gate still blocks cross-peer forgery, and such calls
3857/// time out anyway). `getrandom::fill` failure is a fatal-
3858/// environment signal (no `/dev/urandom`, broken syscall) and the
3859/// broader stack won't be functional anyway; the pool cursor is
3860/// left exhausted so the next mint retries the refill. `0` is
3861/// reserved as a sentinel and never returned.
3862fn mint_random_call_id() -> u64 {
3863 thread_local! {
3864 // (pool, cursor). Cursor starts exhausted so the first
3865 // mint on each thread performs the initial refill.
3866 static CALL_ID_ENTROPY_POOL: std::cell::RefCell<([u8; CALL_ID_ENTROPY_POOL_BYTES], usize)> = const {
3867 std::cell::RefCell::new(([0u8; CALL_ID_ENTROPY_POOL_BYTES], CALL_ID_ENTROPY_POOL_BYTES))
3868 };
3869 }
3870 CALL_ID_ENTROPY_POOL.with(|cell| {
3871 let mut pool = cell.borrow_mut();
3872 let (buf, cursor) = &mut *pool;
3873 if *cursor >= CALL_ID_ENTROPY_POOL_BYTES {
3874 if getrandom::fill(buf).is_err() {
3875 // Entropy unavailable. Do NOT return 0 — concurrent
3876 // callers would all mint 0 and clobber each other's
3877 // pending entries. A process-global counter keeps ids
3878 // distinct (starts at 1, so it is non-zero until it
3879 // wraps the full u64 range, at which point the 0 is
3880 // mapped to 1 below).
3881 static CALL_ID_FALLBACK: std::sync::atomic::AtomicU64 =
3882 std::sync::atomic::AtomicU64::new(1);
3883 let id = CALL_ID_FALLBACK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3884 return if id == 0 { 1 } else { id };
3885 }
3886 *cursor = 0;
3887 }
3888 let mut id = [0u8; 8];
3889 id.copy_from_slice(&buf[*cursor..*cursor + 8]);
3890 *cursor += 8;
3891 // Reserve 0 as the "no correlation" sentinel: on the ~1-in-2^64
3892 // chance the pool yields all-zero bytes, remap to a fixed non-zero.
3893 match u64::from_le_bytes(id) {
3894 0 => 1,
3895 id => id,
3896 }
3897 })
3898}
3899
3900/// Per-thread OS-entropy pool size for [`mint_random_call_id`].
3901/// 64 ids (512 bytes) per `getrandom` syscall — the syscall cost
3902/// is dominated by the fixed kernel round-trip, so batching 64
3903/// mints recovers ~98% of the per-call overhead while keeping the
3904/// amount of buffered future-id entropy per thread small.
3905const CALL_ID_ENTROPY_POOL_BYTES: usize = 64 * 8;
3906
3907// ============================================================================
3908// Internal: tiny shims so the `serve_rpc` / `call` impls stay
3909// readable. The underlying state lives on `MeshNode`; these just
3910// rename the accessor methods locally.
3911// ============================================================================
3912
3913impl MeshNode {
3914 fn rpc_client_pending(&self) -> Arc<super::cortex::RpcClientPending> {
3915 self.rpc_client_pending_arc()
3916 }
3917 fn identity_origin_hash(&self) -> u64 {
3918 self.public_key_origin_hash()
3919 }
3920
3921 /// Caller-side helper that pairs `rpc_route_for_service` with
3922 /// the `RpcError::NoRoute { target, reason }` mapping every
3923 /// `Mesh::call*` entry point needs. Returning `Arc<RpcRoute>`
3924 /// keeps the hot-path allocation profile of the cache intact
3925 /// (one refcount bump per caller).
3926 fn rpc_route_or_no_route(
3927 &self,
3928 target_node_id: u64,
3929 service: &str,
3930 ) -> Result<Arc<super::mesh::RpcRoute>, RpcError> {
3931 self.rpc_route_for_service(service)
3932 .map_err(|reason| RpcError::NoRoute {
3933 target: target_node_id,
3934 reason,
3935 })
3936 }
3937}
3938
3939// `proximity_graph()` is already a public accessor on MeshNode
3940// (see the existing `pub fn proximity_graph(&self) -> &Arc<...>`).
3941// `select_target` uses it directly; no shim needed.
3942
3943// ============================================================================
3944// Errors.
3945// ============================================================================
3946
3947/// Errors returned by [`MeshNode::serve_rpc`].
3948#[derive(Debug, thiserror::Error)]
3949pub enum ServeError {
3950 /// The service name fails channel-name validation.
3951 #[error("invalid service name: {0}")]
3952 InvalidServiceName(String),
3953 /// A handler for this service is already registered on this
3954 /// node. Drop the prior `ServeHandle` to free the slot.
3955 #[error("already serving service `{0}` on this node")]
3956 AlreadyServing(String),
3957 /// The descriptor announces `pricing_terms`, but this serve path
3958 /// has no payment-admission gate — an announced price this path
3959 /// cannot enforce must never reach discovery (callers would see a
3960 /// priced tool that serves free). Serve paid tools via the SDK's
3961 /// `Mesh::serve_tool_paid` (native gate), or publish through the
3962 /// MCP adapter's `ServerPublisher::publish_tools` with a
3963 /// `payment_admission` gate.
3964 #[error(
3965 "tool `{0}` announces pricing_terms but this serve path cannot enforce payment — \
3966 serve paid tools via Mesh::serve_tool_paid, or publish via \
3967 ServerPublisher::publish_tools with payment_admission"
3968 )]
3969 UnenforceablePricing(String),
3970 /// The gated serve path (`Mesh::serve_tool_paid`) got a descriptor
3971 /// with **no** `pricing_terms`: a payment gate on an unannounced
3972 /// price means every caller is refused with no way to know why.
3973 /// Announce the price (the gate enforces it), or serve the tool
3974 /// free via `Mesh::serve_tool`.
3975 #[error(
3976 "tool `{0}` is served through the payment gate but announces no pricing_terms — \
3977 attach terms to the descriptor, or serve it free via Mesh::serve_tool"
3978 )]
3979 MissingPricingTerms(String),
3980}
3981
3982// ============================================================================
3983// Typed-call helper.
3984// ============================================================================
3985
3986/// Wire-shape failures from [`typed_call`]. Distinct variants
3987/// for transport (no route, timeout, etc.) vs codec (serde /
3988/// postcard) so service-specific client error enums can wrap
3989/// each independently. Server-level (application) errors are
3990/// decoded into `Resp` itself — the client matches on the
3991/// resulting `Resp::Error(...)` variant.
3992#[derive(Debug, thiserror::Error)]
3993pub enum TypedCallError {
3994 /// Transport-level failure surfaced by [`MeshNode::call`].
3995 #[error("transport: {0}")]
3996 Transport(#[from] RpcError),
3997 /// Request serialization or response deserialization failed.
3998 #[error("codec: {0}")]
3999 Codec(String),
4000}
4001
4002impl From<postcard::Error> for TypedCallError {
4003 fn from(e: postcard::Error) -> Self {
4004 Self::Codec(e.to_string())
4005 }
4006}
4007
4008/// Send a postcard-encoded request to a remote RPC service and
4009/// decode the postcard-encoded reply. The shared shape every
4010/// substrate-internal RPC client wants:
4011///
4012/// 1. `postcard::to_allocvec(request)` → wire body.
4013/// 2. `MeshNode::call(target, service, body, opts{deadline})`.
4014/// 3. `postcard::from_bytes::<Resp>(reply.body)`.
4015///
4016/// Caller wraps the returned `Resp` in its own typed-error
4017/// surface (typically a `Server` variant that holds the
4018/// service-specific error enum decoded from `Resp`). Returning
4019/// `TypedCallError` here keeps the wrapper code to a one-line
4020/// `From<TypedCallError>` impl per client.
4021pub async fn typed_call<Req, Resp>(
4022 mesh: &std::sync::Arc<crate::adapter::net::MeshNode>,
4023 target_node_id: u64,
4024 service: &str,
4025 request: &Req,
4026 deadline: std::time::Duration,
4027) -> Result<Resp, TypedCallError>
4028where
4029 Req: serde::Serialize,
4030 Resp: serde::de::DeserializeOwned,
4031{
4032 let body = postcard::to_allocvec(request)?;
4033 let opts = CallOptions {
4034 deadline: Some(std::time::Instant::now() + deadline),
4035 ..Default::default()
4036 };
4037 let reply = mesh
4038 .call(target_node_id, service, Bytes::from(body), opts)
4039 .await?;
4040 Ok(postcard::from_bytes(&reply.body)?)
4041}
4042
4043// ============================================================================
4044// Helpers.
4045// ============================================================================
4046
4047/// Detect the "no session to the target node id" sub-case of
4048/// [`AdapterError::Connection`]. The publish path can surface
4049/// this through one of two messages depending on which inner
4050/// helper landed it:
4051///
4052/// - `"publish: no session for subscriber {hash}"` — emitted
4053/// by `mesh.rs::publish_to_peer` when the subscriber-roster
4054/// path can't find an active session.
4055/// - `"no session to publisher {hash}"` — emitted by the lower
4056/// mesh.rs send path when there's no active session to the
4057/// target's publisher record at all.
4058///
4059/// Both mean "I can't reach this peer". When we observe either,
4060/// we surface as [`RpcError::NoRoute`] rather than `Transport`
4061/// because retrying the same target without a session is
4062/// pointless and the right behavior for a routing helper is to
4063/// try a different target.
4064fn classify_publish_no_session(err: &AdapterError) -> bool {
4065 match err {
4066 AdapterError::Connection(msg) => {
4067 msg.contains("no session for subscriber") || msg.contains("no session to publisher")
4068 }
4069 _ => false,
4070 }
4071}
4072
4073fn instant_to_unix_nanos(instant: Instant) -> u64 {
4074 // `Instant` is monotonic and not wall-clock — convert via the
4075 // delta from now plus current SystemTime. The result drifts
4076 // marginally with wall-clock skew but is good enough for
4077 // server-side deadline-already-passed short-circuits (which are
4078 // the only consumer of `deadline_ns`).
4079 let now_instant = Instant::now();
4080 let now_wall = std::time::SystemTime::now()
4081 .duration_since(std::time::UNIX_EPOCH)
4082 .map(|d| d.as_nanos() as u64)
4083 .unwrap_or(0);
4084 if instant >= now_instant {
4085 let delta = instant.duration_since(now_instant);
4086 now_wall.saturating_add(delta.as_nanos() as u64)
4087 } else {
4088 let delta = now_instant.duration_since(instant);
4089 now_wall.saturating_sub(delta.as_nanos() as u64)
4090 }
4091}
4092
4093#[allow(dead_code)]
4094fn _ensure_send_sync() {
4095 fn assert_send_sync<T: Send + Sync>() {}
4096 assert_send_sync::<ServeHandle>();
4097 assert_send_sync::<RpcCancellationToken>();
4098 assert_send_sync::<RpcContext>();
4099 assert_send_sync::<RpcHandlerError>();
4100 assert_send_sync::<RpcStatus>();
4101 assert_send_sync::<RpcReply>();
4102 assert_send_sync::<CallOptions>();
4103}
4104
4105#[cfg(test)]
4106mod origin_cache_tests {
4107 use super::*;
4108
4109 /// The crafted-origin memory-amplification guard (cubic P2): the reply-
4110 /// channel / origin-node caches are keyed by the *wire-claimed*
4111 /// `caller_origin`, which a single authed peer can vary freely. Spraying
4112 /// far more distinct origins than the capacity must NOT grow the cache —
4113 /// it stays pinned at `RPC_CALLER_CACHE_CAP`, evicting the coldest.
4114 #[test]
4115 fn origin_keyed_lru_bounds_under_crafted_origin_flood() {
4116 let cache: OriginKeyedLru<u64> = OriginKeyedLru::new();
4117 let flood = (RPC_CALLER_CACHE_CAP as u64) * 4;
4118 for origin in 0..flood {
4119 cache.insert(origin, origin);
4120 }
4121 assert_eq!(
4122 cache.0.lock().len(),
4123 RPC_CALLER_CACHE_CAP,
4124 "cache must stay at its capacity bound under a crafted-origin flood"
4125 );
4126 // The most-recently-seen window survives; the cold prefix is evicted.
4127 assert_eq!(cache.get(flood - 1), Some(flood - 1));
4128 assert_eq!(cache.get(0), None);
4129 }
4130
4131 /// PERF_AUDIT §3.8 — `mint_random_call_id` mints thousands of
4132 /// values from the pooled-entropy path, all of which must be
4133 /// distinct in practice (a duplicate would let two in-flight
4134 /// calls collide on the per-Mesh pending map). 100k samples is
4135 /// far below the 2^32 birthday-paradox boundary, so a
4136 /// regression that recycled pool bytes (cursor mis-advance,
4137 /// missed refill) would fail loudly. The loop also crosses the
4138 /// pool-refill boundary thousands of times (pool holds 64 ids),
4139 /// pinning the refill/cursor arithmetic.
4140 #[test]
4141 fn mint_random_call_id_produces_distinct_values_across_thousands_of_calls() {
4142 let mut seen = std::collections::HashSet::with_capacity(100_000);
4143 for _ in 0..100_000 {
4144 let id = super::mint_random_call_id();
4145 // 0 is the fallback sentinel — should not appear under
4146 // a working `getrandom` refill.
4147 assert_ne!(id, 0, "fallback-zero path triggered unexpectedly");
4148 assert!(seen.insert(id), "duplicate call_id minted: {:#x}", id);
4149 }
4150 }
4151
4152 /// PERF_AUDIT §3.8 — minted ids are raw OS entropy: count
4153 /// set-bits across 10k mints and assert the fraction is near
4154 /// 0.5. A pool-management bug that handed out the zeroed
4155 /// initial buffer (or re-served a stale window) would skew
4156 /// this hard; properly random 64-bit ids have expected ~0.5
4157 /// set bits per sample with O(1/sqrt(N)) tolerance.
4158 #[test]
4159 fn mint_random_call_id_set_bit_density_is_balanced() {
4160 let n = 10_000u64;
4161 let mut total_set: u64 = 0;
4162 for _ in 0..n {
4163 total_set += super::mint_random_call_id().count_ones() as u64;
4164 }
4165 let bits_total = n * 64;
4166 let fraction = total_set as f64 / bits_total as f64;
4167 // Expected 0.5; tolerance generous (~3σ) to keep the test
4168 // reliable while still catching collapsed-pool regressions.
4169 assert!(
4170 (fraction - 0.5).abs() < 0.02,
4171 "set-bit density {} is too far from 0.5 — pool may be mismanaged",
4172 fraction
4173 );
4174 }
4175
4176 /// PERF_AUDIT §3.5 — the reply-subscription registry's hot
4177 /// path must be a `(target, xxh3(service))` lookup, not a
4178 /// `Mutex<Vec<(u64, String)>>` linear scan. Pin the contract
4179 /// via `reply_subscription_covers` (the exact hot-path check):
4180 /// 1. distinct (target, service) pairs are distinct keys —
4181 /// same service against two targets, and two services
4182 /// against one target, never alias;
4183 /// 2. repeat insert of the same pair is idempotent and the
4184 /// fast path keeps answering `true`;
4185 /// 3. an xxh3 COLLISION (same hash, different service name)
4186 /// must NOT count as covered — a false positive here would
4187 /// skip a needed subscribe and silently drop the colliding
4188 /// service's replies. The stored-name verification turns it
4189 /// into a re-subscribe instead;
4190 /// 4. the cap is enforced via `len()`, not a separate counter
4191 /// that could drift.
4192 #[test]
4193 fn reply_subscriptions_keyed_by_target_and_service_hash() {
4194 use dashmap::DashMap;
4195 let registry: DashMap<(u64, u64), Arc<str>> = DashMap::new();
4196 let h_a = xxhash_rust::xxh3::xxh3_64(b"svc-a");
4197 let h_b = xxhash_rust::xxh3::xxh3_64(b"svc-b");
4198 // Same target, different services → distinct entries.
4199 registry.insert((0xAA, h_a), Arc::from("svc-a"));
4200 registry.insert((0xAA, h_b), Arc::from("svc-b"));
4201 assert!(super::reply_subscription_covers(
4202 ®istry, 0xAA, h_a, "svc-a"
4203 ));
4204 assert!(super::reply_subscription_covers(
4205 ®istry, 0xAA, h_b, "svc-b"
4206 ));
4207 // Same service, different targets → distinct entries.
4208 assert!(!super::reply_subscription_covers(
4209 ®istry, 0xBB, h_a, "svc-a"
4210 ));
4211 registry.insert((0xBB, h_a), Arc::from("svc-a"));
4212 assert!(super::reply_subscription_covers(
4213 ®istry, 0xBB, h_a, "svc-a"
4214 ));
4215 // Idempotent — repeat insert overwrites with the identical
4216 // value; the fast path keeps answering true.
4217 registry.insert((0xAA, h_a), Arc::from("svc-a"));
4218 assert!(super::reply_subscription_covers(
4219 ®istry, 0xAA, h_a, "svc-a"
4220 ));
4221 assert_eq!(registry.len(), 3);
4222 // xxh3 collision: "svc-evil" hashing to h_a (forced here —
4223 // xxh3_64 collisions are computable offline since the hash
4224 // isn't cryptographic) must NOT cover "svc-a"'s slot, and
4225 // vice versa. The hash-only DashSet shape this replaced
4226 // answered `true` and silently skipped the subscribe.
4227 assert!(
4228 !super::reply_subscription_covers(®istry, 0xAA, h_a, "svc-evil"),
4229 "hash collision must not satisfy the membership check for a \
4230 different service name"
4231 );
4232 // After the colliding service legitimately subscribes (slot
4233 // overwritten), the original service degrades to
4234 // re-subscribe — covered must flip to false for it, never
4235 // silently true for both.
4236 registry.insert((0xAA, h_a), Arc::from("svc-evil"));
4237 assert!(super::reply_subscription_covers(
4238 ®istry, 0xAA, h_a, "svc-evil"
4239 ));
4240 assert!(!super::reply_subscription_covers(
4241 ®istry, 0xAA, h_a, "svc-a"
4242 ));
4243 }
4244
4245 /// PERF_AUDIT §3.3 — grant-stall backstop check for the
4246 /// window/2 auto-grant coalescing. Simulates the full
4247 /// credit loop for every window 1..=64: the server starts
4248 /// with `window` credits and consumes one per chunk; the
4249 /// client accumulates via `accumulate_auto_grant` and only
4250 /// flushes at the threshold. Asserts:
4251 /// 1. liveness — an actively-polling consumer never observes
4252 /// the server starved (credits exhausted with nothing left
4253 /// to poll), i.e. withholding sub-threshold credits cannot
4254 /// deadlock the stream and no timer/drop backstop is needed;
4255 /// 2. coalescing — grant-packet count stays at
4256 /// ~chunks / (window/2), and is strictly fewer than one
4257 /// grant per chunk once window ≥ 4 (the integration suite
4258 /// only exercises window=2, whose threshold degenerates
4259 /// to per-chunk).
4260 #[test]
4261 fn auto_grant_coalescing_never_starves_the_server_pump() {
4262 for window in 1u32..=64 {
4263 let chunks = 1_000u32;
4264 let mut server_credits = window as u64;
4265 let mut pending = 0u32;
4266 let mut sent = 0u32;
4267 let mut delivered = 0u32;
4268 let mut grants = 0u32;
4269 while delivered < chunks {
4270 // Server pump: send while credits remain.
4271 while server_credits > 0 && sent < chunks {
4272 server_credits -= 1;
4273 sent += 1;
4274 }
4275 assert!(
4276 sent > delivered,
4277 "window {window}: server starved while the consumer is actively \
4278 polling (credits {server_credits}, pending {pending}, \
4279 sent {sent}, delivered {delivered})"
4280 );
4281 // Consumer polls exactly one chunk.
4282 delivered += 1;
4283 if let Some(amount) = super::accumulate_auto_grant(&mut pending, window) {
4284 grants += 1;
4285 server_credits += amount as u64;
4286 }
4287 }
4288 let threshold = (window / 2).max(1);
4289 assert!(
4290 grants <= chunks / threshold + 1,
4291 "window {window}: {grants} grant packets exceeds the \
4292 coalesced cadence bound of {}",
4293 chunks / threshold + 1
4294 );
4295 if window >= 4 {
4296 assert!(
4297 grants < chunks,
4298 "window {window}: coalescing must emit fewer grants than chunks"
4299 );
4300 }
4301 }
4302 }
4303
4304 /// `get` promotes to most-recently-used, so a touched entry outlives an
4305 /// untouched one when the cache overflows by one — confirming the wrapper
4306 /// gives true LRU semantics (a hot caller isn't evicted out from under an
4307 /// in-flight exchange).
4308 #[test]
4309 fn origin_keyed_lru_get_promotes_to_mru() {
4310 let cache: OriginKeyedLru<u64> = OriginKeyedLru::new();
4311 for origin in 0..(RPC_CALLER_CACHE_CAP as u64) {
4312 cache.insert(origin, origin);
4313 }
4314 // Touch origin 0 (otherwise the LRU), then overflow by one entry.
4315 assert_eq!(cache.get(0), Some(0));
4316 cache.insert(u64::MAX, 1);
4317 assert_eq!(cache.get(0), Some(0), "touched entry must survive eviction");
4318 assert_eq!(cache.get(1), None, "the now-LRU entry (1) must be evicted");
4319 }
4320}