Skip to main content

ados_protocol/
aux_rpc_proxy.rs

1//! Ground-side client for relay-proxy HTTP-over-aux calls.
2//!
3//! A ground station paired to a drone only over WFB has no IP reach to that
4//! drone, but the aux lane already carries MAVLink, status, and identity low-
5//! rate frames between them. This module rides a fourth and fifth channel on
6//! the same pair — `Request` (ground→drone, uplink, radio_id 3) and `Response`
7//! (drone→ground, downlink, radio_id 2) — to forward an HTTP request and
8//! return its response, so a `relay-proxy` HTTP route on the ground station can
9//! reach the drone's own agent HTTP API for a peer it has no LAN or cloud
10//! address for.
11//!
12//! ## Process boundary
13//!
14//! The aux consumer (which receives Response frames off the radio) runs in the
15//! ground-station data-plane process, and the HTTP route handler lives in the
16//! control-surface process. They are separate processes, so the proxy's pending
17//! map cannot be shared in-process. The seam between them is a Unix domain
18//! socket (`/run/ados/aux-rpc-responses.sock`): the proxy's reader LISTENS on
19//! that socket, and the consumer's ingest writer CONNECTS and forwards each
20//! Response payload as a length-prefixed frame. This mirrors the existing
21//! `MavlinkIngest` pattern exactly — same framing, same lifecycle, same
22//! bounded-failure posture.
23//!
24//! ## Correlation
25//!
26//! Each request carries a 32-bit id assigned by the proxy. The drone's
27//! response echoes it back unchanged, so a pending-request map on the ground
28//! matches a response to its caller without ordering assumptions across a
29//! reordering datagram lane. A solo proxy caller can use a monotonic counter;
30//! the 32-bit space does not roll over in any realistic session at a few HTTP
31//! calls per second.
32//!
33//! ## Bounded failure
34//!
35//! Every call is bounded by [`RPC_DEFAULT_TIMEOUT`]. A timeout removes the
36//! pending entry and returns [`RpcError::Timeout`] to the caller rather than
37//! parking any worker on the radio. A drone that never answers — radio dead,
38//! consumer not running, handler not registered — surfaces as a bounded
39//! failure on the very first call, which is what its HTTP caller needs to
40//! report to the operator.
41//!
42//! ## What a successful call does and does not prove
43//!
44//! `Ok` means the drone decoded the request, dispatched it against its own
45//! HTTP API, and returned a response with the same request id. It does NOT
46//! prove the drone's HTTP server returned 200; the response's HTTP status
47//! travels inside the payload and may be 404, 500, or anything else the
48//! drone's own API returned. Callers must inspect [`RpcResponseOwned::status`].
49//!
50//! ## Send-side reuse
51//!
52//! The proxy holds one [`AuxEgress`] for its lifetime and reuses it across
53//! calls. The egress opens the aux pair lazily on first send and re-opens on
54//! a dead socket, so a proxy created before the radio is ready is not a
55//! problem; the first call's open is what lights the uplink up.
56
57use std::collections::HashMap;
58use std::path::PathBuf;
59use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
60use std::sync::Arc;
61use std::time::Duration;
62
63use crate::aux_egress::AuxEgress;
64use crate::aux_mux::{self, AuxChannel};
65use crate::aux_rpc::{self, RpcMethod};
66use crate::frame::HEADER_SIZE;
67use serde::Serialize;
68use tokio::io::{AsyncReadExt, AsyncWriteExt};
69use tokio::net::{UnixListener, UnixStream};
70use tokio::sync::{oneshot, Mutex, Notify};
71
72/// Default per-call timeout.
73///
74/// A round trip is one uplink datagram, the drone's own HTTP processing, and
75/// as many downlink fragments as the response needs. A 25-fragment response
76/// paced at 5 ms plus radio RTT plus the drone's own 5-second HTTP bound does
77/// not fit inside 5 seconds, so the ground bound is 10.
78///
79/// **The two bounds must not be equal.** The ground bound has to exceed the
80/// drone's `HTTP_TIMEOUT` so a wedged drone API surfaces as the drone's own
81/// 503 rather than an ambiguous ground-side timeout that says nothing about
82/// which hop failed.
83pub const RPC_DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
84
85/// How long a write to the proxy's local ingest socket may take.
86///
87/// This is a loopback Unix socket, so a healthy peer completes in microseconds
88/// and anything near this bound means the peer has stopped reading. The caller
89/// awaits it inside the aux consumer's single read loop, so the bound is what
90/// keeps one wedged proxy from stalling a whole drone's inbound traffic.
91const IPC_WRITE_TIMEOUT: Duration = Duration::from_millis(200);
92
93/// How long connecting to that socket may take. A listener that is present
94/// accepts immediately; one that is absent fails immediately. A long wait here
95/// means neither, which is not worth a read loop.
96const IPC_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
97
98/// Resend schedule for the Request datagram, as gaps between attempts.
99///
100/// The drone's radio is half-duplex and spends ~60% of its airtime injecting
101/// video, so an uplink datagram that lands inside a TX burst is lost at the
102/// PHY before it ever reaches a socket. Video frames pace at ~33 ms (30 fps)
103/// and bursts run ~10-20 ms, so these gaps are deliberately non-harmonic with
104/// the frame period and each is well clear of a single burst; the growth
105/// escapes a multi-frame fade. 5 attempts at a measured ~35% per-attempt loss
106/// leaves ~0.5% residual.
107///
108/// Retransmission is only safe because the drone deduplicates on request id
109/// and replays its cached response instead of re-executing the HTTP call.
110const RPC_RETRY_DELAYS: [Duration; 4] = [
111    Duration::from_millis(150),
112    Duration::from_millis(250),
113    Duration::from_millis(400),
114    Duration::from_millis(700),
115];
116
117/// Most concurrent in-flight calls. Beyond this the proxy sheds load rather
118/// than queueing radio work it cannot deliver.
119///
120/// A fleet-wide operation is 24 targeted calls issued concurrently (there is
121/// no broadcast fan-in; see [`AuxRpcProxy::call`]), and an operator surface
122/// polling several endpoints per drone multiplies that again. 256 was sized
123/// for one linked drone and a 24-drone poll would exhaust it, turning a
124/// working lane into a wall of `Busy`.
125const MAX_PENDING_CALLS: usize = 1024;
126
127/// Pause between the fragments of one multi-fragment request, for the same
128/// reason the drone paces its response fragments: back-to-back datagrams into
129/// the transmitter's UDP ingress can overrun its socket buffer and vanish.
130const REQUEST_FRAGMENT_PACING: Duration = Duration::from_millis(5);
131
132/// The default Unix socket path for the Response IPC seam.
133pub const DEFAULT_RESPONSE_SOCK: &str = "/run/ados/aux-rpc-responses.sock";
134
135/// The response, owned.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RpcResponseOwned {
138    /// The HTTP status code the drone's API returned.
139    pub status: u16,
140    /// The HTTP response body bytes the drone's API returned, reassembled from
141    /// every fragment.
142    pub body: Vec<u8>,
143    /// The allow-listed response headers the drone sent with that body, in the
144    /// order it sent them. Empty when the drone carried none — which is also
145    /// what a drone older than the header block reports, so an empty list means
146    /// "no headers crossed", never "the body has no type".
147    ///
148    /// Without these a relayed body is unusable for anything a consumer
149    /// branches on: a downloaded artifact has no `content-type` and no
150    /// `content-disposition` filename, a WHEP `Location` cannot route session
151    /// teardown back through the proxy, and a `text/plain` error renders as a
152    /// download.
153    pub headers: Vec<aux_rpc::ResponseHeader>,
154}
155
156/// Why a call did not complete.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum RpcError {
159    /// The request body exceeds [`aux_rpc::MAX_REQUEST_BODY`]. The HTTP
160    /// caller should surface a 413.
161    TooLarge,
162    /// A request field (target, path, content type, ticket) is too long to
163    /// encode. The HTTP caller should surface a 400.
164    Encode,
165    /// The aux egress failed to send the request datagram.
166    Send(String),
167    /// No response arrived before the bound elapsed.
168    Timeout,
169    /// Some fragments arrived and the rest did not. Distinct from `Timeout`
170    /// on purpose: "the drone never answered" and "the radio dropped a
171    /// fragment" are different faults with different operator actions.
172    Incomplete { received: usize, total: u16 },
173    /// The consumer torn down between the request and the response, so the
174    /// pending entry was removed. The caller may retry.
175    ChannelClosed,
176    /// Too many calls already in flight. Shedding here is honest: the lane
177    /// cannot deliver them, and queueing would only convert a fast 503 into a
178    /// slow timeout.
179    Busy,
180}
181
182impl std::fmt::Display for RpcError {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        match self {
185            Self::TooLarge => write!(f, "request body exceeds the relay limit"),
186            Self::Encode => write!(f, "request field too long to encode"),
187            Self::Send(e) => write!(f, "aux egress send failed: {e}"),
188            Self::Timeout => write!(f, "no response before timeout"),
189            Self::Incomplete { received, total } => {
190                write!(f, "relay response incomplete: {received}/{total} fragments")
191            }
192            Self::ChannelClosed => write!(f, "aux consumer channel closed before response"),
193            Self::Busy => write!(f, "relay proxy has too many calls in flight"),
194        }
195    }
196}
197
198impl std::error::Error for RpcError {}
199
200/// One in-flight call: the caller waiting on it, plus the fragments that have
201/// arrived so far.
202struct PendingCall {
203    sender: oneshot::Sender<RpcResponseOwned>,
204    /// The device id this call was addressed to; `None` for a broadcast.
205    ///
206    /// One fleet key covers up to 24 drones, so a request id is only unique
207    /// per sender. A fragment from any other aircraft is a different answer,
208    /// and RaptorQ has no per-object checksum that would catch the splice —
209    /// the caller would get a cleanly-decoded body belonging to neither drone.
210    expected_sender: Option<Box<str>>,
211    /// Seeded by the first usable fragment that arrives; `None` until then.
212    assembly: Option<Assembly>,
213    /// Set by the first Response fragment carrying this id *from the expected
214    /// sender*, whether or not it was usable. Any such fragment proves the
215    /// Request datagram reached the drone, so it is the stop signal for
216    /// [`RPC_RETRY_DELAYS`]: resending after it would only duplicate work the
217    /// drone has already done. Another drone's fragment proves nothing about
218    /// ours, so it deliberately does not set this.
219    response_started: bool,
220}
221
222/// Partial reassembly state for one response.
223struct Assembly {
224    status: u16,
225    total: u16,
226    /// Symbols accepted so far. Only meaningful as the `received` half of an
227    /// [`RpcError::Incomplete`] report: the call completes on whatever count
228    /// RaptorQ finds sufficient, not on reaching `total`.
229    received: usize,
230    /// Fragment indices already fed to the decoder. The fragment ceiling
231    /// ([`aux_rpc::MAX_RESPONSE_FRAGMENTS`]) is 64, so the whole set is one
232    /// word and a re-delivered fragment costs a bit test rather than a scan.
233    accepted: u64,
234    decoder: aux_rpc::ResponseDecoder,
235}
236
237/// Lane health for one proxy. `AuxRpcProxy` had no counters at all, so a
238/// failing relay was invisible to everything except the single HTTP caller
239/// that happened to be waiting on it.
240#[derive(Default)]
241pub struct RpcProxyCounters {
242    calls_started: AtomicU64,
243    calls_ok: AtomicU64,
244    calls_timeout: AtomicU64,
245    calls_incomplete: AtomicU64,
246    calls_send_error: AtomicU64,
247    calls_channel_closed: AtomicU64,
248    calls_busy: AtomicU64,
249    retransmits: AtomicU64,
250    /// Retransmits that could not leave: a broken egress rather than an
251    /// unanswering far end.
252    retransmit_send_errors: AtomicU64,
253    fragments_received: AtomicU64,
254    fragments_duplicate: AtomicU64,
255    /// Fragments dropped because the drone that sent them is not the one this
256    /// call addressed. Non-zero means two aircraft answered one id, which on a
257    /// shared fleet key is the failure that would otherwise splice two bodies.
258    fragments_wrong_sender: AtomicU64,
259    /// Fragments dropped because the symbol was not the size or id this
260    /// response's geometry allows: radio damage, or the two ends disagreeing
261    /// on the wire layout across an upgrade.
262    fragments_bad_symbol: AtomicU64,
263    responses_out_of_range: AtomicU64,
264    /// Live pending-map size, tracked as its own atomic rather than read as a
265    /// map length so [`AuxRpcProxy::stats`] stays synchronous and a status
266    /// handler never has to await the pending mutex.
267    pending_now: AtomicU64,
268}
269
270/// A point-in-time read of [`RpcProxyCounters`], shaped for the status route.
271#[derive(Debug, Default, Clone, Copy, Serialize, PartialEq, Eq)]
272pub struct RpcProxyStats {
273    pub calls_started: u64,
274    pub calls_ok: u64,
275    pub calls_timeout: u64,
276    pub calls_incomplete: u64,
277    pub calls_send_error: u64,
278    pub calls_channel_closed: u64,
279    pub calls_busy: u64,
280    pub retransmits: u64,
281    pub retransmit_send_errors: u64,
282    pub fragments_received: u64,
283    pub fragments_duplicate: u64,
284    pub fragments_wrong_sender: u64,
285    pub fragments_bad_symbol: u64,
286    pub responses_out_of_range: u64,
287    pub pending_now: u64,
288}
289
290/// One pending caller, keyed by request id. Cheap to clone; every clone shares
291/// the same pending map and egress.
292#[derive(Clone)]
293pub struct AuxRpcProxy {
294    egress: Arc<AuxEgress>,
295    pending: Arc<Mutex<HashMap<u32, PendingCall>>>,
296    next_id: Arc<AtomicU32>,
297    counters: Arc<RpcProxyCounters>,
298    timeout: Duration,
299}
300
301impl AuxRpcProxy {
302    /// A proxy using the given egress, with the default call timeout.
303    pub fn new(egress: AuxEgress) -> Self {
304        Self::with_timeout(egress, RPC_DEFAULT_TIMEOUT)
305    }
306
307    /// A proxy with an explicit per-call timeout (tests use a short one so a
308    /// no-response case does not cost seconds).
309    pub fn with_timeout(egress: AuxEgress, timeout: Duration) -> Self {
310        // A restart must not reuse the id space a still-in-flight response
311        // belongs to: correlation is the bare id with no echo of the request,
312        // so a collision silently hands one caller another call's body.
313        // Seeding from the clock makes reuse within a response window
314        // vanishingly unlikely without a wire change.
315        let seed = std::time::SystemTime::now()
316            .duration_since(std::time::UNIX_EPOCH)
317            .map(|d| d.subsec_nanos() ^ std::process::id())
318            .unwrap_or(1)
319            | 1;
320        Self {
321            egress: Arc::new(egress),
322            pending: Arc::new(Mutex::new(HashMap::new())),
323            next_id: Arc::new(AtomicU32::new(seed)),
324            counters: Arc::new(RpcProxyCounters::default()),
325            timeout,
326        }
327    }
328
329    /// Forward one HTTP-shaped request to the drone and await its response.
330    ///
331    /// `target` is the device id of the drone this call is for; an empty slice
332    /// broadcasts to every linked drone. `path` is the absolute path on the
333    /// drone's own HTTP API, including the leading slash and any query string
334    /// (e.g. `/api/logs?limit=5`). `body` is the request body (empty for GET).
335    /// The drone's HTTP server returns the status and body; the proxy does not
336    /// interpret either.
337    pub async fn call(
338        &self,
339        target: &[u8],
340        method: RpcMethod,
341        path: &[u8],
342        body: &[u8],
343    ) -> Result<RpcResponseOwned, RpcError> {
344        self.call_request(
345            target,
346            method,
347            &aux_rpc::RequestParts {
348                path,
349                body,
350                ..Default::default()
351            },
352        )
353        .await
354    }
355
356    /// As [`Self::call`], carrying every part of the request: its relay ticket
357    /// and the caller's content type as well as the path and body.
358    pub async fn call_request(
359        &self,
360        target: &[u8],
361        method: RpcMethod,
362        parts: &aux_rpc::RequestParts<'_>,
363    ) -> Result<RpcResponseOwned, RpcError> {
364        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
365        self.call_with_id(id, target, method, parts).await
366    }
367
368    /// Send every fragment of one request, paced so a multi-fragment burst
369    /// does not overrun the transmitter's ingress.
370    async fn send_fragments(&self, fragments: &[Vec<u8>]) -> Result<(), String> {
371        let last = fragments.len().saturating_sub(1);
372        for (index, fragment) in fragments.iter().enumerate() {
373            self.egress
374                .send(AuxChannel::Request, fragment)
375                .await
376                .map_err(|e| e.to_string())?;
377            if index < last {
378                tokio::time::sleep(REQUEST_FRAGMENT_PACING).await;
379            }
380        }
381        Ok(())
382    }
383
384    async fn call_with_id(
385        &self,
386        id: u32,
387        target: &[u8],
388        method: RpcMethod,
389        parts: &aux_rpc::RequestParts<'_>,
390    ) -> Result<RpcResponseOwned, RpcError> {
391        let fragments =
392            aux_rpc::encode_request(method, id, target, parts).map_err(|e| match e {
393                aux_rpc::RequestEncodeError::BodyTooLarge => RpcError::TooLarge,
394                aux_rpc::RequestEncodeError::FieldTooLong => RpcError::Encode,
395            })?;
396        // An empty target is a broadcast, which any linked drone may answer,
397        // so there is no single sender to hold its fragments to. A target that
398        // is not UTF-8 is not a device id any drone will ever send back, and
399        // the lossy conversion makes that mismatch — the safe direction.
400        let expected_sender = (!target.is_empty()).then(|| {
401            String::from_utf8_lossy(target)
402                .into_owned()
403                .into_boxed_str()
404        });
405
406        let (tx, rx) = oneshot::channel::<RpcResponseOwned>();
407        {
408            let mut pending = self.pending.lock().await;
409            if pending.len() >= MAX_PENDING_CALLS {
410                self.counters.calls_busy.fetch_add(1, Ordering::Relaxed);
411                return Err(RpcError::Busy);
412            }
413            pending.insert(
414                id,
415                PendingCall {
416                    sender: tx,
417                    expected_sender,
418                    assembly: None,
419                    response_started: false,
420                },
421            );
422        }
423        self.counters.pending_now.fetch_add(1, Ordering::Relaxed);
424        self.counters.calls_started.fetch_add(1, Ordering::Relaxed);
425
426        // Armed from here on: every exit below either removes the entry itself
427        // and disarms, or is a cancellation the guard has to clean up for us.
428        let mut guard = PendingGuard {
429            pending: Arc::clone(&self.pending),
430            counters: Arc::clone(&self.counters),
431            id,
432            armed: true,
433        };
434
435        if let Err(e) = self.send_fragments(&fragments).await {
436            guard.disarm();
437            self.remove_pending(id).await;
438            self.counters
439                .calls_send_error
440                .fetch_add(1, Ordering::Relaxed);
441            return Err(RpcError::Send(e));
442        }
443
444        // One datagram on a lane that loses 20-40% of the uplink is a coin
445        // flip, so resend on a growing schedule until either the response
446        // starts arriving or the caller's bound elapses. The bound starts once
447        // the request is fully on the air, so a large request's own send time
448        // does not eat the drone's answer time.
449        let deadline = tokio::time::Instant::now() + self.timeout;
450        let mut attempt = 0usize;
451        tokio::pin!(rx);
452
453        loop {
454            let wake = match RPC_RETRY_DELAYS.get(attempt).copied() {
455                Some(d) => (tokio::time::Instant::now() + d).min(deadline),
456                None => deadline,
457            };
458            tokio::select! {
459                biased;
460                res = &mut rx => {
461                    guard.disarm();
462                    return match res {
463                        Ok(resp) => {
464                            self.counters.calls_ok.fetch_add(1, Ordering::Relaxed);
465                            Ok(resp)
466                        }
467                        Err(_) => {
468                            self.remove_pending(id).await;
469                            self.counters
470                                .calls_channel_closed
471                                .fetch_add(1, Ordering::Relaxed);
472                            Err(RpcError::ChannelClosed)
473                        }
474                    };
475                }
476                _ = tokio::time::sleep_until(wake) => {
477                    if tokio::time::Instant::now() >= deadline {
478                        guard.disarm();
479                        // Some fragments in flight is a different fault from silence.
480                        let partial = self.remove_pending(id).await;
481                        return match partial.and_then(|p| p.assembly) {
482                            Some(a) => {
483                                self.counters
484                                    .calls_incomplete
485                                    .fetch_add(1, Ordering::Relaxed);
486                                Err(RpcError::Incomplete {
487                                    received: a.received,
488                                    total: a.total,
489                                })
490                            }
491                            None => {
492                                self.counters.calls_timeout.fetch_add(1, Ordering::Relaxed);
493                                Err(RpcError::Timeout)
494                            }
495                        };
496                    }
497                    attempt += 1;
498                    let started = self
499                        .pending
500                        .lock()
501                        .await
502                        .get(&id)
503                        .map(|p| p.response_started)
504                        .unwrap_or(false);
505                    if !started {
506                        // Count what actually went out. The counter used to be
507                        // taken before the send and the send's error dropped,
508                        // so a broken egress — a closed socket, a stopped
509                        // transmitter — read exactly like a healthy radio the
510                        // far end simply was not answering, which is the wrong
511                        // half of the link to go looking at.
512                        match self.send_fragments(&fragments).await {
513                            Ok(()) => {
514                                self.counters.retransmits.fetch_add(1, Ordering::Relaxed);
515                            }
516                            Err(e) => {
517                                self.counters
518                                    .retransmit_send_errors
519                                    .fetch_add(1, Ordering::Relaxed);
520                                tracing::warn!(
521                                    request_id = id,
522                                    attempt,
523                                    error = %e,
524                                    "aux_rpc_retransmit_send_failed"
525                                );
526                            }
527                        }
528                    }
529                }
530            }
531        }
532    }
533
534    /// Remove one pending entry and keep `pending_now` honest. Every removal
535    /// outside `dispatch_response` goes through here.
536    async fn remove_pending(&self, id: u32) -> Option<PendingCall> {
537        let removed = self.pending.lock().await.remove(&id);
538        if removed.is_some() {
539            self.counters.pending_now.fetch_sub(1, Ordering::Relaxed);
540        }
541        removed
542    }
543
544    /// Route a decoded response fragment to its pending caller, completing the
545    /// call as soon as RaptorQ can rebuild the body from what has arrived.
546    ///
547    /// Called by the response reader task when it decodes a Response payload
548    /// off the IPC socket. A fragment whose id matches no pending entry is a
549    /// no-op: the caller already gave up (timeout) or the proxy was reset,
550    /// both expected on a reordering lane.
551    ///
552    /// Completion is on sufficiency, not on `total`: a response carries `k`
553    /// systematic plus [`aux_rpc::RPC_REPAIR_SYMBOLS`] repair symbols, and
554    /// waiting for every index is exactly the geometric loss decay the repair
555    /// symbols exist to remove.
556    pub async fn dispatch_response(&self, response: &aux_rpc::RpcResponse<'_>) {
557        self.counters
558            .fragments_received
559            .fetch_add(1, Ordering::Relaxed);
560        let mut pending = self.pending.lock().await;
561        // Taken out of the map for the duration: a fragment that does not
562        // complete the call is put back untouched.
563        let Some(mut call) = pending.remove(&response.id) else {
564            return;
565        };
566
567        // Ahead of everything, and deliberately ahead of `response_started`:
568        // another drone answering this id says nothing about whether ours got
569        // the request, so letting it stop the resend loop guarantees a timeout.
570        if call
571            .expected_sender
572            .as_deref()
573            .is_some_and(|expected| response.sender != expected.as_bytes())
574        {
575            self.counters
576                .fragments_wrong_sender
577                .fetch_add(1, Ordering::Relaxed);
578            pending.insert(response.id, call);
579            return;
580        }
581
582        // A fragment bearing this id from our drone proves the Request
583        // datagram landed, so the resend loop must stop even if this
584        // particular fragment turns out to be unusable.
585        call.response_started = true;
586
587        if call.assembly.is_none() {
588            let bounded =
589                response.total > 0 && response.total as usize <= aux_rpc::MAX_RESPONSE_FRAGMENTS;
590            let seeded = bounded
591                .then(|| aux_rpc::ResponseDecoder::new(response.oti))
592                .flatten();
593            let Some(decoder) = seeded else {
594                // A fragment count or a transfer length we cannot bound, both
595                // numbers the radio handed us. Complete the caller with an
596                // honest gateway error rather than sizing a decoder from them.
597                self.counters
598                    .responses_out_of_range
599                    .fetch_add(1, Ordering::Relaxed);
600                self.counters.pending_now.fetch_sub(1, Ordering::Relaxed);
601                let _ = call.sender.send(RpcResponseOwned {
602                    status: 502,
603                    body: b"relay response geometry out of range".to_vec(),
604                    headers: Vec::new(),
605                });
606                return;
607            };
608            call.assembly = Some(Assembly {
609                status: response.status,
610                total: response.total,
611                received: 0,
612                accepted: 0,
613                decoder,
614            });
615        }
616
617        let index = response.index as usize;
618        // `total` is capped at MAX_RESPONSE_FRAGMENTS (64), so every in-range
619        // index owns a distinct bit; the mask exists only to keep the shift
620        // defined for an out-of-range one, which `fresh` then rejects.
621        let bit = 1u64 << (index % 64);
622        let assembly = call.assembly.as_mut().expect("seeded above");
623        // A stale fragment from a recycled id must not reach the decoder, an
624        // out-of-range index must not be accepted, and the lane may re-deliver
625        // so a duplicate must not be counted or decoded twice.
626        let fresh = assembly.total == response.total
627            && index < assembly.total as usize
628            && assembly.accepted & bit == 0;
629        let outcome = fresh.then(|| assembly.decoder.push(response.index, response.body));
630        if matches!(outcome, Some(aux_rpc::FragmentOutcome::Pending)) {
631            assembly.accepted |= bit;
632            assembly.received += 1;
633        }
634        let status = assembly.status;
635
636        match outcome {
637            None => {
638                self.counters
639                    .fragments_duplicate
640                    .fetch_add(1, Ordering::Relaxed);
641                pending.insert(response.id, call);
642            }
643            Some(aux_rpc::FragmentOutcome::BadSymbol) => {
644                self.counters
645                    .fragments_bad_symbol
646                    .fetch_add(1, Ordering::Relaxed);
647                pending.insert(response.id, call);
648            }
649            Some(aux_rpc::FragmentOutcome::Pending) => {
650                pending.insert(response.id, call);
651            }
652            Some(aux_rpc::FragmentOutcome::Complete(object)) => {
653                self.counters.pending_now.fetch_sub(1, Ordering::Relaxed);
654                // The reassembled bytes are the encoded object, not the body:
655                // the drone folds an allow-listed header block in after the
656                // body and marks it in the status's top bit. Split it back
657                // apart here, so every caller of `call` sees a real HTTP
658                // status, the body alone, and whatever headers crossed.
659                let (status, body, headers) = aux_rpc::unpack_response(status, object);
660                let _ = call.sender.send(RpcResponseOwned {
661                    status,
662                    body,
663                    headers,
664                });
665            }
666        }
667    }
668
669    /// A synchronous read of the lane's counters, for the status route.
670    pub fn stats(&self) -> RpcProxyStats {
671        let c = &self.counters;
672        RpcProxyStats {
673            calls_started: c.calls_started.load(Ordering::Relaxed),
674            calls_ok: c.calls_ok.load(Ordering::Relaxed),
675            calls_timeout: c.calls_timeout.load(Ordering::Relaxed),
676            calls_incomplete: c.calls_incomplete.load(Ordering::Relaxed),
677            calls_send_error: c.calls_send_error.load(Ordering::Relaxed),
678            calls_channel_closed: c.calls_channel_closed.load(Ordering::Relaxed),
679            calls_busy: c.calls_busy.load(Ordering::Relaxed),
680            retransmits: c.retransmits.load(Ordering::Relaxed),
681            retransmit_send_errors: c.retransmit_send_errors.load(Ordering::Relaxed),
682            fragments_received: c.fragments_received.load(Ordering::Relaxed),
683            fragments_duplicate: c.fragments_duplicate.load(Ordering::Relaxed),
684            fragments_wrong_sender: c.fragments_wrong_sender.load(Ordering::Relaxed),
685            fragments_bad_symbol: c.fragments_bad_symbol.load(Ordering::Relaxed),
686            responses_out_of_range: c.responses_out_of_range.load(Ordering::Relaxed),
687            pending_now: c.pending_now.load(Ordering::Relaxed),
688        }
689    }
690
691    /// Drop every pending caller. Used on shutdown so a caller awaiting a
692    /// response whose consumer is gone does not wait the full timeout.
693    pub async fn reset(&self) {
694        let mut pending = self.pending.lock().await;
695        for (_, call) in pending.drain() {
696            let _ = call.sender.send(RpcResponseOwned {
697                status: 503,
698                body: Vec::new(),
699                headers: Vec::new(),
700            });
701        }
702        self.counters.pending_now.store(0, Ordering::Relaxed);
703    }
704
705    /// Spawn the reader task that LISTENS on the Response IPC socket and
706    /// dispatches each frame to its pending caller. Returns a cancel handle;
707    /// notifying it stops the reader cleanly.
708    ///
709    /// The listener accepts connections from the consumer's ingest writer (one
710    /// per consumer process). A consumer that connects after the proxy is up
711    /// is accepted immediately; a consumer that disconnects mid-session has its
712    /// connection dropped and a new one accepted on the next reconnect.
713    pub fn spawn_response_listener(
714        &self,
715        sock_path: impl Into<PathBuf>,
716        cancel: Arc<Notify>,
717    ) -> tokio::task::JoinHandle<()> {
718        let proxy = self.clone();
719        let sock_path = sock_path.into();
720        tokio::spawn(async move {
721            run_response_listener(proxy, sock_path, cancel).await;
722        })
723    }
724}
725
726/// Removes its pending entry when dropped, however the call ends — including a
727/// cancelled future, which ordinary post-await cleanup never reaches. An axum
728/// handler whose client disconnects drops the call future mid-flight, and
729/// without this the entry (with its `oneshot::Sender` and up to a 76 KB
730/// `Assembly`) leaks for the life of the process.
731///
732/// The map is behind an async mutex, so the removal is scheduled rather than
733/// taken inline; `Handle::try_current` keeps a drop outside the runtime from
734/// panicking.
735struct PendingGuard {
736    pending: Arc<Mutex<HashMap<u32, PendingCall>>>,
737    counters: Arc<RpcProxyCounters>,
738    id: u32,
739    armed: bool,
740}
741
742impl PendingGuard {
743    /// Called on any path that already removed the entry itself.
744    fn disarm(&mut self) {
745        self.armed = false;
746    }
747}
748
749impl Drop for PendingGuard {
750    fn drop(&mut self) {
751        if !self.armed {
752            return;
753        }
754        let pending = Arc::clone(&self.pending);
755        let counters = Arc::clone(&self.counters);
756        let id = self.id;
757        if let Ok(handle) = tokio::runtime::Handle::try_current() {
758            handle.spawn(async move {
759                if pending.lock().await.remove(&id).is_some() {
760                    counters.pending_now.fetch_sub(1, Ordering::Relaxed);
761                }
762            });
763        }
764    }
765}
766
767/// The listener loop: bind, accept connections, read length-prefixed Response
768/// payloads, dispatch to pending callers.
769async fn run_response_listener(proxy: AuxRpcProxy, sock_path: PathBuf, cancel: Arc<Notify>) {
770    // Remove a stale socket file from a previous process. A leftover file from
771    // an unclean shutdown prevents the new bind from succeeding, which would
772    // silently disable the response dispatch path for the whole process
773    // lifetime. Removing first is the same pattern the mavlink state socket uses.
774    let _ = std::fs::remove_file(&sock_path);
775
776    let listener = match UnixListener::bind(&sock_path) {
777        Ok(l) => l,
778        Err(e) => {
779            tracing::error!(error = %e, path = %sock_path.display(), "aux_rpc_response_listener_bind_failed");
780            return;
781        }
782    };
783    tracing::info!(path = %sock_path.display(), "aux_rpc_response_listener_listening");
784
785    loop {
786        tokio::select! {
787            biased;
788            _ = cancel.notified() => break,
789            accept_result = listener.accept() => {
790                match accept_result {
791                    Ok((stream, _)) => {
792                        let proxy = proxy.clone();
793                        tokio::spawn(handle_one_connection(stream, proxy));
794                    }
795                    Err(e) => {
796                        tracing::warn!(error = %e, "aux_rpc_response_listener_accept_failed");
797                    }
798                }
799            }
800        }
801    }
802    // Shutdown: complete every waiter now rather than leaving each one to burn
803    // its full 10-second bound against a consumer that is already gone.
804    proxy.reset().await;
805    tracing::info!("aux_rpc_response_listener_stopped");
806    let _ = std::fs::remove_file(&sock_path);
807}
808
809/// Read length-prefixed Response payloads off one accepted connection until EOF.
810///
811/// [`AuxRpcResponseIngest::send`] writes exactly one fragment per
812/// length-prefixed frame, so each read is one whole payload. It is decoded on
813/// its own rather than accumulated: a fragmented response is 25 frames on the
814/// wire, and one undecodable frame appended to a shared buffer would poison
815/// every frame after it for the life of the connection.
816async fn handle_one_connection(mut stream: UnixStream, proxy: AuxRpcProxy) {
817    loop {
818        match read_one_frame(&mut stream).await {
819            Ok(payload) => match aux_rpc::decode_response(&payload) {
820                Ok(response) => proxy.dispatch_response(&response).await,
821                Err(e) => {
822                    tracing::debug!(error = ?e, len = payload.len(), "aux_rpc_response_undecodable");
823                }
824            },
825            Err(e) => {
826                if e.kind() != std::io::ErrorKind::UnexpectedEof {
827                    tracing::debug!(error = %e, "aux_rpc_response_reader_read_failed");
828                }
829                break;
830            }
831        }
832    }
833}
834
835/// Read one length-prefixed frame off the stream. Returns the payload bytes.
836async fn read_one_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
837    let mut header = [0u8; HEADER_SIZE];
838    stream.read_exact(&mut header).await?;
839    let len = u32::from_be_bytes(header) as usize;
840    if len > aux_mux::AUX_MAX_PAYLOAD {
841        return Err(std::io::Error::new(
842            std::io::ErrorKind::InvalidData,
843            "response frame exceeds the aux payload maximum",
844        ));
845    }
846    let mut payload = vec![0u8; len];
847    if len > 0 {
848        stream.read_exact(&mut payload).await?;
849    }
850    Ok(payload)
851}
852
853/// Writer side: CONNECTS to the Response IPC socket and writes each Response
854/// payload as a length-prefixed frame. Used by the aux consumer's process to
855/// forward Response datagrams to the proxy's process.
856#[derive(Clone)]
857pub struct AuxRpcResponseIngest {
858    sock_path: PathBuf,
859    conn: Arc<Mutex<Option<UnixStream>>>,
860}
861
862impl AuxRpcResponseIngest {
863    /// An ingest writing to the given Unix socket path.
864    pub fn new(sock_path: impl Into<PathBuf>) -> Self {
865        Self {
866            sock_path: sock_path.into(),
867            conn: Arc::new(Mutex::new(None)),
868        }
869    }
870
871    /// Forward one Response payload as a length-prefixed frame.
872    ///
873    /// Best-effort: a failed or slow write drops the frame and drops the
874    /// connection so the next call re-connects.
875    ///
876    /// Both the connect and the write are BOUNDED, and that is the whole
877    /// point. The caller awaits this inside the aux consumer's single
878    /// `recv_from` loop, so an unbounded write to a proxy that has stopped
879    /// reading its socket stalls that slot's entire read loop — not just RPC,
880    /// but that drone's MAVLink ingest, its status frames and its peer cache.
881    /// The doc used to claim this never blocks while `write_all` on a full
882    /// socket buffer did exactly that.
883    pub async fn send(&self, payload: &[u8]) {
884        // Length-prefix the payload: 4-byte BE length + body, matching the
885        // frame module's framing shared across all the agent's IPC.
886        let len = payload.len() as u32;
887        let mut frame = Vec::with_capacity(HEADER_SIZE + payload.len());
888        frame.extend_from_slice(&len.to_be_bytes());
889        frame.extend_from_slice(payload);
890        let mut guard = self.conn.lock().await;
891        match guard.as_mut() {
892            Some(stream) => {
893                let wrote = tokio::time::timeout(IPC_WRITE_TIMEOUT, stream.write_all(&frame)).await;
894                // A timeout and an error are treated alike: drop the
895                // connection so the next call reconnects. A peer that stopped
896                // reading is not going to start because we waited longer, and
897                // the caller's read loop is what pays for the wait.
898                if !matches!(wrote, Ok(Ok(()))) {
899                    if wrote.is_err() {
900                        tracing::warn!(
901                            timeout_ms = IPC_WRITE_TIMEOUT.as_millis() as u64,
902                            "aux_rpc_ingest_write_timed_out"
903                        );
904                    }
905                    *guard = None;
906                }
907            }
908            None => {
909                match tokio::time::timeout(
910                    IPC_CONNECT_TIMEOUT,
911                    UnixStream::connect(&self.sock_path),
912                )
913                .await
914                {
915                    Ok(Ok(mut stream)) => {
916                        let wrote =
917                            tokio::time::timeout(IPC_WRITE_TIMEOUT, stream.write_all(&frame)).await;
918                        if matches!(wrote, Ok(Ok(()))) {
919                            *guard = Some(stream);
920                        }
921                    }
922                    Ok(Err(_)) => {
923                        // No listener yet; the proxy will start eventually.
924                    }
925                    Err(_) => {
926                        tracing::warn!(
927                            timeout_ms = IPC_CONNECT_TIMEOUT.as_millis() as u64,
928                            "aux_rpc_ingest_connect_timed_out"
929                        );
930                    }
931                }
932            }
933        }
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    /// A peer that accepts a connection and then never reads.
942    ///
943    /// This is the shape that stalls a read loop: the socket buffer fills, the
944    /// write blocks, and every drone behind that consumer goes quiet.
945    async fn deaf_listener(path: &std::path::Path) -> tokio::task::JoinHandle<()> {
946        let listener = tokio::net::UnixListener::bind(path).unwrap();
947        tokio::spawn(async move {
948            let mut held = Vec::new();
949            loop {
950                match listener.accept().await {
951                    // Hold the connection open and never read from it.
952                    Ok((s, _)) => held.push(s),
953                    Err(_) => return,
954                }
955            }
956        })
957    }
958
959    #[tokio::test]
960    async fn a_peer_that_stops_reading_cannot_stall_the_caller() {
961        // The caller awaits this inside the aux consumer's single recv loop, so
962        // an unbounded write to a wedged proxy stalls that slot's whole inbound
963        // path — MAVLink ingest, status frames and the peer cache, not just RPC.
964        let dir = tempfile::tempdir().unwrap();
965        let sock = dir.path().join("ingest.sock");
966        let _listener = deaf_listener(&sock).await;
967
968        let ingest = AuxRpcResponseIngest::new(&sock);
969        let big = vec![0u8; 512 * 1024];
970
971        // Write until the buffer fills; every call must return promptly.
972        let started = std::time::Instant::now();
973        for _ in 0..8 {
974            ingest.send(&big).await;
975        }
976        let elapsed = started.elapsed();
977        assert!(
978            elapsed < Duration::from_secs(5),
979            "sends took {elapsed:?}; a wedged peer is stalling the caller"
980        );
981    }
982
983    #[tokio::test]
984    async fn an_absent_listener_returns_promptly() {
985        let ingest =
986            AuxRpcResponseIngest::new(std::path::Path::new("/nonexistent/ados/ingest.sock"));
987        let started = std::time::Instant::now();
988        ingest.send(b"payload").await;
989        assert!(started.elapsed() < Duration::from_secs(1));
990    }
991
992    #[test]
993    fn the_ipc_bounds_are_far_below_the_call_bound() {
994        // These guard a read loop, not a call. A bound anywhere near the RPC
995        // timeout would let one wedged proxy silence a drone for seconds.
996        assert!(IPC_WRITE_TIMEOUT < RPC_DEFAULT_TIMEOUT / 10);
997        assert!(IPC_CONNECT_TIMEOUT < RPC_DEFAULT_TIMEOUT / 10);
998    }
999    use crate::aux_mux;
1000    use tokio::net::UdpSocket;
1001    use tokio::time::Duration;
1002
1003    /// A no-radio egress that the test can read back from.
1004    async fn loopback_egress() -> (
1005        AuxEgress,
1006        tokio::sync::mpsc::Receiver<(AuxChannel, Vec<u8>)>,
1007    ) {
1008        let listener = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1009        let port = listener.local_addr().unwrap().port();
1010        let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1011        sock.connect(("127.0.0.1", port)).await.unwrap();
1012        let egress = AuxEgress::connected_for_test(sock);
1013
1014        let (tx, rx) = tokio::sync::mpsc::channel::<(AuxChannel, Vec<u8>)>(8);
1015        tokio::spawn(async move {
1016            let mut buf = [0u8; 2048];
1017            while let Ok((n, _)) = listener.recv_from(&mut buf).await {
1018                if let Ok((ch, payload)) = aux_mux::decode(&buf[..n]) {
1019                    let _ = tx.send((ch, payload.to_vec())).await;
1020                }
1021            }
1022        });
1023        (egress, rx)
1024    }
1025
1026    /// The device id the tests address, and the one their drone answers with.
1027    const DRONE: &[u8] = b"0a1b2c3d4e5f";
1028
1029    /// The encoded aux payloads for one response, exactly as a drone emits
1030    /// them: RaptorQ symbols, each stamped with the sender's device id.
1031    fn response_payloads(sender: &[u8], id: u32, status: u16, body: &[u8]) -> Vec<Vec<u8>> {
1032        let split = aux_rpc::split_response(body).unwrap();
1033        let total = split.symbols.len() as u16;
1034        split
1035            .symbols
1036            .iter()
1037            .enumerate()
1038            .map(|(index, symbol)| {
1039                aux_rpc::encode_response_fragment(
1040                    sender,
1041                    id,
1042                    status,
1043                    index as u16,
1044                    total,
1045                    split.oti,
1046                    symbol,
1047                )
1048                .unwrap()
1049            })
1050            .collect()
1051    }
1052
1053    /// Decode one encoded fragment and hand it to the proxy, the way the
1054    /// response listener does.
1055    async fn deliver(proxy: &AuxRpcProxy, payload: &[u8]) {
1056        let fragment = aux_rpc::decode_response(payload).unwrap();
1057        proxy.dispatch_response(&fragment).await;
1058    }
1059
1060    /// Deliver every fragment of one response.
1061    async fn deliver_body(proxy: &AuxRpcProxy, sender: &[u8], id: u32, status: u16, body: &[u8]) {
1062        for payload in response_payloads(sender, id, status, body) {
1063            deliver(proxy, &payload).await;
1064        }
1065    }
1066
1067    /// Sniff the id of the single call the proxy currently has in flight.
1068    async fn pending_id(proxy: &AuxRpcProxy) -> u32 {
1069        let pending = proxy.pending.lock().await;
1070        *pending.keys().next().expect("one pending caller")
1071    }
1072
1073    #[tokio::test]
1074    async fn a_call_round_trips_through_egress_and_a_dispatched_response() {
1075        let (egress, mut sent) = loopback_egress().await;
1076        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1077
1078        let proxy_for_consumer = proxy.clone();
1079        let consumer = tokio::spawn(async move {
1080            let (channel, payload) = sent.recv().await.expect("no request datagram");
1081            assert_eq!(channel, AuxChannel::Request);
1082            let fragment = aux_rpc::decode_request_fragment(&payload).unwrap();
1083            assert_eq!(fragment.total, 1, "a small request is one datagram");
1084            let aux_rpc::ReassemblyOutcome::Complete(request) =
1085                aux_rpc::RequestReassembler::new().push(&fragment, std::time::Instant::now())
1086            else {
1087                panic!("a one-fragment request completes on arrival");
1088            };
1089            assert_eq!(request.method, RpcMethod::Get);
1090            assert_eq!(request.target, DRONE);
1091            assert_eq!(request.path, b"/api/pairing/info");
1092            assert!(request.body.is_empty());
1093
1094            let body = br#"{"device_id":"abc"}"#;
1095            deliver_body(&proxy_for_consumer, DRONE, request.id, 200, body).await;
1096        });
1097
1098        let result = proxy
1099            .call(DRONE, RpcMethod::Get, b"/api/pairing/info", &[])
1100            .await
1101            .expect("call must succeed");
1102
1103        consumer.await.unwrap();
1104        assert_eq!(result.status, 200);
1105        assert_eq!(result.body, br#"{"device_id":"abc"}"#);
1106    }
1107
1108    #[tokio::test]
1109    async fn a_multi_fragment_response_completes_on_its_systematic_symbols_alone() {
1110        let (egress, mut sent) = loopback_egress().await;
1111        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1112
1113        let body: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
1114        let expected = body.clone();
1115        let proxy_call = proxy.clone();
1116        let call = tokio::spawn(async move {
1117            proxy_call
1118                .call(DRONE, RpcMethod::Get, b"/api/services", &[])
1119                .await
1120        });
1121
1122        let (_, payload) = sent.recv().await.expect("request datagram");
1123        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1124        let fragments = response_payloads(DRONE, id, 200, &body);
1125        assert_eq!(fragments.len(), 3 + aux_rpc::RPC_REPAIR_SYMBOLS as usize);
1126        // Only the systematic symbols. The repair symbols are the cushion, not
1127        // a prerequisite: a caller that waited for all seven would be back to
1128        // the geometric loss decay they exist to remove.
1129        for fragment in &fragments[..3] {
1130            deliver(&proxy, fragment).await;
1131        }
1132
1133        let result = call.await.unwrap().expect("call must succeed");
1134        assert_eq!(result.status, 200);
1135        assert_eq!(result.body, expected);
1136    }
1137
1138    #[tokio::test]
1139    async fn fragments_delivered_out_of_order_reassemble_identically() {
1140        let (egress, mut sent) = loopback_egress().await;
1141        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1142
1143        let body: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
1144        let expected = body.clone();
1145        let proxy_call = proxy.clone();
1146        let call = tokio::spawn(async move {
1147            proxy_call
1148                .call(DRONE, RpcMethod::Get, b"/api/services", &[])
1149                .await
1150        });
1151
1152        let (_, payload) = sent.recv().await.expect("request datagram");
1153        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1154        let mut fragments = response_payloads(DRONE, id, 200, &body);
1155        fragments.reverse();
1156        for fragment in &fragments {
1157            deliver(&proxy, fragment).await;
1158        }
1159
1160        let result = call.await.unwrap().expect("call must succeed");
1161        assert_eq!(result.body, expected);
1162    }
1163
1164    #[tokio::test]
1165    async fn a_thirty_kilobyte_response_completes_after_losing_four_fragments() {
1166        // The measured `/api/status/full`. All-or-nothing reassembly of its 26
1167        // fragments at the measured 0.7% per-fragment loss succeeded 17/20 on
1168        // the bench; the four repair symbols are what remove that decay.
1169        let (egress, mut sent) = loopback_egress().await;
1170        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(2));
1171
1172        let body: Vec<u8> = (0..29_339u32).map(|i| (i % 251) as u8).collect();
1173        let expected = body.clone();
1174        let proxy_call = proxy.clone();
1175        let call = tokio::spawn(async move {
1176            proxy_call
1177                .call(DRONE, RpcMethod::Get, b"/api/status/full", &[])
1178                .await
1179        });
1180
1181        let (_, payload) = sent.recv().await.expect("request datagram");
1182        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1183        let mut fragments = response_payloads(DRONE, id, 200, &body);
1184        assert_eq!(fragments.len(), 30, "26 systematic + 4 repair");
1185        // Reordered by the lane, and four of them never arrive.
1186        fragments.rotate_left(7);
1187        fragments.truncate(26);
1188        for fragment in &fragments {
1189            deliver(&proxy, fragment).await;
1190        }
1191
1192        let result = call.await.unwrap().expect("26 of 30 must be enough");
1193        assert_eq!(result.body, expected);
1194        assert_eq!(proxy.stats().calls_ok, 1);
1195    }
1196
1197    #[tokio::test]
1198    async fn a_fragment_from_another_drone_is_dropped_and_counted() {
1199        // 24 drones share one fleet key, so a request id is only unique per
1200        // sender. Splicing a second drone's symbols into this call's decoder
1201        // yields a cleanly-decoded body belonging to neither aircraft —
1202        // RaptorQ has no per-object checksum that would catch it.
1203        let (egress, mut sent) = loopback_egress().await;
1204        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(300));
1205
1206        let proxy_call = proxy.clone();
1207        let call =
1208            tokio::spawn(
1209                async move { proxy_call.call(DRONE, RpcMethod::Get, b"/api/x", &[]).await },
1210            );
1211
1212        let (_, payload) = sent.recv().await.expect("request datagram");
1213        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1214        let imposter = response_payloads(b"ados-elsewhere", id, 200, b"not-your-answer");
1215        for fragment in &imposter {
1216            deliver(&proxy, fragment).await;
1217        }
1218
1219        let err = call.await.unwrap().unwrap_err();
1220        let stats = proxy.stats();
1221        assert_eq!(
1222            err,
1223            RpcError::Timeout,
1224            "an imposter must not complete a call"
1225        );
1226        assert_eq!(stats.fragments_wrong_sender, imposter.len() as u64);
1227        assert_eq!(stats.calls_ok, 0);
1228        assert!(
1229            stats.retransmits > 0,
1230            "another drone's answer proves nothing about ours, so the resend \
1231             loop must keep running"
1232        );
1233    }
1234
1235    #[tokio::test]
1236    async fn the_addressed_drone_still_completes_the_call_after_an_imposter() {
1237        // Dropping the wrong sender's fragments must not poison the pending
1238        // entry: the real answer, arriving second, still has to land.
1239        let (egress, mut sent) = loopback_egress().await;
1240        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(2));
1241
1242        let proxy_call = proxy.clone();
1243        let call =
1244            tokio::spawn(
1245                async move { proxy_call.call(DRONE, RpcMethod::Get, b"/api/x", &[]).await },
1246            );
1247
1248        let (_, payload) = sent.recv().await.expect("request datagram");
1249        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1250        for fragment in &response_payloads(b"ados-elsewhere", id, 200, b"wrong-drone") {
1251            deliver(&proxy, fragment).await;
1252        }
1253        deliver_body(&proxy, DRONE, id, 200, b"right-drone").await;
1254
1255        let result = call.await.unwrap().expect("the addressed drone answered");
1256        assert_eq!(result.body, b"right-drone");
1257    }
1258
1259    #[tokio::test]
1260    async fn a_broadcast_call_accepts_whichever_drone_answers() {
1261        // An empty target is a broadcast: there is no single sender to hold
1262        // the fragments to, and gating one would break the diagnostic call
1263        // that does not know a device id yet.
1264        let (egress, mut sent) = loopback_egress().await;
1265        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1266
1267        let proxy_call = proxy.clone();
1268        let call =
1269            tokio::spawn(async move { proxy_call.call(&[], RpcMethod::Get, b"/api/x", &[]).await });
1270
1271        let (_, payload) = sent.recv().await.expect("request datagram");
1272        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1273        deliver_body(&proxy, b"ados-anyone", id, 200, b"answered").await;
1274
1275        let result = call.await.unwrap().expect("call must succeed");
1276        assert_eq!(result.body, b"answered");
1277        assert_eq!(proxy.stats().fragments_wrong_sender, 0);
1278    }
1279
1280    #[tokio::test]
1281    async fn a_duplicate_fragment_does_not_double_count() {
1282        let (egress, mut sent) = loopback_egress().await;
1283        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(400));
1284
1285        let body: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
1286        let expected = body.clone();
1287        let proxy_call = proxy.clone();
1288        let call =
1289            tokio::spawn(
1290                async move { proxy_call.call(DRONE, RpcMethod::Get, b"/api/x", &[]).await },
1291            );
1292
1293        let (_, payload) = sent.recv().await.expect("request datagram");
1294        let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1295        let fragments = response_payloads(DRONE, id, 200, &body);
1296        // Symbol 0 twice, then 1: a naive counter would report two received
1297        // with symbol 1 still missing.
1298        for i in [0usize, 0, 1] {
1299            deliver(&proxy, &fragments[i]).await;
1300        }
1301
1302        let result = call.await.unwrap().expect("call must succeed");
1303        assert_eq!(result.body, expected);
1304        assert_eq!(proxy.stats().fragments_duplicate, 1);
1305    }
1306
1307    #[tokio::test]
1308    async fn a_missing_fragment_yields_incomplete_not_timeout() {
1309        let (egress, mut sent) = loopback_egress().await;
1310        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(120));
1311
1312        let body: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
1313        let proxy_for_consumer = proxy.clone();
1314        let consumer = tokio::spawn(async move {
1315            let (_, payload) = sent.recv().await.expect("request datagram");
1316            let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1317            let fragments = response_payloads(DRONE, id, 200, &body);
1318            deliver(&proxy_for_consumer, &fragments[0]).await;
1319        });
1320
1321        let err = proxy
1322            .call(DRONE, RpcMethod::Get, b"/api/x", &[])
1323            .await
1324            .unwrap_err();
1325        consumer.await.unwrap();
1326        assert_eq!(
1327            err,
1328            RpcError::Incomplete {
1329                received: 1,
1330                total: 7
1331            }
1332        );
1333        assert_eq!(
1334            err.to_string(),
1335            "relay response incomplete: 1/7 fragments",
1336            "the operator must be able to tell a dropped fragment from silence"
1337        );
1338    }
1339
1340    #[tokio::test]
1341    async fn a_fragment_count_past_the_ceiling_completes_with_502() {
1342        let (egress, mut sent) = loopback_egress().await;
1343        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(400));
1344
1345        let proxy_for_consumer = proxy.clone();
1346        let consumer = tokio::spawn(async move {
1347            let (_, payload) = sent.recv().await.expect("request datagram");
1348            let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1349            proxy_for_consumer
1350                .dispatch_response(&aux_rpc::RpcResponse {
1351                    id,
1352                    sender: DRONE,
1353                    status: 200,
1354                    index: 0,
1355                    total: 65,
1356                    oti: 2,
1357                    body: b"aa",
1358                })
1359                .await;
1360        });
1361
1362        let result = proxy
1363            .call(DRONE, RpcMethod::Get, b"/api/x", &[])
1364            .await
1365            .expect("the caller must be completed, not left hanging");
1366        consumer.await.unwrap();
1367        assert_eq!(result.status, 502);
1368        assert_eq!(result.body, b"relay response geometry out of range");
1369        assert_eq!(proxy.stats().responses_out_of_range, 1);
1370    }
1371
1372    #[tokio::test]
1373    async fn a_transfer_length_past_the_ceiling_completes_with_502() {
1374        // `oti` is a number the radio handed us and RaptorQ sizes its matrices
1375        // from it. A corrupt one must be refused, not allocated for.
1376        let (egress, mut sent) = loopback_egress().await;
1377        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(400));
1378
1379        let proxy_for_consumer = proxy.clone();
1380        let consumer = tokio::spawn(async move {
1381            let (_, payload) = sent.recv().await.expect("request datagram");
1382            let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1383            proxy_for_consumer
1384                .dispatch_response(&aux_rpc::RpcResponse {
1385                    id,
1386                    sender: DRONE,
1387                    status: 200,
1388                    index: 0,
1389                    total: 2,
1390                    oti: u32::MAX,
1391                    body: b"aa",
1392                })
1393                .await;
1394        });
1395
1396        let result = proxy
1397            .call(DRONE, RpcMethod::Get, b"/api/x", &[])
1398            .await
1399            .expect("the caller must be completed, not left hanging");
1400        consumer.await.unwrap();
1401        assert_eq!(result.status, 502);
1402        assert_eq!(proxy.stats().responses_out_of_range, 1);
1403    }
1404
1405    #[tokio::test]
1406    async fn a_symbol_of_the_wrong_size_is_dropped_and_counted() {
1407        // Radio damage, or two ends on different builds. Feeding it to RaptorQ
1408        // would corrupt a matrix that assumes every symbol is one size.
1409        let (egress, mut sent) = loopback_egress().await;
1410        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(200));
1411
1412        let proxy_for_consumer = proxy.clone();
1413        let consumer = tokio::spawn(async move {
1414            let (_, payload) = sent.recv().await.expect("request datagram");
1415            let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1416            proxy_for_consumer
1417                .dispatch_response(&aux_rpc::RpcResponse {
1418                    id,
1419                    sender: DRONE,
1420                    status: 200,
1421                    index: 0,
1422                    total: 5,
1423                    oti: 64,
1424                    body: b"only-nine",
1425                })
1426                .await;
1427        });
1428
1429        let err = proxy
1430            .call(DRONE, RpcMethod::Get, b"/api/x", &[])
1431            .await
1432            .unwrap_err();
1433        consumer.await.unwrap();
1434        assert_eq!(
1435            err,
1436            RpcError::Incomplete {
1437                received: 0,
1438                total: 5
1439            }
1440        );
1441        assert_eq!(proxy.stats().fragments_bad_symbol, 1);
1442    }
1443
1444    #[tokio::test]
1445    async fn a_call_times_out_when_no_response_arrives() {
1446        let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1447        sock.connect(("127.0.0.1", 9u16)).await.unwrap();
1448        let egress = AuxEgress::connected_for_test(sock);
1449        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(80));
1450
1451        let started = std::time::Instant::now();
1452        let err = proxy
1453            .call(&[], RpcMethod::Get, b"/api/status", &[])
1454            .await
1455            .unwrap_err();
1456        assert_eq!(err, RpcError::Timeout);
1457        assert!(started.elapsed() < Duration::from_secs(1));
1458    }
1459
1460    #[tokio::test]
1461    async fn a_call_returns_send_error_when_egress_fails() {
1462        let egress =
1463            AuxEgress::with_timeout("/nonexistent/aux-cmd.sock", Duration::from_millis(40));
1464        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(200));
1465        let err = proxy
1466            .call(&[], RpcMethod::Get, b"/api/status", &[])
1467            .await
1468            .unwrap_err();
1469        assert!(
1470            matches!(err, RpcError::Send(_)),
1471            "expected Send, got {err:?}"
1472        );
1473    }
1474
1475    #[tokio::test]
1476    async fn a_body_past_the_relay_limit_is_refused_before_sending() {
1477        let (egress, _sent) = loopback_egress().await;
1478        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(200));
1479        let big_body = vec![b'A'; aux_rpc::MAX_REQUEST_BODY + 1];
1480        let err = proxy
1481            .call(&[], RpcMethod::Post, b"/api/x", &big_body)
1482            .await
1483            .unwrap_err();
1484        assert_eq!(err, RpcError::TooLarge);
1485    }
1486
1487    #[tokio::test]
1488    async fn a_body_larger_than_one_frame_goes_out_as_several_fragments() {
1489        let (egress, mut sent) = loopback_egress().await;
1490        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1491        let body = vec![b'B'; 4 * aux_mux::AUX_MAX_PAYLOAD];
1492        let expected = body.clone();
1493        let proxy_for_consumer = proxy.clone();
1494        let consumer = tokio::spawn(async move {
1495            let mut reassembler = aux_rpc::RequestReassembler::new();
1496            loop {
1497                let (_, payload) = sent.recv().await.expect("request fragment");
1498                let fragment = aux_rpc::decode_request_fragment(&payload).unwrap();
1499                assert!(fragment.total > 1);
1500                if let aux_rpc::ReassemblyOutcome::Complete(request) =
1501                    reassembler.push(&fragment, std::time::Instant::now())
1502                {
1503                    assert_eq!(request.method, RpcMethod::Patch);
1504                    assert_eq!(request.body, expected);
1505                    deliver_body(&proxy_for_consumer, DRONE, request.id, 200, b"ok").await;
1506                    return;
1507                }
1508            }
1509        });
1510        let result = proxy
1511            .call(DRONE, RpcMethod::Patch, b"/api/plugins/p/x/upload", &body)
1512            .await
1513            .expect("call must succeed");
1514        consumer.await.unwrap();
1515        assert_eq!(result.status, 200);
1516    }
1517
1518    #[tokio::test]
1519    async fn a_response_for_an_unknown_id_is_a_noop() {
1520        let (egress, _sent) = loopback_egress().await;
1521        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(50));
1522        for fragment in &response_payloads(DRONE, 9999, 200, b"orphan") {
1523            deliver(&proxy, fragment).await;
1524        }
1525        assert!(proxy.pending.lock().await.is_empty());
1526    }
1527
1528    #[tokio::test]
1529    async fn concurrent_calls_with_distinct_ids_match_their_own_responses() {
1530        let (egress, mut sent) = loopback_egress().await;
1531        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1532        let proxy_consumer = proxy.clone();
1533
1534        let consumer = tokio::spawn(async move {
1535            for _ in 0..3 {
1536                let (ch, payload) = sent.recv().await.expect("request datagram");
1537                assert_eq!(ch, AuxChannel::Request);
1538                let request = aux_rpc::decode_request_fragment(&payload).unwrap();
1539                let body = format!("{}", request.id).into_bytes();
1540                deliver_body(&proxy_consumer, DRONE, request.id, 200, &body).await;
1541            }
1542        });
1543
1544        let p1 = tokio::spawn({
1545            let proxy = proxy.clone();
1546            async move { proxy.call(&[], RpcMethod::Get, b"/api/a", &[]).await }
1547        });
1548        let p2 = tokio::spawn({
1549            let proxy = proxy.clone();
1550            async move { proxy.call(&[], RpcMethod::Get, b"/api/b", &[]).await }
1551        });
1552        let p3 = tokio::spawn({
1553            let proxy = proxy.clone();
1554            async move { proxy.call(&[], RpcMethod::Get, b"/api/c", &[]).await }
1555        });
1556
1557        let r1 = p1.await.unwrap().unwrap();
1558        let r2 = p2.await.unwrap().unwrap();
1559        let r3 = p3.await.unwrap().unwrap();
1560        consumer.await.unwrap();
1561
1562        assert_ne!(r1.body, r2.body);
1563        assert_ne!(r2.body, r3.body);
1564        assert_eq!(r1.status, 200);
1565        assert_eq!(r2.status, 200);
1566        assert_eq!(r3.status, 200);
1567    }
1568
1569    #[tokio::test]
1570    async fn reset_drains_pending_callers_with_503() {
1571        let (egress, _sent) = loopback_egress().await;
1572        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(60));
1573
1574        let proxy_call = proxy.clone();
1575        let call_handle =
1576            tokio::spawn(async move { proxy_call.call(&[], RpcMethod::Get, b"/api/x", &[]).await });
1577
1578        tokio::time::sleep(Duration::from_millis(20)).await;
1579        proxy.reset().await;
1580        let result = tokio::time::timeout(Duration::from_millis(500), call_handle)
1581            .await
1582            .expect("reset must unblock the caller within the bound")
1583            .unwrap();
1584        assert_eq!(result.unwrap().status, 503);
1585    }
1586
1587    #[tokio::test]
1588    async fn the_ingest_writes_and_the_listener_dispatches() {
1589        // End-to-end: proxy LISTENS, ingest CONNECTS, a Response written by
1590        // the ingest arrives at the listener and is dispatched to the pending
1591        // caller. The call's Request goes into a loopback void (no consumer
1592        // reading it), but the Response arrives via the IPC seam.
1593        let dir = tempfile::tempdir().unwrap();
1594        let sock_path = dir.path().join("aux-rpc-responses.sock");
1595
1596        // A loopback egress whose Request datagrams nobody reads — the call
1597        // succeeds on the Response side, not the Request side.
1598        let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1599        sock.connect(("127.0.0.1", 9u16)).await.unwrap();
1600        let egress = AuxEgress::connected_for_test(sock);
1601        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(500));
1602
1603        // Spawn the listener.
1604        let cancel = Arc::new(Notify::new());
1605        let listener_handle = proxy.spawn_response_listener(sock_path.clone(), cancel.clone());
1606
1607        // Give the listener time to bind.
1608        tokio::time::sleep(Duration::from_millis(20)).await;
1609
1610        // Start a call — it will send a Request into the void and wait.
1611        let proxy_call = proxy.clone();
1612        let call_handle = tokio::spawn(async move {
1613            proxy_call
1614                .call(&[], RpcMethod::Get, b"/api/pairing/info", &[])
1615                .await
1616        });
1617
1618        // Give the call time to register its pending entry.
1619        tokio::time::sleep(Duration::from_millis(20)).await;
1620
1621        // Write a matching Response via the ingest. The call's request id was
1622        // assigned by the proxy (starts at 1), so we sniff it from the
1623        // pending map.
1624        let id = pending_id(&proxy).await;
1625
1626        let ingest = AuxRpcResponseIngest::new(&sock_path);
1627        for payload in response_payloads(DRONE, id, 200, br#"{"ok":true}"#) {
1628            ingest.send(&payload).await;
1629        }
1630
1631        let result = tokio::time::timeout(Duration::from_secs(2), call_handle)
1632            .await
1633            .expect("the call must complete within the bound")
1634            .unwrap();
1635        assert_eq!(result.unwrap().status, 200);
1636
1637        cancel.notify_waiters();
1638        let _ = listener_handle.await;
1639    }
1640
1641    #[tokio::test]
1642    async fn an_undecodable_frame_does_not_poison_the_fragments_after_it() {
1643        // A 25-fragment response is 25 IPC frames. One damaged frame must cost
1644        // exactly that fragment, not every fragment behind it.
1645        let dir = tempfile::tempdir().unwrap();
1646        let sock_path = dir.path().join("aux-rpc-responses.sock");
1647
1648        let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1649        sock.connect(("127.0.0.1", 9u16)).await.unwrap();
1650        let egress = AuxEgress::connected_for_test(sock);
1651        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(2));
1652
1653        let cancel = Arc::new(Notify::new());
1654        let listener_handle = proxy.spawn_response_listener(sock_path.clone(), cancel.clone());
1655        tokio::time::sleep(Duration::from_millis(20)).await;
1656
1657        let proxy_call = proxy.clone();
1658        let call_handle =
1659            tokio::spawn(async move { proxy_call.call(&[], RpcMethod::Get, b"/api/x", &[]).await });
1660        tokio::time::sleep(Duration::from_millis(20)).await;
1661
1662        let id = pending_id(&proxy).await;
1663
1664        let body: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
1665        let fragments = response_payloads(DRONE, id, 200, &body);
1666        let ingest = AuxRpcResponseIngest::new(&sock_path);
1667        ingest.send(&fragments[0]).await;
1668        ingest.send(b"not-a-fragment").await;
1669        ingest.send(&fragments[1]).await;
1670
1671        let result = tokio::time::timeout(Duration::from_secs(3), call_handle)
1672            .await
1673            .expect("the call must complete within the bound")
1674            .unwrap();
1675        assert_eq!(result.unwrap().body, body);
1676
1677        cancel.notify_waiters();
1678        let _ = listener_handle.await;
1679    }
1680
1681    #[tokio::test]
1682    async fn a_lost_request_datagram_is_retransmitted_until_the_drone_answers() {
1683        let (egress, mut sent) = loopback_egress().await;
1684        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(3));
1685
1686        let proxy_for_consumer = proxy.clone();
1687        let consumer = tokio::spawn(async move {
1688            // The half-duplex radio swallowed the first two attempts while the
1689            // drone was mid video burst. The third is the one that lands.
1690            let (_, first) = sent.recv().await.expect("first attempt");
1691            let (_, second) = sent.recv().await.expect("first retransmit");
1692            let (_, third) = sent.recv().await.expect("second retransmit");
1693            assert_eq!(first, second, "a retransmit must be the identical datagram");
1694            assert_eq!(second, third);
1695            let request = aux_rpc::decode_request_fragment(&third).unwrap();
1696            deliver_body(
1697                &proxy_for_consumer,
1698                DRONE,
1699                request.id,
1700                200,
1701                b"late-but-answered",
1702            )
1703            .await;
1704        });
1705
1706        let result = proxy
1707            .call(DRONE, RpcMethod::Get, b"/api/version", &[])
1708            .await
1709            .expect("a retransmitted request must still complete");
1710        consumer.await.unwrap();
1711        assert_eq!(result.body, b"late-but-answered");
1712        assert!(
1713            proxy.stats().retransmits >= 2,
1714            "one datagram on a 20-40% loss lane is a coin flip; it must be resent"
1715        );
1716    }
1717
1718    #[tokio::test]
1719    async fn a_retransmit_that_could_not_be_sent_is_not_counted_as_one_that_was() {
1720        // The counter used to be taken before the send and the send's error
1721        // dropped, so a broken egress — a closed socket, a stopped transmitter
1722        // — read exactly like a healthy radio the far end simply was not
1723        // answering. That points a diagnosis at the wrong half of the link.
1724        // A UDP socket connected to an unbound loopback port: a send leaves,
1725        // and the kernel returns the ICMP port-unreachable on the next one. The
1726        // egress re-opens after each failure, so with the transmitter down the
1727        // retransmits alternate between one that leaves (a real retransmit)
1728        // and one that cannot (a send error).
1729        let sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1730        let dead = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
1731        let dead_addr = dead.local_addr().unwrap();
1732        drop(dead);
1733        sock.connect(dead_addr).await.unwrap();
1734        let egress = AuxEgress::connected_for_test(sock);
1735        let proxy = AuxRpcProxy::new(egress);
1736
1737        let r = tokio::time::timeout(
1738            RPC_DEFAULT_TIMEOUT + Duration::from_secs(2),
1739            proxy.call(&[], RpcMethod::Get, b"/api/pairing/info", &[]),
1740        )
1741        .await;
1742        assert!(r.is_ok(), "the call never terminated");
1743
1744        let stats = proxy.stats();
1745        assert!(
1746            stats.retransmit_send_errors > 0,
1747            "a broken egress must be visible as a send failure, not as silence \
1748             from the far end: {stats:?}"
1749        );
1750    }
1751
1752    #[tokio::test]
1753    async fn a_response_that_has_started_arriving_stops_the_retransmits() {
1754        let (egress, mut sent) = loopback_egress().await;
1755        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(600));
1756
1757        let body: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
1758        let proxy_for_consumer = proxy.clone();
1759        let consumer = tokio::spawn(async move {
1760            let (_, payload) = sent.recv().await.expect("request datagram");
1761            let id = aux_rpc::decode_request_fragment(&payload).unwrap().id;
1762            // One symbol of seven lands; the rest never do. The call still
1763            // fails Incomplete, but the request demonstrably got through, so
1764            // resending it would only duplicate the drone's work.
1765            let fragments = response_payloads(DRONE, id, 200, &body);
1766            deliver(&proxy_for_consumer, &fragments[0]).await;
1767            tokio::time::sleep(Duration::from_millis(450)).await;
1768            sent.try_recv().is_ok()
1769        });
1770
1771        let err = proxy
1772            .call(DRONE, RpcMethod::Get, b"/api/x", &[])
1773            .await
1774            .unwrap_err();
1775        let resent = consumer.await.unwrap();
1776        assert_eq!(
1777            err,
1778            RpcError::Incomplete {
1779                received: 1,
1780                total: 7
1781            }
1782        );
1783        assert!(!resent, "no second Request datagram may leave the ground");
1784        assert_eq!(proxy.stats().retransmits, 0);
1785    }
1786
1787    #[tokio::test]
1788    async fn a_saturated_proxy_sheds_load_instead_of_queueing_radio_work() {
1789        let (egress, _sent) = loopback_egress().await;
1790        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_millis(200));
1791        {
1792            let mut pending = proxy.pending.lock().await;
1793            for id in 0..MAX_PENDING_CALLS as u32 {
1794                let (tx, _rx) = oneshot::channel::<RpcResponseOwned>();
1795                pending.insert(
1796                    id,
1797                    PendingCall {
1798                        sender: tx,
1799                        expected_sender: None,
1800                        assembly: None,
1801                        response_started: false,
1802                    },
1803                );
1804            }
1805        }
1806
1807        let err = proxy
1808            .call(&[], RpcMethod::Get, b"/api/x", &[])
1809            .await
1810            .unwrap_err();
1811        assert_eq!(err, RpcError::Busy);
1812        assert_eq!(err.to_string(), "relay proxy has too many calls in flight");
1813        assert_eq!(proxy.stats().calls_busy, 1);
1814    }
1815
1816    #[tokio::test]
1817    async fn a_cancelled_call_does_not_leak_its_pending_entry() {
1818        // An axum handler whose client disconnects drops the call future
1819        // mid-flight. Post-await cleanup never runs on that path.
1820        let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
1821        sock.connect(("127.0.0.1", 9u16)).await.unwrap();
1822        let egress = AuxEgress::connected_for_test(sock);
1823        let proxy = AuxRpcProxy::with_timeout(egress, Duration::from_secs(60));
1824
1825        let proxy_call = proxy.clone();
1826        let handle =
1827            tokio::spawn(async move { proxy_call.call(&[], RpcMethod::Get, b"/api/x", &[]).await });
1828        tokio::time::sleep(Duration::from_millis(50)).await;
1829        assert_eq!(proxy.pending.lock().await.len(), 1);
1830
1831        handle.abort();
1832        // The guard schedules its removal rather than taking the async mutex
1833        // inline, so give the spawned cleanup a turn.
1834        tokio::time::sleep(Duration::from_millis(80)).await;
1835        assert!(
1836            proxy.pending.lock().await.is_empty(),
1837            "a dropped call future must not leak its sender and assembly forever"
1838        );
1839        assert_eq!(proxy.stats().pending_now, 0);
1840    }
1841
1842    #[tokio::test]
1843    async fn the_request_id_seed_is_not_a_fixed_one() {
1844        // Two proxies standing in for two process starts: if both began at 1, a
1845        // stale in-flight response from before a restart would silently
1846        // complete a different new caller.
1847        let (egress_a, _a) = loopback_egress().await;
1848        let (egress_b, _b) = loopback_egress().await;
1849        let a = AuxRpcProxy::with_timeout(egress_a, Duration::from_millis(50));
1850        let b = AuxRpcProxy::with_timeout(egress_b, Duration::from_millis(50));
1851        assert_ne!(a.next_id.load(Ordering::Relaxed), 1);
1852        assert_ne!(b.next_id.load(Ordering::Relaxed), 1);
1853    }
1854}