Skip to main content

hyperactor/
gateway.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Connectivity layer for Hyperactor procs.
10//!
11//! A proc owns actor lifecycle and mailboxes; a [`Gateway`] owns how that proc
12//! is reached. Attached procs derive advertised addresses from the gateway's
13//! default location, receive inbound traffic through the gateway, and forward
14//! outbound traffic directly through the gateway.
15//!
16//! Gateways route [`Location`]s: if the inbound message is source routed,
17//! the gateway looks up the next hop via its peer table and forwards
18//! accordingly; otherwise the message is delivered if the proc is attached
19//! to the gateway.
20//!
21//! Messages with no local proc or peer route are forwarded through the
22//! default forwarder.
23
24use std::collections::HashMap;
25use std::fmt;
26use std::sync::Arc;
27use std::sync::OnceLock;
28use std::sync::RwLock;
29use std::sync::Weak;
30use std::sync::atomic::AtomicU64;
31use std::sync::atomic::Ordering as AtomicOrdering;
32use std::time::Duration;
33
34use async_trait::async_trait;
35use futures::StreamExt as _;
36use serde::Deserialize;
37use serde::Serialize;
38use tokio::sync::watch;
39use tokio::task::JoinSet;
40use tokio_util::sync::CancellationToken;
41
42use crate::Location;
43use crate::PortAddr;
44use crate::ProcAddr;
45use crate::ProcId;
46use crate::channel;
47use crate::channel::ChannelAddr;
48use crate::channel::ChannelError;
49use crate::channel::ChannelTransport;
50use crate::channel::Rx;
51use crate::channel::Tx;
52use crate::id::Uid;
53use crate::mailbox::BoxedMailboxSender;
54use crate::mailbox::DeliveryFailure;
55use crate::mailbox::DialMailboxRouter;
56use crate::mailbox::IntoBoxedMailboxSender as _;
57use crate::mailbox::MailboxClient;
58use crate::mailbox::MailboxSender as _;
59use crate::mailbox::MailboxServer as _;
60use crate::mailbox::MailboxServerError;
61use crate::mailbox::MailboxServerHandle;
62use crate::mailbox::MessageEnvelope;
63use crate::mailbox::PortHandle;
64use crate::mailbox::TransportFailure;
65use crate::mailbox::TransportFailureReason;
66use crate::mailbox::Undeliverable;
67use crate::mailbox::UndeliverableReason;
68use crate::mailbox::UnroutableMailboxSender;
69use crate::proc::Proc;
70use crate::proc::WeakProc;
71
72// ---------------------------------------------------------------------------
73// Gateway attach protocol
74// ---------------------------------------------------------------------------
75//
76// Gateways are the connectivity layer; all on-the-wire attach is
77// gateway-to-gateway. A client gateway dials a peer's accept endpoint
78// and sends [`AttachRequest`] carrying its own uid; the peer replies
79// with [`AttachAck`], either accepting with the via location through
80// which the client is reachable or rejecting with a handshake error.
81// After an accepted handshake, both ends serve regular
82// [`MessageEnvelope`] traffic on the same duplex.
83
84/// Label used for attach-control envelopes.
85const ATTACH_CONTROL_LABEL: &str = "attach";
86
87/// Upper bound on how long [`Gateway::serve_via`] waits for the peer's
88/// [`AttachAck`]. A peer that accepts the connection but never replies
89/// (hung, misconfigured, or running an older protocol) would otherwise
90/// block the caller — typically Python `bootstrap_host` — indefinitely.
91const ATTACH_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
92
93/// First message a gateway sends when attaching to a peer. The peer
94/// records `uid` in its [`peers`] table; afterwards, any
95/// destination whose outermost location hop is `Via(uid, ...)` is
96/// peeled by the peer and forwarded back over the duplex.
97#[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
98pub(crate) struct AttachRequest {
99    /// The attaching gateway's uid.
100    pub(crate) uid: Uid,
101}
102wirevalue::register_type!(AttachRequest);
103
104/// Acknowledgement returned by a peer gateway during attach.
105#[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
106pub(crate) enum AttachAck {
107    /// Attach succeeded. Carries the via location the client should advertise
108    /// as its [`default_location`] — `Via(client_uid, peer_default_location)`.
109    Accepted {
110        /// The location through which the client is now reachable.
111        location: Location,
112    },
113    /// Attach failed before the peer registered this connection.
114    Rejected {
115        /// Human-readable rejection reason.
116        reason: String,
117    },
118}
119wirevalue::register_type!(AttachAck);
120
121/// Wire protocol for the peer → client direction on a duplex attach
122/// connection.
123#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
124#[expect(
125    clippy::large_enum_variant,
126    reason = "wire-protocol enum; boxing Envelope would ripple through channel/networking destructure sites"
127)]
128pub(crate) enum AttachWire {
129    /// First message: the peer accepts or rejects the attach.
130    Ack(AttachAck),
131    /// Subsequent messages: routed envelopes.
132    Envelope(MessageEnvelope),
133}
134wirevalue::register_type!(AttachWire);
135
136/// [`Rx<MessageEnvelope>`](channel::Rx) adapter that unwraps
137/// [`AttachWire::Envelope`] from a duplex receiver. Errors if the
138/// peer sends another [`AttachWire::Ack`] after the handshake.
139pub(crate) struct AttachRx(pub(crate) channel::duplex::DuplexRx<AttachWire>);
140
141#[async_trait]
142impl channel::Rx<MessageEnvelope> for AttachRx {
143    async fn recv(&mut self) -> Result<MessageEnvelope, ChannelError> {
144        match self.0.recv().await? {
145            AttachWire::Envelope(envelope) => Ok(envelope),
146            AttachWire::Ack(_) => Err(ChannelError::Other(anyhow::anyhow!(
147                "unexpected attach ack after handshake"
148            ))),
149        }
150    }
151
152    fn addr(&self) -> ChannelAddr {
153        self.0.addr()
154    }
155
156    async fn join(self) {
157        self.0.join().await
158    }
159}
160
161/// [`Tx<MessageEnvelope>`](channel::Tx) adapter that wraps outbound
162/// [`MessageEnvelope`]s in [`AttachWire::Envelope`] before posting to
163/// a peer's [`DuplexTx<AttachWire>`]. Used through [`MailboxClient`]
164/// on the accept side so normal sender flushing semantics apply.
165#[derive(Clone)]
166pub(crate) struct AttachTx(pub(crate) channel::duplex::DuplexTx<AttachWire>);
167
168#[async_trait]
169impl channel::Tx<MessageEnvelope> for AttachTx {
170    fn do_post(
171        &self,
172        envelope: MessageEnvelope,
173        completion: channel::CompletionSink<MessageEnvelope>,
174    ) {
175        let completion = completion.contramap_rejected(
176            |channel::SendError {
177                 error,
178                 message,
179                 reason,
180             }| {
181                let AttachWire::Envelope(envelope) = message else {
182                    return None;
183                };
184                Some(channel::SendError {
185                    error,
186                    message: envelope,
187                    reason,
188                })
189            },
190        );
191        self.0.do_post(AttachWire::Envelope(envelope), completion);
192    }
193
194    fn addr(&self) -> ChannelAddr {
195        self.0.addr()
196    }
197
198    fn status(&self) -> &watch::Receiver<channel::TxStatus> {
199        self.0.status()
200    }
201}
202
203struct PreboundAcceptServer {
204    inner: channel::duplex::DuplexServer<MessageEnvelope, AttachWire>,
205}
206
207impl PreboundAcceptServer {
208    fn duplex(
209        addr: ChannelAddr,
210        listener: Option<std::net::TcpListener>,
211    ) -> Result<Self, channel::ServerError> {
212        let inner = channel::duplex::serve::<MessageEnvelope, AttachWire>(addr, listener)?;
213        Ok(Self { inner })
214    }
215
216    fn addr(&self) -> &ChannelAddr {
217        self.inner.addr()
218    }
219}
220
221/// Serialize a control payload into a placeholder [`MessageEnvelope`]
222/// suitable for posting on a duplex client→peer channel.
223///
224/// Sender/dest ids are placeholders the peer consumes without routing;
225/// `return_undeliverable` is cleared so an envelope that ever escapes
226/// into the forwarder is dropped rather than bounced to the fake
227/// sender.
228fn build_control_envelope<T>(payload: &T) -> anyhow::Result<MessageEnvelope>
229where
230    T: serde::Serialize + typeuri::Named,
231{
232    let signal_actor_id = crate::ActorAddr::root(
233        ProcAddr::singleton(
234            ChannelAddr::any(channel::ChannelTransport::Local),
235            ATTACH_CONTROL_LABEL,
236        ),
237        crate::id::Label::strip(ATTACH_CONTROL_LABEL),
238    );
239    let signal_port = signal_actor_id.port_addr(crate::port::Port::from(0u64));
240    let mut envelope =
241        MessageEnvelope::serialize(signal_actor_id, signal_port, payload, Default::default())?;
242    envelope.set_return_undeliverable(false);
243    Ok(envelope)
244}
245
246/// Connectivity boundary for one or more procs.
247#[derive(Clone)]
248pub struct Gateway {
249    inner: Arc<GatewayState>,
250}
251
252// This explicit impl is only a compiler performance optimization. It is safe
253// to delete this module and cfg and let Rust derive the auto traits; see proc.rs.
254#[cfg(not(hyperactor_verify_auto_traits))]
255mod _send_sync_shortcut {
256    use super::*;
257
258    // SAFETY: the verification build structurally checks this bound.
259    unsafe impl Send for GatewayState {}
260    // SAFETY: the verification build structurally checks this bound.
261    unsafe impl Sync for GatewayState {}
262}
263
264const _: fn() = || {
265    fn assert<T: Send + Sync + ?Sized>() {}
266    assert::<GatewayState>();
267};
268
269/// Handle returned by [`Gateway::attach_proc`] that detaches the proc
270/// (removing its local-delivery registration) when dropped. This is the
271/// sole remover of the entry; it runs from `ProcState::drop`, so by the
272/// time it fires the proc's [`WeakProc`] no longer upgrades. Drop is a
273/// no-op if the gateway has already been dropped.
274///
275/// Removal is identity-guarded: it removes the entry only if the slot
276/// still holds *this* proc's registration. If a same-id proc was rebuilt
277/// after ours died, [`Gateway::attach_proc`] replaced our dead entry
278/// with the new proc's, so the slot is no longer ours — we leave it
279/// untouched rather than evict the successor's live registration.
280#[must_use = "dropping the AttachedProcGuard immediately detaches the proc"]
281pub struct AttachedProcGuard {
282    gateway: Weak<GatewayState>,
283    proc_id: ProcId,
284    weak: WeakProc,
285}
286
287impl fmt::Debug for AttachedProcGuard {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.debug_struct("AttachedProcGuard")
290            .field("proc_id", &self.proc_id)
291            .finish()
292    }
293}
294
295impl Drop for AttachedProcGuard {
296    fn drop(&mut self) {
297        let Some(state) = self.gateway.upgrade() else {
298            return;
299        };
300        let mut procs = state.procs.write().unwrap();
301        if procs
302            .get(&self.proc_id)
303            .is_some_and(|weak| weak.ptr_eq(&self.weak))
304        {
305            procs.remove(&self.proc_id);
306        }
307    }
308}
309
310/// Handle returned by [`Gateway::attach_peer`] that removes the peer
311/// entry from the gateway's `peers` map when dropped.
312#[must_use = "dropping the PeerAttachGuard immediately removes the peer entry"]
313pub struct PeerAttachGuard {
314    gateway: Weak<GatewayState>,
315    uid: Uid,
316}
317
318impl fmt::Debug for PeerAttachGuard {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        f.debug_struct("PeerAttachGuard")
321            .field("uid", &self.uid)
322            .finish()
323    }
324}
325
326impl Drop for PeerAttachGuard {
327    fn drop(&mut self) {
328        let Some(state) = self.gateway.upgrade() else {
329            return;
330        };
331        state.peers.write().unwrap().remove(&self.uid);
332    }
333}
334
335/// Error returned by [`Gateway::attach_peer`] when a peer is already
336/// attached under the given uid.
337#[derive(Debug, thiserror::Error)]
338#[error("gateway already has a via peer with uid {uid}")]
339pub struct PeerAttachError {
340    /// The uid that was already registered.
341    pub uid: Uid,
342}
343
344/// Original routing inputs for a gateway. Mutate through [`Routing::mutate`]
345/// so [`Routing`] can recompute its derived fields afterward.
346struct RoutingState {
347    /// The location to use when no server is active.
348    fallback_location: Location,
349
350    /// The forwarder configured when the gateway was created.
351    base_forwarder: BoxedMailboxSender,
352
353    /// Active normal serves and `serve_via` sessions in start order.
354    active_serves: Vec<ActiveServe>,
355}
356
357/// Routing state for a gateway. Holds original inputs plus derived fields
358/// (`default_location`, `local_delivery_locations`, and `forwarder`) behind one
359/// lock so advertised reachability and outbound routing stay synchronized.
360struct Routing {
361    state: RoutingState,
362
363    /// The advertised location for newly bound refs. The newest active
364    /// normal serve or [`Gateway::serve_via`] session takes precedence.
365    default_location: Location,
366
367    /// Cached locations that may receive local delivery for attached procs.
368    local_delivery_locations: Arc<[Location]>,
369
370    /// Sender used to forward messages whose destination is neither
371    /// an attached proc nor matched by [`peers`]. The newest active
372    /// [`Gateway::serve_via`] session supplies this; otherwise this is
373    /// `base_forwarder`.
374    forwarder: BoxedMailboxSender,
375}
376
377impl Routing {
378    fn new(fallback_location: Location, base_forwarder: BoxedMailboxSender) -> Self {
379        Self {
380            state: RoutingState {
381                fallback_location: fallback_location.clone(),
382                base_forwarder: base_forwarder.clone(),
383                active_serves: Vec::new(),
384            },
385            default_location: fallback_location.clone(),
386            local_delivery_locations: vec![fallback_location].into(),
387            forwarder: base_forwarder,
388        }
389    }
390
391    fn mutate(&mut self, update: impl FnOnce(&mut RoutingState)) {
392        update(&mut self.state);
393        self.recompute();
394    }
395
396    fn recompute(&mut self) {
397        self.default_location = self
398            .state
399            .active_serves
400            .last()
401            .map(|serve| serve.location.clone())
402            .unwrap_or_else(|| self.state.fallback_location.clone());
403        self.local_delivery_locations = if self.state.active_serves.is_empty() {
404            vec![self.default_location.clone()].into()
405        } else {
406            self.state
407                .active_serves
408                .iter()
409                .map(|serve| serve.location.clone())
410                .collect::<Vec<_>>()
411                .into()
412        };
413        self.forwarder = self
414            .state
415            .active_serves
416            .iter()
417            .rev()
418            .find_map(|serve| match &serve.kind {
419                ActiveServeKind::Server => None,
420                ActiveServeKind::Via { forwarder } => Some(forwarder.clone()),
421            })
422            .unwrap_or_else(|| self.state.base_forwarder.clone());
423    }
424}
425
426#[derive(Clone, Copy, Debug, Eq, PartialEq)]
427struct ServeId(u64);
428
429struct ActiveServe {
430    id: ServeId,
431    location: Location,
432    kind: ActiveServeKind,
433}
434
435enum ActiveServeKind {
436    Server,
437    Via { forwarder: BoxedMailboxSender },
438}
439
440struct GatewayState {
441    /// A random, stable identifier for this gateway. It is just a
442    /// routing key in peers' tables: peers route messages
443    /// back through this gateway by referencing its uid in a
444    /// [`Location::Via`] hop. We mint a uid (rather than reuse an
445    /// existing key) mainly so the entry can carry a meaningful label.
446    uid: Uid,
447
448    /// Outbound routing state, held under one lock so the advertised
449    /// location and the forwarder transition together. Starting or
450    /// stopping a serve updates both in one critical section, so
451    /// concurrent readers never observe a half-updated pair.
452    routing: RwLock<Routing>,
453
454    /// Local procs registered with this gateway, keyed by proc id.
455    /// Values hold weak proc references for in-process delivery.
456    procs: RwLock<HashMap<ProcId, WeakProc>>,
457
458    /// Monotonic id source for active serve handles.
459    next_serve_id: AtomicU64,
460
461    /// Senders to gateways that have attached *to* this one. Each key
462    /// is the attaching gateway's uid; values are senders that put
463    /// envelopes back onto the duplex toward that gateway. Source
464    /// routes (`Location::Via(uid, ...)`) consult this table to peel
465    /// the outermost hop and forward.
466    peers: RwLock<HashMap<Uid, BoxedMailboxSender>>,
467}
468
469impl Gateway {
470    /// Create a fresh unserved gateway with dial-based forwarding.
471    pub fn new() -> Self {
472        Self::configured(
473            channel::reserve_local_addr().into(),
474            DialMailboxRouter::new().into_boxed(),
475        )
476    }
477
478    /// Create a fresh unserved local-only gateway.
479    pub fn isolated() -> Self {
480        Self::configured(
481            channel::reserve_local_addr().into(),
482            BoxedMailboxSender::new(UnroutableMailboxSender),
483        )
484    }
485
486    /// Return the process-wide global gateway.
487    pub fn global() -> &'static Self {
488        static GLOBAL_GATEWAY: OnceLock<Gateway> = OnceLock::new();
489        GLOBAL_GATEWAY.get_or_init(Self::new)
490    }
491
492    /// Return the gateway for the current execution context.
493    ///
494    /// This is the gateway attached to [`Proc::current()`].
495    pub fn current() -> Self {
496        Proc::current().gateway()
497    }
498
499    /// Create a gateway with an explicit default advertised location
500    /// and outbound forwarder. Inbound traffic for destinations that
501    /// don't match a bound proc, route, or via peer is handed off to
502    /// `forwarder`.
503    pub(crate) fn configured(default_location: Location, forwarder: BoxedMailboxSender) -> Self {
504        Self {
505            inner: Arc::new(GatewayState {
506                uid: Uid::anonymous(),
507                routing: RwLock::new(Routing::new(default_location, forwarder.clone())),
508                procs: RwLock::new(HashMap::new()),
509                next_serve_id: AtomicU64::new(1),
510                peers: RwLock::new(HashMap::new()),
511            }),
512        }
513    }
514
515    /// This gateway's stable uid. Peers route messages back to procs
516    /// attached here by addressing them as
517    /// `Location::Via(this_uid, inner)`.
518    pub fn uid(&self) -> &Uid {
519        &self.inner.uid
520    }
521
522    /// The gateway's default advertised location.
523    pub fn default_location(&self) -> Location {
524        self.inner.routing.read().unwrap().default_location.clone()
525    }
526
527    /// The outbound forwarder. Inbound traffic for destinations that
528    /// don't match a bound proc, route, or via peer is handed off to
529    /// this sender.
530    pub fn forwarder(&self) -> BoxedMailboxSender {
531        self.inner.routing.read().unwrap().forwarder.clone()
532    }
533
534    /// Set the gateway's fallback advertised location.
535    ///
536    /// If any serves are active, the newest active normal serve or
537    /// [`Gateway::serve_via`] session remains the advertised default. This
538    /// location is used once all active serves stop.
539    pub fn set_default_location(&self, location: Location) {
540        let mut routing = self.inner.routing.write().unwrap();
541        routing.mutate(|routing| routing.fallback_location = location);
542    }
543
544    /// Attach a proc to this gateway, establishing the two-way
545    /// relationship between them: the gateway can deliver inbound
546    /// traffic directly to the proc's muxer, and the proc routes its
547    /// egress through the gateway.
548    ///
549    /// The gateway delivers messages addressed to this id directly to
550    /// the muxer when [`Proc::is_local_delivery_target`] holds;
551    /// otherwise routing continues to the appropriate peer or
552    /// forwarder.
553    ///
554    /// Internal-only: only [`Proc`] construction calls this, and the
555    /// resulting [`AttachedProcGuard`] is held inside the proc itself
556    /// so the proc's lifetime drives detachment. The public Gateway
557    /// API exposes gateway connectivity via [`Gateway::attach`] (an
558    /// in-process bidirectional bind), [`Gateway::attach_peer`] (a
559    /// sender-based via entry for a peer gateway uid), and
560    /// [`Gateway::serve_via`] (a duplex-attach connection to a remote
561    /// gateway). Hosts register spawned child proc gateways with
562    /// [`Gateway::attach_peer`].
563    ///
564    /// Panics if a live proc with the same id is already attached. A
565    /// dead entry whose [`WeakProc`] has been dropped is replaced
566    /// silently.
567    pub(crate) fn attach_proc(&self, proc: &Proc) -> AttachedProcGuard {
568        let proc_id = proc.proc_id().clone();
569        let weak = proc.downgrade();
570        let mut procs = self.inner.procs.write().unwrap();
571        let duplicate_live_proc = procs.get(&proc_id).and_then(WeakProc::upgrade).is_some();
572        if duplicate_live_proc {
573            drop(procs);
574            panic!("gateway already has a proc attached with id {}", proc_id)
575        }
576        procs.insert(proc_id.clone(), weak.clone());
577        AttachedProcGuard {
578            gateway: Arc::downgrade(&self.inner),
579            proc_id,
580            weak,
581        }
582    }
583
584    /// Register a gateway peer that is reachable through `sender`.
585    /// Messages whose destination location has an outermost
586    /// [`Location::Via`] hop carrying `uid` are forwarded through
587    /// `sender` after peeling the hop. The returned guard removes the
588    /// entry on drop.
589    ///
590    /// Used by [`Gateway::attach`] and the duplex accept loop in
591    /// [`Gateway::serve_duplex`]. Hosts also use this for spawned
592    /// child proc gateways.
593    ///
594    /// Returns [`PeerAttachError`] if a peer with the same uid is
595    /// already attached.
596    pub fn attach_peer(
597        &self,
598        uid: Uid,
599        sender: BoxedMailboxSender,
600    ) -> Result<PeerAttachGuard, PeerAttachError> {
601        let mut via = self.inner.peers.write().unwrap();
602        if via.contains_key(&uid) {
603            return Err(PeerAttachError { uid });
604        }
605        via.insert(uid.clone(), sender);
606        Ok(PeerAttachGuard {
607            gateway: Arc::downgrade(&self.inner),
608            uid,
609        })
610    }
611
612    pub(crate) fn serve_rx(
613        &self,
614        rx: impl channel::Rx<MessageEnvelope> + Send + 'static,
615    ) -> MailboxServerHandle {
616        Arc::downgrade(&self.inner).serve(rx)
617    }
618
619    /// Serve this gateway on the provided channel address.
620    ///
621    /// When serving the first local [`ChannelAddr::any`] address, the gateway
622    /// binds the local address that was reserved when the gateway was created.
623    /// Local reservation is separate from local binding so a gateway can have a
624    /// stable location before it has a runtime available to run a server.
625    /// Later local `any` serves allocate fresh local ports, so the gateway can
626    /// have multiple active local servers.
627    ///
628    /// Serving updates the gateway's default location to the newly served
629    /// address. When that server stops, the default location falls back to the
630    /// previous active normal serve or `serve_via` session, or to the reserved
631    /// fallback location when no serve remains.
632    pub fn serve(&self, addr: ChannelAddr) -> Result<GatewayServeHandle, ChannelError> {
633        let (serve_id, handle) = self.serve_inner(addr)?;
634        Ok(GatewayServeHandle {
635            gateway: self.clone(),
636            handle,
637            stopped: false,
638            kind: HandleKind::Serve {
639                serve_id: Some(serve_id),
640            },
641        })
642    }
643
644    /// Open a duplex endpoint that accepts both regular inbound
645    /// envelope traffic and gateway-attach handshakes from peers.
646    ///
647    /// On each connection the first message determines the branch:
648    /// * an [`AttachRequest`] control envelope enters the attach
649    ///   branch — this gateway records the peer's uid in `peers` (so
650    ///   source routes addressed to `Via(peer_uid, ...)` flow back
651    ///   through the duplex), replies with [`AttachAck::Accepted`]
652    ///   carrying `Via(peer_uid, default_location)`, and serves
653    ///   remaining traffic from the duplex into this gateway. If the
654    ///   peer cannot be registered, it replies with
655    ///   [`AttachAck::Rejected`] and closes the connection.
656    /// * a regular [`MessageEnvelope`] enters the inbound branch and
657    ///   is served straight through.
658    ///
659    /// Returns a [`GatewayServeHandle`] of kind `ServeDuplex`. The
660    /// accept loop respects `.stop("reason")`.
661    ///
662    /// Errors if `addr`'s transport cannot carry the duplex protocol
663    /// (e.g. local transport). Callers that may be handed a non-duplex
664    /// address should branch on
665    /// [`ChannelTransport::supports_duplex`] and use [`serve`] instead.
666    pub fn serve_duplex(&self, addr: ChannelAddr) -> Result<GatewayServeHandle, ChannelError> {
667        self.serve_duplex_with_listener(addr, None)
668    }
669
670    fn serve_duplex_with_listener(
671        &self,
672        addr: ChannelAddr,
673        listener: Option<std::net::TcpListener>,
674    ) -> Result<GatewayServeHandle, ChannelError> {
675        if !addr.transport().supports_duplex() {
676            return Err(ChannelError::Other(anyhow::anyhow!(
677                "serve_duplex requires a duplex-capable transport, but {addr} does not support duplex"
678            )));
679        }
680        // A gateway duplex endpoint doubles as a relay: peers attach over
681        // duplex (see `serve_via`), while a third party with no peer
682        // relationship reaches via-addressed refs by dialing the raw
683        // address with a plain *simplex* channel. Now that the link layer
684        // distinguishes the two protocols on the wire, a strict duplex
685        // server would reject those simplex dials. Serve net endpoints
686        // through the mux instead, so one address accepts both protocols:
687        // simplex posts are dispatched into the gateway, duplex attaches
688        // run the shared `AttachWire` accept loop. The in-process `Local`
689        // transport is not a kernel socket and cannot be muxed, so it
690        // keeps the plain duplex accept path.
691        if addr.transport().is_net() {
692            return self.serve_mux_with_listener(addr, listener);
693        }
694        let server = PreboundAcceptServer::duplex(addr, listener)
695            .map_err(|e| ChannelError::Other(anyhow::anyhow!("{e}")))?;
696        Ok(self.serve_duplex_with_server(server))
697    }
698
699    fn serve_duplex_with_server(&self, server: PreboundAcceptServer) -> GatewayServeHandle {
700        let bound_addr = server.addr().clone();
701        let location = Location::from(bound_addr.clone());
702        // The accept loop and its per-connection tasks are driven by a
703        // single cancellation token. `stop()` cancels it; dropping the
704        // handle without stopping leaves the token uncancelled, so the
705        // loop keeps running — matching the [`MailboxServerHandle`]
706        // detach-on-drop convention.
707        let cancel_token = CancellationToken::new();
708        let loop_token = cancel_token.clone();
709        let gateway = self.clone();
710        let inner = server.inner;
711        let serve_id = self.add_server(location);
712        let join_handle = tokio::spawn(async move {
713            duplex_accept_loop(inner, bound_addr, gateway, loop_token).await;
714            Ok::<(), MailboxServerError>(())
715        });
716        // The inner handle exists only so `join()`/`await` can await the
717        // accept-loop task; its stop watch is never signaled, because
718        // stop flows through `cancel_token` instead.
719        let (idle_stop_tx, _idle_stop_rx) = watch::channel(false);
720        let handle = MailboxServerHandle::from_parts(join_handle, idle_stop_tx);
721        GatewayServeHandle {
722            gateway: self.clone(),
723            handle,
724            stopped: false,
725            kind: HandleKind::ServeDuplex {
726                serve_id: Some(serve_id),
727                cancel_token,
728            },
729        }
730    }
731
732    /// Serve this gateway on `addr` (optionally with a pre-bound TCP
733    /// listener) using a *muxed* listener: simplex clients (dialed via
734    /// [`channel::dial`]) and duplex attach clients (dialed via
735    /// [`channel::duplex::dial`]) share one address, demultiplexed at
736    /// the link layer by [`channel::serve_mux`].
737    ///
738    /// Simplex traffic is served straight into this gateway; duplex
739    /// connections run the *same* [`AttachWire`] accept path as
740    /// [`serve_duplex`](Self::serve_duplex) — i.e. there is a single
741    /// attach protocol regardless of whether a frontend is muxed or a
742    /// plain duplex endpoint. Requires a net transport (`serve_mux`
743    /// rejects non-net addresses).
744    ///
745    /// Like [`serve_with_listener`](Self::serve_with_listener), this
746    /// registers the bound address as an active serve location (so the
747    /// gateway delivers frontend-addressed traffic in-process and adopts
748    /// it as the default location).
749    pub fn serve_mux_with_listener(
750        &self,
751        addr: ChannelAddr,
752        listener: Option<std::net::TcpListener>,
753    ) -> Result<GatewayServeHandle, ChannelError> {
754        let mux =
755            channel::serve_mux::<MessageEnvelope, MessageEnvelope, AttachWire>(addr, listener)?;
756        let bound_addr = mux.addr().clone();
757        let simplex_gateway = self.clone();
758        let duplex_gateway = self.clone();
759        let duplex_addr = bound_addr.clone();
760        let raw = mux.serve(
761            move |rx| simplex_gateway.serve(rx),
762            move |duplex_server, mut stop_rx| async move {
763                // The mux signals shutdown through a watch channel, but
764                // `duplex_accept_loop` drains on a `CancellationToken`.
765                // Bridge the two: cancel only on an explicit stop, and
766                // pend (leaving the token uncancelled) if the watch
767                // sender is dropped without stopping, matching the
768                // detach-on-drop convention of the serve handles.
769                let cancel_token = CancellationToken::new();
770                let loop_token = cancel_token.clone();
771                tokio::spawn(async move {
772                    if stop_rx.wait_for(|stopped| *stopped).await.is_ok() {
773                        cancel_token.cancel();
774                    }
775                });
776                duplex_accept_loop(duplex_server, duplex_addr, duplex_gateway, loop_token).await;
777            },
778        );
779        Ok(GatewayServeHandle::from_mailbox_handle(
780            self.clone(),
781            bound_addr,
782            raw,
783        ))
784    }
785
786    /// Serve this gateway on `addr`, optionally using an already-bound
787    /// listener.
788    ///
789    /// This chooses a duplex accept loop for duplex-capable transports and a
790    /// simplex mailbox receiver otherwise. Serving updates the gateway's
791    /// advertised default location to the concrete bound address. Callers that
792    /// need to construct procs using that address can call
793    /// [`Gateway::default_location`] after this returns.
794    pub fn serve_with_listener(
795        &self,
796        addr: ChannelAddr,
797        listener: Option<std::net::TcpListener>,
798    ) -> Result<GatewayServeHandle, ChannelError> {
799        if addr.transport().supports_duplex() {
800            self.serve_duplex_with_listener(addr, listener)
801        } else {
802            let addr = self.resolve_serve_addr(addr);
803            let (addr, rx) = channel::serve_with_listener(addr, listener)?;
804            let serve_id = self.add_server(Location::from(addr));
805            let raw = self.clone().serve(rx);
806            Ok(GatewayServeHandle::from_simplex(
807                self.clone(),
808                raw,
809                Some(serve_id),
810            ))
811        }
812    }
813
814    /// Connect this gateway to a peer gateway's [`serve_duplex`] endpoint
815    /// using the attach handshake.
816    ///
817    /// Dials `addr`, sends this gateway's uid as [`AttachRequest`],
818    /// receives [`AttachAck::Accepted`] with the via location the peer
819    /// assigned, sets that location as this gateway's
820    /// [`default_location`] (so every address handed out by this
821    /// gateway carries the via prefix), installs the duplex sender as
822    /// this gateway's outbound forwarder, and serves inbound traffic
823    /// from the duplex locally. If the peer returns
824    /// [`AttachAck::Rejected`], the rejection reason is returned as an
825    /// error.
826    ///
827    /// Multiple `serve_via` sessions may be active. The newest active
828    /// normal serve or `serve_via` session is this gateway's advertised
829    /// default location, while older active locations remain valid. The
830    /// newest active `serve_via` supplies the outbound forwarder.
831    pub async fn serve_via(&self, addr: ChannelAddr) -> anyhow::Result<GatewayServeHandle> {
832        let my_uid = self.inner.uid.clone();
833        let mut duplex_client = channel::duplex::dial::<MessageEnvelope, AttachWire>(addr)?;
834        let duplex_tx = duplex_client.tx();
835        let mut duplex_rx = duplex_client
836            .take_rx()
837            .expect("dial returns a fresh DuplexClient with rx present");
838
839        duplex_tx.post(build_control_envelope(&AttachRequest { uid: my_uid })?);
840
841        let location = match tokio::time::timeout(ATTACH_HANDSHAKE_TIMEOUT, duplex_rx.recv())
842            .await
843            .map_err(|_| {
844                anyhow::anyhow!(
845                    "attach handshake timed out after {:?} waiting for AttachAck",
846                    ATTACH_HANDSHAKE_TIMEOUT
847                )
848            })?? {
849            AttachWire::Ack(AttachAck::Accepted { location }) => location,
850            AttachWire::Ack(AttachAck::Rejected { reason }) => {
851                anyhow::bail!("attach rejected by peer: {reason}")
852            }
853            AttachWire::Envelope(_) => anyhow::bail!("expected attach ack as first message"),
854        };
855
856        let serve_id = self.add_via_server(location, MailboxClient::new(duplex_tx).into_boxed());
857        let serve_handle = self.serve_rx(AttachRx(duplex_rx));
858        Ok(GatewayServeHandle {
859            gateway: self.clone(),
860            handle: serve_handle,
861            stopped: false,
862            kind: HandleKind::ServeVia {
863                serve_id: Some(serve_id),
864                duplex_client: Some(duplex_client),
865            },
866        })
867    }
868
869    fn serve_inner(
870        &self,
871        addr: ChannelAddr,
872    ) -> Result<(ServeId, MailboxServerHandle), ChannelError> {
873        let addr = self.resolve_serve_addr(addr);
874        let (addr, rx) = channel::serve(addr)?;
875        let serve_id = self.add_server(Location::from(addr));
876        Ok((serve_id, self.serve_rx(rx)))
877    }
878
879    fn resolve_serve_addr(&self, addr: ChannelAddr) -> ChannelAddr {
880        if addr != ChannelAddr::any(ChannelTransport::Local) {
881            return addr;
882        }
883
884        // The first local-any serve for the reserved fallback address activates
885        // that address. Later local-any serves allocate fresh ports, so
886        // multiple local servers can coexist for the same gateway.
887        let routing = self.inner.routing.read().unwrap();
888        let fallback_addr = routing.state.fallback_location.addr();
889        let fallback_is_active = routing.state.active_serves.iter().any(|serve| {
890            matches!(&serve.kind, ActiveServeKind::Server) && serve.location.addr() == fallback_addr
891        });
892        if matches!(fallback_addr, ChannelAddr::Local(_)) && !fallback_is_active {
893            return fallback_addr.clone();
894        }
895        addr
896    }
897
898    fn add_server(&self, location: Location) -> ServeId {
899        self.add_active_serve(location, ActiveServeKind::Server)
900    }
901
902    fn add_via_server(&self, location: Location, forwarder: BoxedMailboxSender) -> ServeId {
903        self.add_active_serve(location, ActiveServeKind::Via { forwarder })
904    }
905
906    fn add_active_serve(&self, location: Location, kind: ActiveServeKind) -> ServeId {
907        let id = ServeId(
908            self.inner
909                .next_serve_id
910                .fetch_add(1, AtomicOrdering::Relaxed),
911        );
912        let mut routing = self.inner.routing.write().unwrap();
913        routing.mutate(|routing| {
914            routing
915                .active_serves
916                .push(ActiveServe { id, location, kind });
917        });
918        id
919    }
920
921    fn remove_active_serve(&self, serve_id: ServeId) {
922        let mut routing = self.inner.routing.write().unwrap();
923        routing.mutate(|routing| {
924            if let Some(index) = routing
925                .active_serves
926                .iter()
927                .rposition(|active| active.id == serve_id)
928            {
929                routing.active_serves.remove(index);
930            }
931        });
932    }
933
934    fn local_delivery_locations(&self) -> Arc<[Location]> {
935        Arc::clone(&self.inner.routing.read().unwrap().local_delivery_locations)
936    }
937
938    fn envelope_with_next_hop_location(
939        envelope: MessageEnvelope,
940        location: Location,
941    ) -> MessageEnvelope {
942        let dest_id = envelope.next_hop().id().clone();
943        envelope.with_next_hop(PortAddr::new(dest_id, location))
944    }
945
946    fn return_no_route(
947        envelope: MessageEnvelope,
948        return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
949    ) {
950        let target = envelope.dest().clone();
951        let failure = DeliveryFailure::new(UndeliverableReason::Transport(TransportFailure::new(
952            target,
953            TransportFailureReason::NoRoute,
954        )));
955        envelope.undeliverable(failure, return_handle);
956    }
957
958    /// Flush pending gateway traffic.
959    ///
960    /// Flushes the muxers for all live attached procs and then the
961    /// gateway's forwarder. Flushing the proc muxers drains local
962    /// delivery and any return paths rooted in attached procs;
963    /// flushing the forwarder drains outbound traffic that the gateway
964    /// routed away from those targets.
965    ///
966    /// Flushing is best-effort: every muxer and the forwarder are
967    /// flushed even if some fail, and the first error (if any) is
968    /// returned afterward. The live proc set is snapshotted before
969    /// awaiting, so we do not hold its map while flushing. Procs that
970    /// have already been dropped are ignored. Concurrent posts may
971    /// race with this operation; `flush` only guarantees that each
972    /// flushed sender observes its usual sender-level flush semantics
973    /// at the time it is flushed.
974    pub(crate) async fn flush(&self) -> Result<(), anyhow::Error> {
975        // Flush local procs and the forwarder. We intentionally do
976        // *not* iterate `peers` here:
977        // in-process gateway attaches install each peer in the other's
978        // peers, so a naive iteration recurses through the peer's
979        // `flush` and overflows the stack.
980        let local_procs: Vec<_> = self
981            .inner
982            .procs
983            .read()
984            .unwrap()
985            .values()
986            .filter_map(WeakProc::upgrade)
987            .collect();
988        // Bound concurrency by the proc count so every muxer flush is
989        // still launched at once (best-effort, order-independent). Each
990        // future owns its `Proc` so the borrow stays self-contained.
991        let concurrency = local_procs.len().max(1);
992        let proc_results: Vec<_> = futures::stream::iter(
993            local_procs
994                .into_iter()
995                .map(|proc| async move { proc.muxer().flush().await }),
996        )
997        .buffer_unordered(concurrency)
998        .collect()
999        .await;
1000        let forwarder = self.inner.routing.read().unwrap().forwarder.clone();
1001        let forwarder_result = forwarder.flush().await;
1002
1003        // Best-effort: all flushes have run; surface the first error.
1004        proc_results
1005            .into_iter()
1006            .chain(std::iter::once(forwarder_result))
1007            .collect()
1008    }
1009}
1010
1011impl fmt::Debug for Gateway {
1012    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1013        f.debug_struct("Gateway")
1014            .field("default_location", &self.default_location())
1015            .finish()
1016    }
1017}
1018
1019/// A running gateway server. Returned by [`Gateway::serve`],
1020/// [`Gateway::serve_duplex`], [`Gateway::serve_with_listener`], and
1021/// [`Gateway::serve_via`].
1022///
1023/// The same type covers all three flavors; the internal [`HandleKind`]
1024/// carries flavor-specific teardown state. Shutdown is two steps:
1025/// [`stop`](Self::stop) signals the server and runs the flavor-specific
1026/// cleanup, and [`join`](Self::join) awaits teardown. They are
1027/// independent — `join` does not stop, so a caller that wants both must
1028/// call `stop` first.
1029pub struct GatewayServeHandle {
1030    gateway: Gateway,
1031    handle: MailboxServerHandle,
1032    stopped: bool,
1033    kind: HandleKind,
1034}
1035
1036/// Per-flavor teardown state for a [`GatewayServeHandle`].
1037enum HandleKind {
1038    /// A simple serve on a [`ChannelAddr`]. `serve_id` is removed from
1039    /// the gateway's active-serve list on stop. `None` for callers that
1040    /// have already handled active-serve bookkeeping.
1041    Serve { serve_id: Option<ServeId> },
1042    /// A duplex accept loop. Same active-serve bookkeeping as
1043    /// [`Serve`]; `cancel_token` stops the loop and its per-connection
1044    /// tasks when [`stop`](GatewayServeHandle::stop) cancels it.
1045    ServeDuplex {
1046        serve_id: Option<ServeId>,
1047        cancel_token: CancellationToken,
1048    },
1049    /// An outbound attach session. Owns the duplex client and removes its
1050    /// active-serve entry on cleanup.
1051    ServeVia {
1052        serve_id: Option<ServeId>,
1053        duplex_client: Option<channel::duplex::DuplexClient<MessageEnvelope, AttachWire>>,
1054    },
1055}
1056
1057impl fmt::Debug for GatewayServeHandle {
1058    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1059        let variant = match &self.kind {
1060            HandleKind::Serve { .. } => "Serve",
1061            HandleKind::ServeDuplex { .. } => "ServeDuplex",
1062            HandleKind::ServeVia { .. } => "ServeVia",
1063        };
1064        f.debug_struct("GatewayServeHandle")
1065            .field("kind", &variant)
1066            .finish()
1067    }
1068}
1069
1070impl GatewayServeHandle {
1071    /// [`GatewayServeHandle`] of kind `Serve` for an already-open receiver.
1072    fn from_simplex(
1073        gateway: Gateway,
1074        handle: MailboxServerHandle,
1075        serve_id: Option<ServeId>,
1076    ) -> Self {
1077        Self {
1078            gateway,
1079            handle,
1080            stopped: false,
1081            kind: HandleKind::Serve { serve_id },
1082        }
1083    }
1084
1085    /// Wrap an externally-produced [`MailboxServerHandle`] — e.g. a
1086    /// host's muxed frontend accept loop driven outside the gateway —
1087    /// as a gateway serve handle. Registers `addr` as an active serve
1088    /// location (via [`add_server`](Gateway::add_server)) so the gateway
1089    /// delivers traffic addressed to the frontend in-process instead of
1090    /// dialing it, and advertises it as the default location — mirroring
1091    /// [`Gateway::serve_with_listener`]. The active-serve entry is removed
1092    /// when the handle is stopped.
1093    pub fn from_mailbox_handle(
1094        gateway: Gateway,
1095        addr: ChannelAddr,
1096        handle: MailboxServerHandle,
1097    ) -> Self {
1098        let serve_id = gateway.add_server(Location::from(addr));
1099        Self::from_simplex(gateway, handle, Some(serve_id))
1100    }
1101
1102    /// Signal the underlying server to stop and run the flavor-specific
1103    /// cleanup: remove the active-serve entry for `Serve`, `ServeDuplex`,
1104    /// or `ServeVia`. Idempotent: later calls are no-ops. Call
1105    /// [`join`](Self::join) afterward to await teardown.
1106    pub fn stop(&mut self, reason: &str) {
1107        if self.stopped {
1108            return;
1109        }
1110        self.stopped = true;
1111        // `ServeDuplex` is driven by its cancellation token, not the
1112        // inner mailbox-server watch (whose receiver is unused), so
1113        // signal the token here and leave the inner handle alone.
1114        match &self.kind {
1115            HandleKind::ServeDuplex { cancel_token, .. } => {
1116                tracing::info!("stopping gateway duplex accept loop; reason: {reason}");
1117                cancel_token.cancel();
1118            }
1119            HandleKind::Serve { .. } | HandleKind::ServeVia { .. } => {
1120                self.handle.stop(reason);
1121            }
1122        }
1123        self.run_cleanup();
1124    }
1125
1126    /// Await teardown of the underlying server (and any owned duplex
1127    /// session), returning its join result. This does not signal the
1128    /// server to stop; call [`stop`](Self::stop) first if it is still
1129    /// running, or `join` will block until it terminates on its own.
1130    pub async fn join(mut self) -> Result<(), MailboxServerError> {
1131        let inner_result = (&mut self.handle).await;
1132
1133        // Drain any owned duplex session as part of join.
1134        if let HandleKind::ServeVia { duplex_client, .. } = &mut self.kind
1135            && let Some(client) = duplex_client.take()
1136        {
1137            client.join().await;
1138        }
1139        self.run_cleanup();
1140        match inner_result {
1141            Ok(Ok(())) => Ok(()),
1142            Ok(Err(err)) => Err(err),
1143            Err(join_err) => Err(MailboxServerError::Channel(ChannelError::Other(
1144                anyhow::anyhow!("gateway serve task join error: {join_err}"),
1145            ))),
1146        }
1147    }
1148
1149    fn run_cleanup(&mut self) {
1150        match &mut self.kind {
1151            HandleKind::Serve { serve_id }
1152            | HandleKind::ServeDuplex { serve_id, .. }
1153            | HandleKind::ServeVia { serve_id, .. } => {
1154                if let Some(serve_id) = serve_id.take() {
1155                    self.gateway.remove_active_serve(serve_id);
1156                }
1157            }
1158        }
1159    }
1160}
1161
1162impl Drop for GatewayServeHandle {
1163    fn drop(&mut self) {
1164        // Graceful teardown is `stop()` + `join().await`. As a safety
1165        // net, the `ServeVia` variant must remove its active-serve
1166        // entry on drop: it installed a forwarder backed by the duplex
1167        // client this handle owns, so dropping without cleanup would
1168        // leave the gateway holding a dead sender. The `Serve` and
1169        // `ServeDuplex` active-serve bookkeeping is intentionally left
1170        // to an explicit `stop()`.
1171        if self.stopped {
1172            return;
1173        }
1174        if matches!(&self.kind, HandleKind::ServeVia { .. }) {
1175            self.run_cleanup();
1176        }
1177    }
1178}
1179
1180/// Accept loop body shared by all duplex servers attached to a
1181/// gateway. Each accepted connection is dispatched based on its first
1182/// message:
1183///
1184/// * [`AttachRequest`] control envelope — register the peer in
1185///   `peers`, reply with [`AttachAck::Accepted`] carrying the via
1186///   location, then serve remaining envelope traffic from the duplex.
1187///   If registration fails, reply with [`AttachAck::Rejected`] and
1188///   close the connection.
1189/// * regular [`MessageEnvelope`] — serve straight through.
1190async fn duplex_accept_loop(
1191    mut duplex_server: channel::duplex::DuplexServer<MessageEnvelope, AttachWire>,
1192    bound_addr: ChannelAddr,
1193    gateway: Gateway,
1194    cancel_token: CancellationToken,
1195) {
1196    let mut tasks: JoinSet<()> = JoinSet::new();
1197    loop {
1198        let accept = tokio::select! {
1199            result = duplex_server.accept() => result,
1200            () = cancel_token.cancelled() => break,
1201        };
1202        let (duplex_rx, duplex_tx) = match accept {
1203            Ok(pair) => pair,
1204            Err(e) => {
1205                tracing::info!(
1206                    bound_addr = bound_addr.to_string(),
1207                    error = %e,
1208                    "duplex accept loop ended"
1209                );
1210                break;
1211            }
1212        };
1213
1214        tasks.spawn(serve_duplex_connection(
1215            gateway.clone(),
1216            duplex_rx,
1217            duplex_tx,
1218            cancel_token.clone(),
1219        ));
1220    }
1221
1222    while tasks.join_next().await.is_some() {}
1223    // Tear down the server now that the accept loop has exited. The loop
1224    // broke on its own `cancel_token`, which is distinct from the server's
1225    // listener cancel, so we must signal the listener explicitly: `stop`
1226    // cancels it (for a muxed frontend that is the shared listener, so it
1227    // also closes the simplex half and lets simplex peers observe a clean
1228    // `Closed`), then `join` awaits the teardown. Stopping before joining
1229    // — rather than relying on `join` to cancel — keeps the teardown
1230    // correct regardless of how the underlying handle implements `join`.
1231    duplex_server.stop("duplex accept loop draining");
1232    duplex_server.join().await;
1233}
1234
1235async fn serve_duplex_connection(
1236    gateway: Gateway,
1237    mut duplex_rx: channel::duplex::DuplexRx<MessageEnvelope>,
1238    duplex_tx: channel::duplex::DuplexTx<AttachWire>,
1239    cancel_token: CancellationToken,
1240) {
1241    let first_msg = tokio::select! {
1242        result = duplex_rx.recv() => match result {
1243            Ok(msg) => msg,
1244            Err(e) => {
1245                tracing::info!(error = %e, "duplex connection closed before first message");
1246                return;
1247            }
1248        },
1249        () = cancel_token.cancelled() => return,
1250    };
1251
1252    if let Ok(attach_request) = first_msg.deserialized::<AttachRequest>() {
1253        let peer_uid = attach_request.uid;
1254        tracing::info!(
1255            uid = %peer_uid,
1256            "duplex accepted gateway-attach connection",
1257        );
1258        // Register the peer *before* accepting, so the handshake
1259        // reflects the actual server-side state. If registration
1260        // fails (e.g. a duplicate uid), reject the attach instead of
1261        // letting the client infer failure from a closed channel.
1262        let sender = MailboxClient::new(AttachTx(duplex_tx.clone())).into_boxed();
1263        let attach_guard = match gateway.attach_peer(peer_uid.clone(), sender) {
1264            Ok(guard) => guard,
1265            Err(err) => {
1266                let reason = err.to_string();
1267                tracing::warn!(
1268                    uid = %err.uid,
1269                    error = %reason,
1270                    "rejecting gateway-attach connection"
1271                );
1272                if let Err(send_error) = duplex_tx
1273                    .send(AttachWire::Ack(AttachAck::Rejected { reason }))
1274                    .await
1275                {
1276                    tracing::warn!(
1277                        uid = %err.uid,
1278                        error = %send_error,
1279                        "failed to send gateway-attach rejection"
1280                    );
1281                }
1282                return;
1283            }
1284        };
1285
1286        // Reply with the via location the peer should advertise.
1287        let via_location = gateway.default_location().with_via(peer_uid);
1288        duplex_tx.post(AttachWire::Ack(AttachAck::Accepted {
1289            location: via_location,
1290        }));
1291
1292        let mut handle = gateway.serve_rx(duplex_rx);
1293        tokio::select! {
1294            _ = &mut handle => {}
1295            () = cancel_token.cancelled() => {
1296                handle.stop("gateway accept loop stopping");
1297                let _ = handle.await;
1298            }
1299        }
1300        drop(attach_guard);
1301        tracing::info!("gateway-attach connection closed");
1302    } else {
1303        // Regular inbound connection: route messages, no outbound
1304        // tag-0x01 traffic. The DuplexTx is held for the lifetime of
1305        // the connection: dropping it closes the session's outbound
1306        // channel, which causes the session task to exit and the
1307        // inbound receiver to close after a single message.
1308        let _keep_alive = duplex_tx;
1309        let rx = PrependRx {
1310            first: Some(first_msg),
1311            inner: duplex_rx,
1312        };
1313        let mut handle = gateway.serve_rx(rx);
1314        tokio::select! {
1315            _ = &mut handle => {}
1316            () = cancel_token.cancelled() => {
1317                handle.stop("gateway accept loop stopping");
1318                let _ = handle.await;
1319            }
1320        }
1321    }
1322}
1323
1324/// [`Rx<MessageEnvelope>`] adapter that yields a single pre-read
1325/// envelope before delegating to an inner receiver. Used by the
1326/// duplex accept loop to re-inject the first message it consumed for
1327/// connection-type dispatch.
1328struct PrependRx<R> {
1329    first: Option<MessageEnvelope>,
1330    inner: R,
1331}
1332
1333#[async_trait]
1334impl<R: channel::Rx<MessageEnvelope> + Send> channel::Rx<MessageEnvelope> for PrependRx<R> {
1335    async fn recv(&mut self) -> Result<MessageEnvelope, ChannelError> {
1336        if let Some(msg) = self.first.take() {
1337            return Ok(msg);
1338        }
1339        self.inner.recv().await
1340    }
1341
1342    fn addr(&self) -> ChannelAddr {
1343        self.inner.addr()
1344    }
1345
1346    async fn join(self) {
1347        self.inner.join().await
1348    }
1349}
1350
1351#[async_trait]
1352impl crate::mailbox::MailboxSender for Weak<GatewayState> {
1353    fn post_unchecked(
1354        &self,
1355        envelope: MessageEnvelope,
1356        return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1357    ) {
1358        match Weak::upgrade(self).map(|inner| Gateway { inner }) {
1359            Some(gateway) => {
1360                gateway.route_envelope(envelope, return_handle);
1361            }
1362            None => {
1363                let target = envelope.dest().clone();
1364                let failure =
1365                    DeliveryFailure::new(UndeliverableReason::Transport(TransportFailure::new(
1366                        target,
1367                        TransportFailureReason::LinkUnavailable("gateway is gone".to_string()),
1368                    )));
1369                envelope.undeliverable(failure, return_handle)
1370            }
1371        }
1372    }
1373
1374    async fn flush(&self) -> Result<(), anyhow::Error> {
1375        match Weak::upgrade(self).map(|inner| Gateway { inner }) {
1376            Some(gateway) => Gateway::flush(&gateway).await,
1377            None => Ok(()),
1378        }
1379    }
1380}
1381
1382#[async_trait]
1383impl crate::mailbox::MailboxSender for Gateway {
1384    fn post_unchecked(
1385        &self,
1386        envelope: MessageEnvelope,
1387        return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1388    ) {
1389        self.route_envelope(envelope, return_handle);
1390    }
1391
1392    async fn flush(&self) -> Result<(), anyhow::Error> {
1393        Gateway::flush(self).await
1394    }
1395}
1396
1397impl Gateway {
1398    fn route_envelope(
1399        &self,
1400        envelope: MessageEnvelope,
1401        return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1402    ) {
1403        // A message that reaches a gateway resolves by the
1404        // next hop's outermost `Via(uid, ...)` hop (if any):
1405        //
1406        //   * a peer hop (`uid` in `peers`): peel the hop and forward
1407        //     to that peer;
1408        //   * *our own* hop (`uid == self.uid`): consume the hop, then
1409        //     keep routing the peeled next hop;
1410        //   * a via-less local destination: deliver locally;
1411        //   * anything else: not ours. Hand it to the forwarder, which
1412        //     is either a route onward (a dial router, or an attached
1413        //     duplex) or a terminal `UnroutableMailboxSender` that
1414        //     returns it as undeliverable.
1415        //
1416        // A foreign via is never silently delivered locally just
1417        // because its inner proc id happens to match a local proc.
1418        let mut envelope = envelope;
1419        loop {
1420            let dest_location = envelope.next_hop().location().clone();
1421            let Ok((via_uid, inner_location)) = dest_location.pop_via() else {
1422                break;
1423            };
1424
1425            if let Some(sender) = self.inner.peers.read().unwrap().get(&via_uid).cloned() {
1426                let envelope = Gateway::envelope_with_next_hop_location(envelope, inner_location);
1427                sender.post(envelope, return_handle);
1428                return;
1429            }
1430            if via_uid != self.inner.uid {
1431                // A hop naming neither a peer nor this gateway: we are a
1432                // waypoint, not the destination. Forward toward the
1433                // default route, which itself returns the message as
1434                // undeliverable if it is terminal.
1435                let forwarder = self.inner.routing.read().unwrap().forwarder.clone();
1436                forwarder.post(envelope, return_handle);
1437                return;
1438            }
1439            envelope = Gateway::envelope_with_next_hop_location(envelope, inner_location);
1440        }
1441
1442        // Via-less destination: deliver to the local proc if it is a
1443        // delivery target, otherwise hand to the forwarder (outbound egress
1444        // for plain remote addresses). When a gateway has already consumed a
1445        // routing hop, it is the named leaf and a miss is undeliverable rather
1446        // than a fallback forward. A dead entry is left in place —
1447        // `AttachedProcGuard::drop` is the sole remover.
1448        let dest_proc = envelope.dest().actor_addr().proc_addr();
1449        let local = self
1450            .inner
1451            .procs
1452            .read()
1453            .unwrap()
1454            .get(dest_proc.id())
1455            .and_then(WeakProc::upgrade);
1456        if let Some(proc) = local {
1457            let local_locations = self.local_delivery_locations();
1458            if proc.is_local_delivery_target_at(&dest_proc, &local_locations) {
1459                proc.muxer().post(envelope, return_handle);
1460                return;
1461            }
1462        }
1463
1464        if envelope.has_next_hop() {
1465            Gateway::return_no_route(envelope, return_handle);
1466        } else {
1467            let forwarder = self.inner.routing.read().unwrap().forwarder.clone();
1468            forwarder.post(envelope, return_handle)
1469        }
1470    }
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475    use std::sync::Arc;
1476    use std::sync::atomic::AtomicUsize;
1477    use std::sync::atomic::Ordering;
1478    use std::time::Duration;
1479
1480    use async_trait::async_trait;
1481    use hyperactor_config::Flattrs;
1482    use timed_test::async_timed_test;
1483    use tokio::sync::mpsc;
1484    use tokio::time;
1485
1486    use super::*;
1487    use crate::Endpoint as _;
1488    use crate::Label;
1489    use crate::ProcAddr;
1490    use crate::mailbox::DeliveryFailureKind;
1491    use crate::mailbox::MailboxClient;
1492    use crate::mailbox::MailboxSender;
1493    use crate::mailbox::PortLocation;
1494    use crate::mailbox::monitored_return_handle;
1495    use crate::port::Port;
1496    use crate::proc::Proc;
1497    use crate::testing::ids::test_actor_id;
1498    use crate::testing::pingpong::PingPongActor;
1499    use crate::testing::pingpong::PingPongMessage;
1500
1501    #[derive(Clone)]
1502    struct RecordingSender(mpsc::UnboundedSender<MessageEnvelope>);
1503
1504    #[async_trait]
1505    impl MailboxSender for RecordingSender {
1506        fn post_unchecked(
1507            &self,
1508            envelope: MessageEnvelope,
1509            _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1510        ) {
1511            self.0
1512                .send(envelope)
1513                .expect("recording sender should be open");
1514        }
1515    }
1516
1517    /// Test-only helper that connects two gateways over real
1518    /// `Local`-transport channels, so the via-routing tests exercise a
1519    /// genuine cross-gateway hop: each gateway serves on a local channel
1520    /// and the peer reaches it by dialing. After attach, each side's
1521    /// `default_location` advertises its destinations through the peer's
1522    /// uid (`Via(self_uid, peer_default)`), so procs bound afterward
1523    /// inherit the via prefix and route across the link.
1524    ///
1525    /// Production cross-process attach is [`Gateway::serve_via`] /
1526    /// [`Gateway::serve_duplex`]; this is just enough wiring to test the
1527    /// gateway's via routing locally.
1528    trait GatewayAttachExt {
1529        fn attach(&self, peer: &Gateway) -> AttachGuard;
1530    }
1531
1532    impl GatewayAttachExt for Gateway {
1533        fn attach(&self, peer: &Gateway) -> AttachGuard {
1534            // Genuine pre-attach defaults, captured before serving
1535            // (serving overwrites `default_location` with the served
1536            // address).
1537            let pre_self_default = self.default_location();
1538            let pre_peer_default = peer.default_location();
1539
1540            // Serve each gateway on a local channel so the other can
1541            // reach it by dialing.
1542            let self_serve = self
1543                .serve(ChannelAddr::any(ChannelTransport::Local))
1544                .expect("serve self on local channel");
1545            let peer_serve = peer
1546                .serve(ChannelAddr::any(ChannelTransport::Local))
1547                .expect("serve peer on local channel");
1548            let self_addr = self.default_location().addr().clone();
1549            let peer_addr = peer.default_location().addr().clone();
1550
1551            // Cross-register dialed senders keyed by uid, so each side
1552            // peels the other's uid and forwards over the local channel.
1553            let self_via_guard = peer
1554                .attach_peer(
1555                    self.inner.uid.clone(),
1556                    MailboxClient::dial(self_addr)
1557                        .expect("dial self")
1558                        .into_boxed(),
1559                )
1560                .expect("peer has no via entry for this gateway's uid");
1561            let peer_via_guard = self
1562                .attach_peer(
1563                    peer.inner.uid.clone(),
1564                    MailboxClient::dial(peer_addr)
1565                        .expect("dial peer")
1566                        .into_boxed(),
1567                )
1568                .expect("self has no via entry for the peer gateway's uid");
1569
1570            // Advertise each side's destinations through the peer's uid.
1571            self.inner.routing.write().unwrap().default_location =
1572                Location::Via(self.inner.uid.clone(), Box::new(pre_peer_default.clone()));
1573            peer.inner.routing.write().unwrap().default_location =
1574                Location::Via(peer.inner.uid.clone(), Box::new(pre_self_default.clone()));
1575
1576            AttachGuard {
1577                self_gateway: Arc::downgrade(&self.inner),
1578                peer_gateway: Arc::downgrade(&peer.inner),
1579                prev_self_default: Some(pre_self_default),
1580                prev_peer_default: Some(pre_peer_default),
1581                _self_via_guard: self_via_guard,
1582                _peer_via_guard: peer_via_guard,
1583                _self_serve: self_serve,
1584                _peer_serve: peer_serve,
1585            }
1586        }
1587    }
1588
1589    /// Guard for the test-only [`GatewayAttachExt::attach`]: on drop it
1590    /// restores both gateways' previous default locations and removes
1591    /// the cross-registered peers and local serve loops (via the
1592    /// held guards and serve handles).
1593    struct AttachGuard {
1594        self_gateway: Weak<GatewayState>,
1595        peer_gateway: Weak<GatewayState>,
1596        prev_self_default: Option<Location>,
1597        prev_peer_default: Option<Location>,
1598        _self_via_guard: PeerAttachGuard,
1599        _peer_via_guard: PeerAttachGuard,
1600        _self_serve: GatewayServeHandle,
1601        _peer_serve: GatewayServeHandle,
1602    }
1603
1604    impl Drop for AttachGuard {
1605        fn drop(&mut self) {
1606            if let Some(state) = self.self_gateway.upgrade()
1607                && let Some(loc) = self.prev_self_default.take()
1608            {
1609                state.routing.write().unwrap().default_location = loc;
1610            }
1611            if let Some(state) = self.peer_gateway.upgrade()
1612                && let Some(loc) = self.prev_peer_default.take()
1613            {
1614                state.routing.write().unwrap().default_location = loc;
1615            }
1616            // via guards and serve handles drop themselves.
1617        }
1618    }
1619
1620    /// `Gateway::post_unchecked` demuxes inbound envelopes by
1621    /// destination `ProcId` to the matching attached proc's muxer,
1622    /// and falls through to the configured forwarder for unknown
1623    /// destinations. Attached procs only receive envelopes addressed
1624    /// to them — a stranger-addressed envelope does not leak to local
1625    /// receivers.
1626    #[tokio::test]
1627    async fn test_gateway_post_demuxes_by_proc_id() {
1628        let (tx, mut forwarded_rx) = mpsc::unbounded_channel();
1629        let gateway = Gateway::configured(
1630            channel::reserve_local_addr().into(),
1631            BoxedMailboxSender::new(RecordingSender(tx)),
1632        );
1633
1634        let alpha = Proc::builder()
1635            .proc_id(ProcId::instance(Label::strip("alpha")))
1636            .shared_gateway(gateway.clone())
1637            .build()
1638            .unwrap();
1639        let beta = Proc::builder()
1640            .proc_id(ProcId::instance(Label::strip("beta")))
1641            .shared_gateway(gateway.clone())
1642            .build()
1643            .unwrap();
1644
1645        let alpha_client = alpha.client("client");
1646        let (alpha_port, mut alpha_rx) = alpha_client.bind_handler_port::<u64>();
1647        let PortLocation::Bound(alpha_dest) = alpha_port.location() else {
1648            panic!("alpha handler port must be bound");
1649        };
1650
1651        let beta_client = beta.client("client");
1652        let (beta_port, mut beta_rx) = beta_client.bind_handler_port::<u64>();
1653        let PortLocation::Bound(beta_dest) = beta_port.location() else {
1654            panic!("beta handler port must be bound");
1655        };
1656
1657        let sender = test_actor_id("test", "sender");
1658
1659        gateway.post(
1660            MessageEnvelope::serialize(sender.clone(), alpha_dest.clone(), &111u64, Flattrs::new())
1661                .unwrap(),
1662            monitored_return_handle(),
1663        );
1664        let received = time::timeout(Duration::from_secs(5), alpha_rx.recv())
1665            .await
1666            .expect("alpha_rx timed out")
1667            .expect("alpha_rx closed");
1668        assert_eq!(received, 111);
1669        assert!(matches!(
1670            forwarded_rx.try_recv(),
1671            Err(mpsc::error::TryRecvError::Empty)
1672        ));
1673
1674        gateway.post(
1675            MessageEnvelope::serialize(sender.clone(), beta_dest.clone(), &222u64, Flattrs::new())
1676                .unwrap(),
1677            monitored_return_handle(),
1678        );
1679        let received = time::timeout(Duration::from_secs(5), beta_rx.recv())
1680            .await
1681            .expect("beta_rx timed out")
1682            .expect("beta_rx closed");
1683        assert_eq!(received, 222);
1684        assert!(matches!(
1685            forwarded_rx.try_recv(),
1686            Err(mpsc::error::TryRecvError::Empty)
1687        ));
1688
1689        let stranger_proc = ProcAddr::instance(ChannelAddr::Local(9999), "stranger");
1690        let stranger_dest = stranger_proc
1691            .actor_addr("ghost")
1692            .port_addr(Port::from(0u64));
1693        gateway.post(
1694            MessageEnvelope::serialize(sender, stranger_dest.clone(), &333u64, Flattrs::new())
1695                .unwrap()
1696                .set_ttl(3),
1697            monitored_return_handle(),
1698        );
1699        let forwarded = time::timeout(Duration::from_secs(5), forwarded_rx.recv())
1700            .await
1701            .expect("forwarded_rx timed out")
1702            .expect("forwarded_rx closed");
1703        assert_eq!(forwarded.dest(), &stranger_dest);
1704        // The fallback route is another `MailboxSender` hop: `gateway.post`
1705        // decrements once, and then the forwarder's `post` decrements again.
1706        assert_eq!(forwarded.ttl(), 1);
1707        assert!(
1708            time::timeout(Duration::from_millis(50), alpha_rx.recv())
1709                .await
1710                .is_err(),
1711            "alpha_rx received a message after stranger post",
1712        );
1713        assert!(
1714            time::timeout(Duration::from_millis(50), beta_rx.recv())
1715                .await
1716                .is_err(),
1717            "beta_rx received a message after stranger post",
1718        );
1719    }
1720
1721    /// A via hop naming neither a peer nor this gateway is *forwarded*,
1722    /// never delivered locally — even when the inner proc id matches a
1723    /// live local proc. The source route wins over an incidental id
1724    /// match.
1725    #[tokio::test]
1726    async fn test_gateway_foreign_via_forwards_not_local() {
1727        #[derive(Clone)]
1728        struct CountingSender(Arc<AtomicUsize>);
1729
1730        #[async_trait]
1731        impl MailboxSender for CountingSender {
1732            fn post_unchecked(
1733                &self,
1734                _envelope: MessageEnvelope,
1735                _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1736            ) {
1737                self.0.fetch_add(1, Ordering::SeqCst);
1738            }
1739        }
1740
1741        let forwarded = Arc::new(AtomicUsize::new(0));
1742        let gateway = Gateway::configured(
1743            channel::reserve_local_addr().into(),
1744            BoxedMailboxSender::new(CountingSender(forwarded.clone())),
1745        );
1746
1747        // A live local proc whose id we will reuse behind a foreign via.
1748        let alpha = Proc::builder()
1749            .proc_id(ProcId::instance(Label::strip("alpha")))
1750            .shared_gateway(gateway.clone())
1751            .build()
1752            .unwrap();
1753
1754        // Address alpha's proc id, but behind a via hop for a uid that
1755        // is neither a peer nor this gateway's own uid.
1756        let foreign_uid = Uid::Instance(0xfeed, Some(Label::strip("foreign")));
1757        assert_ne!(&foreign_uid, gateway.uid());
1758        let dest = ProcAddr::new(
1759            alpha.proc_id().clone(),
1760            Location::from(ChannelAddr::Local(7777)).with_via(foreign_uid),
1761        )
1762        .actor_addr("ghost")
1763        .port_addr(Port::from(0u64));
1764
1765        gateway.post(
1766            MessageEnvelope::serialize(
1767                test_actor_id("test", "sender"),
1768                dest,
1769                &7u64,
1770                Flattrs::new(),
1771            )
1772            .unwrap(),
1773            monitored_return_handle(),
1774        );
1775
1776        // Forwarded, not delivered locally by the matching inner id.
1777        assert_eq!(
1778            forwarded.load(Ordering::SeqCst),
1779            1,
1780            "a foreign via must be forwarded, not delivered locally",
1781        );
1782    }
1783
1784    /// A self via is only one routing hop. After it is consumed, any
1785    /// remaining via must be routed before local delivery is considered.
1786    #[tokio::test]
1787    async fn test_gateway_self_via_routes_remaining_via_before_local_delivery() {
1788        #[derive(Clone)]
1789        struct CountingSender(Arc<AtomicUsize>);
1790
1791        #[async_trait]
1792        impl MailboxSender for CountingSender {
1793            fn post_unchecked(
1794                &self,
1795                _envelope: MessageEnvelope,
1796                _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1797            ) {
1798                self.0.fetch_add(1, Ordering::SeqCst);
1799            }
1800        }
1801
1802        let forwarded = Arc::new(AtomicUsize::new(0));
1803        let gateway = Gateway::configured(
1804            channel::reserve_local_addr().into(),
1805            BoxedMailboxSender::new(CountingSender(forwarded.clone())),
1806        );
1807        let proc = Proc::builder()
1808            .proc_id(ProcId::instance(Label::strip("alpha")))
1809            .shared_gateway(gateway.clone())
1810            .build()
1811            .unwrap();
1812
1813        let foreign_uid = Uid::Instance(0xfeed, Some(Label::strip("foreign")));
1814        let dest = ProcAddr::new(
1815            proc.proc_id().clone(),
1816            proc.default_location()
1817                .with_via(foreign_uid)
1818                .with_via(gateway.uid().clone()),
1819        )
1820        .actor_addr("ghost")
1821        .port_addr(Port::from(0u64));
1822
1823        gateway.post(
1824            MessageEnvelope::serialize(
1825                test_actor_id("test", "sender"),
1826                dest,
1827                &7u64,
1828                Flattrs::new(),
1829            )
1830            .unwrap(),
1831            monitored_return_handle(),
1832        );
1833
1834        assert_eq!(
1835            forwarded.load(Ordering::SeqCst),
1836            1,
1837            "remaining via routes must not deliver locally after peeling this gateway's hop",
1838        );
1839    }
1840
1841    /// A via hop naming this gateway is consumed for routing before local
1842    /// delivery, but the proc muxer still sees the canonical destination.
1843    #[tokio::test]
1844    async fn test_gateway_self_via_peels_before_local_delivery() {
1845        #[derive(Clone)]
1846        struct CapturingSender(Arc<std::sync::Mutex<Option<(PortAddr, PortAddr)>>>);
1847
1848        #[async_trait]
1849        impl MailboxSender for CapturingSender {
1850            fn post_unchecked(
1851                &self,
1852                envelope: MessageEnvelope,
1853                _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
1854            ) {
1855                *self.0.lock().unwrap() =
1856                    Some((envelope.dest().clone(), envelope.next_hop().clone()));
1857            }
1858        }
1859
1860        let gateway = Gateway::isolated();
1861        let proc = Proc::builder()
1862            .proc_id(ProcId::instance(Label::strip("alpha")))
1863            .shared_gateway(gateway.clone())
1864            .build()
1865            .unwrap();
1866
1867        let captured = Arc::new(std::sync::Mutex::new(None));
1868        let actor_addr = proc.proc_addr().actor_addr("capture");
1869        assert!(
1870            proc.muxer()
1871                .bind(actor_addr.id().clone(), CapturingSender(captured.clone()))
1872        );
1873
1874        let inner_dest = actor_addr.port_addr(Port::from(7u64));
1875        let via_dest = PortAddr::new(
1876            inner_dest.id().clone(),
1877            inner_dest
1878                .location()
1879                .clone()
1880                .with_via(gateway.uid().clone()),
1881        );
1882        gateway.post(
1883            MessageEnvelope::serialize(
1884                test_actor_id("test", "sender"),
1885                via_dest.clone(),
1886                &7u64,
1887                Flattrs::new(),
1888            )
1889            .unwrap(),
1890            monitored_return_handle(),
1891        );
1892
1893        assert_eq!(
1894            *captured.lock().unwrap(),
1895            Some((via_dest, inner_dest)),
1896            "delivery preserves the canonical destination and peels only the next hop",
1897        );
1898    }
1899
1900    /// A via hop naming *this* gateway (the leaf) whose inner proc id is
1901    /// not a live local proc is undeliverable — a hop addressed to us is
1902    /// never forwarded back out. The returned envelope preserves the
1903    /// canonical destination and carries the peeled next hop.
1904    #[tokio::test]
1905    async fn test_gateway_self_via_unknown_proc_is_undeliverable() {
1906        let gateway = Gateway::isolated();
1907
1908        // Scratch proc just to host the return port.
1909        let scratch = Proc::isolated();
1910        let scratch_client = scratch.client("return");
1911        let (return_handle, mut return_rx) =
1912            scratch_client.open_port::<Undeliverable<MessageEnvelope>>();
1913
1914        // Address an id with no live local proc, behind this gateway's
1915        // own uid (so we are the named leaf).
1916        let peeled_dest = ProcAddr::new(
1917            ProcId::instance(Label::strip("stranger")),
1918            Location::from(ChannelAddr::Local(4321)),
1919        )
1920        .actor_addr("ghost")
1921        .port_addr(Port::from(0u64));
1922        let dest = PortAddr::new(
1923            peeled_dest.id().clone(),
1924            peeled_dest
1925                .location()
1926                .clone()
1927                .with_via(gateway.uid().clone()),
1928        );
1929        let envelope = MessageEnvelope::serialize(
1930            test_actor_id("test", "sender"),
1931            dest.clone(),
1932            &9u64,
1933            Flattrs::new(),
1934        )
1935        .unwrap()
1936        .set_ttl(3);
1937
1938        gateway.post(envelope, return_handle);
1939
1940        let Undeliverable::Returned(returned) =
1941            time::timeout(Duration::from_secs(5), return_rx.recv())
1942                .await
1943                .expect("return_rx timed out")
1944                .expect("return_rx closed")
1945        else {
1946            panic!("expected returned envelope");
1947        };
1948        assert_eq!(returned.dest(), &dest);
1949        assert_eq!(returned.next_hop(), &peeled_dest);
1950        assert!(
1951            returned
1952                .root_delivery_failure()
1953                .is_some_and(|failure| matches!(
1954                    &failure.kind,
1955                    DeliveryFailureKind::Undeliverable(UndeliverableReason::Transport(_))
1956                )),
1957            "expected NoRoute transport bounce, got {:?}",
1958            returned.delivery_failures(),
1959        );
1960        // This self-via miss is returned directly from `Gateway::post_unchecked`
1961        // without forwarding through another `MailboxSender`, so only
1962        // `gateway.post` decrements the TTL.
1963        assert_eq!(returned.ttl(), 2);
1964    }
1965
1966    /// Ping-pong between two `PingPongActor`s on two procs that share
1967    /// one gateway. Each cross-proc hop goes `Proc::post_unchecked` →
1968    /// `Gateway::post_unchecked` demux → destination proc's muxer
1969    /// directly, without touching the gateway's forwarder.
1970    #[tokio::test]
1971    async fn test_ping_pong_across_shared_gateway() {
1972        let gateway = Gateway::isolated();
1973
1974        let alpha = Proc::builder()
1975            .proc_id(ProcId::instance(Label::strip("alpha")))
1976            .shared_gateway(gateway.clone())
1977            .build()
1978            .unwrap();
1979        let beta = Proc::builder()
1980            .proc_id(ProcId::instance(Label::strip("beta")))
1981            .shared_gateway(gateway.clone())
1982            .build()
1983            .unwrap();
1984
1985        let client = alpha.client("client");
1986        let (undeliverable_msg_tx, mut undeliverable_rx) =
1987            client.open_port::<Undeliverable<MessageEnvelope>>();
1988
1989        let ping_actor = PingPongActor::new(Some(undeliverable_msg_tx.bind()), None, None);
1990        let pong_actor = PingPongActor::new(Some(undeliverable_msg_tx.bind()), None, None);
1991        let ping_handle = alpha.spawn_with_label::<PingPongActor>("ping", ping_actor);
1992        let pong_handle = beta.spawn_with_label::<PingPongActor>("pong", pong_actor);
1993
1994        let (local_port, local_receiver) = client.open_once_port();
1995
1996        ping_handle.post(
1997            &client,
1998            PingPongMessage(10, pong_handle.bind(), local_port.bind()),
1999        );
2000
2001        let received = time::timeout(Duration::from_secs(5), local_receiver.recv())
2002            .await
2003            .expect("local_receiver timed out")
2004            .expect("local_receiver closed");
2005        assert!(received);
2006
2007        assert!(
2008            time::timeout(Duration::from_millis(50), undeliverable_rx.recv())
2009                .await
2010                .is_err(),
2011            "unexpected undeliverable during cross-proc ping-pong",
2012        );
2013    }
2014
2015    /// `Gateway::attach_proc` panics when a second proc with the
2016    /// same `ProcId` is built against the same gateway while the
2017    /// first is still alive. The check is in
2018    /// `Gateway::attach_proc`, invoked from `Proc::builder().build()`
2019    /// via `Proc::from_parts_unchecked`.
2020    #[test]
2021    #[should_panic(expected = "gateway already has a proc attached with id")]
2022    fn test_gateway_attach_proc_panics_on_duplicate_live_proc() {
2023        let gateway = Gateway::isolated();
2024        let proc_id = ProcId::instance(Label::strip("alpha"));
2025
2026        // Hold the first proc in a binding so it stays alive across
2027        // the second build; if the first were dropped, the gateway's
2028        // stale-entry path would silently replace it instead of
2029        // panicking.
2030        let _first = Proc::builder()
2031            .proc_id(proc_id.clone())
2032            .shared_gateway(gateway.clone())
2033            .build()
2034            .unwrap();
2035
2036        let _second = Proc::builder()
2037            .proc_id(proc_id)
2038            .shared_gateway(gateway.clone())
2039            .build()
2040            .unwrap();
2041    }
2042
2043    /// `Gateway::flush()` propagates the flush to each attached
2044    /// proc's muxer (which in turn flushes its bound senders) and
2045    /// then to the gateway's forwarder. Verified by binding a
2046    /// `FlushCountingSender` into each proc's muxer and asserting all
2047    /// three counters (alpha's, beta's, the forwarder's) increment
2048    /// exactly once.
2049    #[tokio::test]
2050    async fn test_gateway_flush_propagates_to_attached_procs() {
2051        #[derive(Clone)]
2052        struct FlushCountingSender(Arc<AtomicUsize>);
2053
2054        #[async_trait]
2055        impl MailboxSender for FlushCountingSender {
2056            fn post_unchecked(
2057                &self,
2058                _envelope: MessageEnvelope,
2059                _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
2060            ) {
2061                // Not exercised by this test.
2062            }
2063
2064            async fn flush(&self) -> Result<(), anyhow::Error> {
2065                self.0.fetch_add(1, Ordering::SeqCst);
2066                Ok(())
2067            }
2068        }
2069
2070        let alpha_flushed = Arc::new(AtomicUsize::new(0));
2071        let beta_flushed = Arc::new(AtomicUsize::new(0));
2072        let forwarder_flushed = Arc::new(AtomicUsize::new(0));
2073
2074        let gateway = Gateway::configured(
2075            channel::reserve_local_addr().into(),
2076            BoxedMailboxSender::new(FlushCountingSender(forwarder_flushed.clone())),
2077        );
2078
2079        let alpha = Proc::builder()
2080            .proc_id(ProcId::instance(Label::strip("alpha")))
2081            .shared_gateway(gateway.clone())
2082            .build()
2083            .unwrap();
2084        let beta = Proc::builder()
2085            .proc_id(ProcId::instance(Label::strip("beta")))
2086            .shared_gateway(gateway.clone())
2087            .build()
2088            .unwrap();
2089
2090        // Bind a flush-counting probe into each proc's muxer. Use a
2091        // fabricated actor id under the proc — no actor is spawned
2092        // there; the muxer just routes flushes to whatever's bound.
2093        let alpha_probe = alpha.proc_addr().actor_addr("alpha_probe").id().clone();
2094        let beta_probe = beta.proc_addr().actor_addr("beta_probe").id().clone();
2095        assert!(
2096            alpha
2097                .muxer()
2098                .bind(alpha_probe, FlushCountingSender(alpha_flushed.clone()))
2099        );
2100        assert!(
2101            beta.muxer()
2102                .bind(beta_probe, FlushCountingSender(beta_flushed.clone()))
2103        );
2104
2105        // Sanity: two procs registered, both live.
2106        assert_eq!(gateway.inner.procs.read().unwrap().len(), 2);
2107
2108        gateway.flush().await.unwrap();
2109
2110        assert_eq!(alpha_flushed.load(Ordering::SeqCst), 1);
2111        assert_eq!(beta_flushed.load(Ordering::SeqCst), 1);
2112        assert_eq!(forwarder_flushed.load(Ordering::SeqCst), 1);
2113    }
2114
2115    /// Driving `Gateway::flush` concurrently with proc attach + drop must
2116    /// not panic, deadlock, or leave the gateway in a torn state. The
2117    /// flush impl snapshots the live proc set before awaiting, so
2118    /// attaches/drops during flush should be invisible to the flush in
2119    /// flight.
2120    #[async_timed_test(timeout_secs = 10)]
2121    async fn test_gateway_flush_concurrent_with_attach_and_drop() {
2122        #[derive(Clone)]
2123        struct NoopSender;
2124
2125        #[async_trait]
2126        impl MailboxSender for NoopSender {
2127            fn post_unchecked(
2128                &self,
2129                _envelope: MessageEnvelope,
2130                _return_handle: PortHandle<Undeliverable<MessageEnvelope>>,
2131            ) {
2132                // Defensive no-op: this test shouldn't route messages.
2133            }
2134
2135            async fn flush(&self) -> Result<(), anyhow::Error> {
2136                Ok(())
2137            }
2138        }
2139
2140        let gateway = Gateway::configured(
2141            channel::reserve_local_addr().into(),
2142            BoxedMailboxSender::new(NoopSender),
2143        );
2144
2145        let barrier = Arc::new(tokio::sync::Barrier::new(2));
2146
2147        let flushes = {
2148            let gateway = gateway.clone();
2149            let barrier = barrier.clone();
2150            tokio::spawn(async move {
2151                barrier.wait().await;
2152                for _ in 0..100 {
2153                    gateway.flush().await.unwrap();
2154                    tokio::task::yield_now().await;
2155                }
2156            })
2157        };
2158
2159        let attach_drop = {
2160            let gateway = gateway.clone();
2161            let barrier = barrier.clone();
2162            tokio::spawn(async move {
2163                barrier.wait().await;
2164                for i in 0..100 {
2165                    let proc = Proc::builder()
2166                        .proc_id(ProcId::instance(Label::strip(&format!("p{i}"))))
2167                        .shared_gateway(gateway.clone())
2168                        .build()
2169                        .unwrap();
2170                    // Hold the proc across at least one yield so it's
2171                    // attached for an observable window before drop.
2172                    tokio::task::yield_now().await;
2173                    drop(proc);
2174                }
2175            })
2176        };
2177
2178        flushes.await.unwrap();
2179        attach_drop.await.unwrap();
2180
2181        // No torn state: a final flush succeeds.
2182        gateway.flush().await.unwrap();
2183
2184        // All procs dropped — no weak entries should still upgrade. Stale
2185        // weak entries may remain in the map (replaced on next attach with
2186        // same id), so we don't assert on `len()`; we assert on live
2187        // entries only.
2188        assert_eq!(
2189            gateway
2190                .inner
2191                .procs
2192                .read()
2193                .unwrap()
2194                .values()
2195                .filter_map(WeakProc::upgrade)
2196                .count(),
2197            0,
2198            "no procs should still be live after attach_drop task completes",
2199        );
2200    }
2201
2202    /// After the gateway is dropped, its weak server sender
2203    /// (the sender used by gateway-served mailbox tasks) bounces
2204    /// envelopes as a structured transport failure rather than panicking or
2205    /// hanging.
2206    /// Tested directly against the weak sender: no channel server, no
2207    /// task lifecycle. The bounce is in-process and
2208    /// observable at the caller's return port without going through
2209    /// any serialize/dispatch path.
2210    #[tokio::test]
2211    async fn test_weak_gateway_bounces_broken_link_after_drop() {
2212        let gateway = Gateway::isolated();
2213        let weak = Arc::downgrade(&gateway.inner);
2214        drop(gateway);
2215
2216        // Scratch proc just to host the return port.
2217        let scratch = Proc::isolated();
2218        let scratch_client = scratch.client("return");
2219        let (return_handle, mut return_rx) =
2220            scratch_client.open_port::<Undeliverable<MessageEnvelope>>();
2221
2222        // Fabricate a destination. Its contents don't matter; the
2223        // bounce happens when the weak sender fails to upgrade before any demux
2224        // would run.
2225        let dest_proc = ProcAddr::instance(ChannelAddr::Local(1234), "stranger");
2226        let dest = dest_proc.actor_addr("ghost").port_addr(Port::from(0u64));
2227        let envelope = MessageEnvelope::serialize(
2228            test_actor_id("test", "sender"),
2229            dest.clone(),
2230            &42u64,
2231            Flattrs::new(),
2232        )
2233        .unwrap();
2234
2235        // Post directly through the weak sender. Upgrade fails and sends the
2236        // bounce synchronously to our return port.
2237        weak.post(envelope, return_handle);
2238
2239        let Undeliverable::Returned(envelope) =
2240            time::timeout(Duration::from_secs(5), return_rx.recv())
2241                .await
2242                .expect("return_rx timed out")
2243                .expect("return_rx closed")
2244        else {
2245            panic!("expected returned envelope");
2246        };
2247        assert_eq!(envelope.dest(), &dest);
2248        assert!(
2249            envelope
2250                .root_delivery_failure()
2251                .is_some_and(|failure| matches!(
2252                    &failure.kind,
2253                    DeliveryFailureKind::Undeliverable(UndeliverableReason::Transport(_))
2254                )),
2255            "expected structured transport bounce, got {:?}",
2256            envelope.delivery_failures(),
2257        );
2258    }
2259
2260    /// Dropping the `Proc` drops its `AttachedProcGuard`, which
2261    /// eagerly removes the entry from the gateway's proc map. A
2262    /// subsequent attach with the same `ProcId` is therefore a fresh
2263    /// insert — no panic, no stale entry to replace.
2264    #[tokio::test]
2265    async fn test_gateway_attach_proc_after_proc_drop() {
2266        let gateway = Gateway::isolated();
2267        let proc_id = ProcId::instance(Label::strip("alpha"));
2268
2269        let first = Proc::builder()
2270            .proc_id(proc_id.clone())
2271            .shared_gateway(gateway.clone())
2272            .build()
2273            .unwrap();
2274        drop(first);
2275
2276        // AttachedProcGuard::drop removed the entry from the map.
2277        assert_eq!(gateway.inner.procs.read().unwrap().len(), 0);
2278
2279        // The slot is free, so registering a new proc with the same id
2280        // is a fresh insert — no panic.
2281        let second = Proc::builder()
2282            .proc_id(proc_id.clone())
2283            .shared_gateway(gateway.clone())
2284            .build()
2285            .unwrap();
2286        assert_eq!(gateway.inner.procs.read().unwrap().len(), 1);
2287
2288        // Verify the new proc is reachable via the gateway.
2289        let client = second.client("client");
2290        let (port, mut rx) = client.bind_handler_port::<u64>();
2291        let dest = port.bind().port_addr().clone();
2292
2293        gateway.post(
2294            MessageEnvelope::serialize(
2295                test_actor_id("test", "sender"),
2296                dest,
2297                &42u64,
2298                Flattrs::new(),
2299            )
2300            .unwrap(),
2301            monitored_return_handle(),
2302        );
2303
2304        let received = time::timeout(Duration::from_secs(5), rx.recv())
2305            .await
2306            .expect("rx timed out")
2307            .expect("rx closed");
2308        assert_eq!(received, 42);
2309    }
2310
2311    /// Active serves unwind by handle id when handles stop out of
2312    /// order. Three concurrent servers; stop the middle one, then the
2313    /// last, then the first, asserting the gateway's
2314    /// `default_location` at each step. Final empty state reverts to
2315    /// the construction-time fallback.
2316    #[tokio::test]
2317    async fn test_gateway_serve_stop_unwinds_in_any_order() {
2318        let gateway = Gateway::isolated();
2319        let fallback = gateway.default_location();
2320
2321        let mut s1 = Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Local)).unwrap();
2322        let loc1 = gateway.default_location();
2323        let mut s2 = Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Local)).unwrap();
2324        let loc2 = gateway.default_location();
2325        let mut s3 = Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Local)).unwrap();
2326        let loc3 = gateway.default_location();
2327
2328        // First serve(any) reuses the gateway's reserved fallback
2329        // address (see resolve_serve_addr); subsequent serves
2330        // allocate fresh ports.
2331        assert_eq!(loc1, fallback);
2332        assert_ne!(loc1, loc2);
2333        assert_ne!(loc2, loc3);
2334        assert_ne!(loc1, loc3);
2335
2336        // Middle handle stops first: default stays at loc3 (still the
2337        // last active serve). `stop` runs the cleanup; `join` awaits
2338        // teardown.
2339        s2.stop("test");
2340        s2.join().await.unwrap();
2341        assert_eq!(gateway.default_location(), loc3);
2342
2343        // Last handle stops: default falls back to loc1.
2344        s3.stop("test");
2345        s3.join().await.unwrap();
2346        assert_eq!(gateway.default_location(), loc1);
2347
2348        // Final handle stops: default reverts to the
2349        // construction-time fallback.
2350        s1.stop("test");
2351        s1.join().await.unwrap();
2352        assert_eq!(gateway.default_location(), fallback);
2353    }
2354
2355    #[tokio::test]
2356    async fn test_gateway_first_local_serve_uses_fallback_after_nonlocal_serve() {
2357        let gateway = Gateway::isolated();
2358        let fallback = gateway.default_location();
2359
2360        let mut unix = Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Unix)).unwrap();
2361        let unix_location = gateway.default_location();
2362        assert_ne!(unix_location, fallback);
2363
2364        let mut local =
2365            Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Local)).unwrap();
2366        assert_eq!(gateway.default_location(), fallback);
2367
2368        local.stop("test");
2369        local.join().await.unwrap();
2370        assert_eq!(gateway.default_location(), unix_location);
2371
2372        unix.stop("test");
2373        unix.join().await.unwrap();
2374        assert_eq!(gateway.default_location(), fallback);
2375    }
2376
2377    /// End-to-end gateway-to-gateway attach via the new protocol:
2378    /// the client gateway calls `serve_via` against a peer that
2379    /// called `serve_duplex`; the handshake assigns the client a via
2380    /// location and installs the duplex sender; outbound traffic from
2381    /// the client's gateway falls through to the duplex; inbound
2382    /// envelope next hops addressed to procs on the server gateway
2383    /// are peeled at the via boundary and routed locally.
2384    #[tokio::test]
2385    async fn test_gateway_serve_via_peer() {
2386        // The server gateway accepts duplex attaches on a unix
2387        // address. Spawn a proc on it so the client has a destination
2388        // to reach.
2389        let server_gw = Gateway::new();
2390        let server_addr = ChannelAddr::any(ChannelTransport::Unix);
2391        let mut accept_handle = server_gw.serve_duplex(server_addr).unwrap();
2392        let server_addr = server_gw.default_location().addr().clone();
2393
2394        let server_proc = Proc::builder()
2395            .proc_id(ProcId::instance(Label::strip("echo")))
2396            .shared_gateway(server_gw.clone())
2397            .build()
2398            .unwrap();
2399        let server_inst = server_proc.client("recv");
2400        let (server_port, mut server_rx) = server_inst.bind_handler_port::<u64>();
2401        let PortLocation::Bound(server_dest) = server_port.location() else {
2402            panic!("server port must be bound");
2403        };
2404
2405        // The client gateway dials the server's accept endpoint.
2406        // After handshake, the client's default_location is wrapped in
2407        // Via(client_uid, server_addr).
2408        let client_gw = Gateway::new();
2409        let pre_default = client_gw.default_location();
2410        let serve_via = client_gw.serve_via(server_addr.clone()).await.unwrap();
2411        let post_default = client_gw.default_location();
2412        assert_ne!(
2413            pre_default, post_default,
2414            "serve_via must update default_location"
2415        );
2416        let (via_uid, inner) = post_default.as_via().expect("default must be via");
2417        assert_eq!(via_uid, client_gw.uid());
2418        assert_eq!(inner.addr(), &server_addr);
2419
2420        // Post directly to the server proc through the client gateway —
2421        // since the server proc is on the server gateway, the
2422        // envelope flows out via the duplex and the server peels at
2423        // the post_unchecked via-first path.
2424        let sender = test_actor_id("client", "sender");
2425        client_gw.post(
2426            MessageEnvelope::serialize(sender, server_dest.clone(), &7u64, Flattrs::new()).unwrap(),
2427            monitored_return_handle(),
2428        );
2429        let received = time::timeout(Duration::from_secs(5), server_rx.recv())
2430            .await
2431            .expect("server_rx timed out")
2432            .expect("server_rx closed");
2433        assert_eq!(received, 7);
2434
2435        // Drop the via handle: client's default_location is restored.
2436        drop(serve_via);
2437        assert_eq!(client_gw.default_location(), pre_default);
2438
2439        // Clean up the accept loop.
2440        accept_handle.stop("test cleanup");
2441    }
2442
2443    #[tokio::test]
2444    async fn test_gateway_serve_via_sessions_are_additive() {
2445        let server1_gw = Gateway::new();
2446        let mut accept1_handle = server1_gw
2447            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2448            .unwrap();
2449        let server1_addr = server1_gw.default_location().addr().clone();
2450        let server2_gw = Gateway::new();
2451        let mut accept2_handle = server2_gw
2452            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2453            .unwrap();
2454        let server2_addr = server2_gw.default_location().addr().clone();
2455
2456        let client_gw = Gateway::new();
2457        let pre_default = client_gw.default_location();
2458        let mut serve_via1 = client_gw.serve_via(server1_addr.clone()).await.unwrap();
2459        let via1_default = client_gw.default_location();
2460        assert_eq!(via1_default.addr(), &server1_addr);
2461
2462        let client_proc = Proc::legacy_service_pseudo_singleton_on_gateway(client_gw.clone());
2463        let client = client_proc.client("recv");
2464        let (client_port, mut client_rx) = client.bind_handler_port::<u64>();
2465        let PortLocation::Bound(old_client_dest) = client_port.location() else {
2466            panic!("client port must be bound");
2467        };
2468
2469        let mut serve_via2 = client_gw.serve_via(server2_addr.clone()).await.unwrap();
2470        let via2_default = client_gw.default_location();
2471        assert_eq!(via2_default.addr(), &server2_addr);
2472        assert_ne!(via1_default, via2_default);
2473
2474        server1_gw.post(
2475            MessageEnvelope::serialize(
2476                test_actor_id("server1", "sender"),
2477                old_client_dest,
2478                &11u64,
2479                Flattrs::new(),
2480            )
2481            .unwrap(),
2482            monitored_return_handle(),
2483        );
2484        let received = time::timeout(Duration::from_secs(5), client_rx.recv())
2485            .await
2486            .expect("client_rx timed out")
2487            .expect("client_rx closed");
2488        assert_eq!(received, 11);
2489
2490        serve_via2.stop("test cleanup");
2491        serve_via2.join().await.unwrap();
2492        assert_eq!(client_gw.default_location(), via1_default);
2493
2494        serve_via1.stop("test cleanup");
2495        serve_via1.join().await.unwrap();
2496        assert_eq!(client_gw.default_location(), pre_default);
2497
2498        accept1_handle.stop("test cleanup");
2499        accept1_handle.join().await.unwrap();
2500        accept2_handle.stop("test cleanup");
2501        accept2_handle.join().await.unwrap();
2502    }
2503
2504    #[tokio::test]
2505    async fn test_gateway_serve_via_duplicate_peer_reports_rejection() {
2506        let server_gw = Gateway::new();
2507        let mut accept_handle = server_gw
2508            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2509            .unwrap();
2510        let server_addr = server_gw.default_location().addr().clone();
2511
2512        let client_gw = Gateway::new();
2513        let mut serve_via = client_gw.serve_via(server_addr.clone()).await.unwrap();
2514        let via_default = client_gw.default_location();
2515
2516        let err = client_gw.serve_via(server_addr).await.unwrap_err();
2517        assert!(
2518            err.to_string()
2519                .contains("gateway already has a via peer with uid"),
2520            "unexpected error: {err:#}"
2521        );
2522        assert_eq!(client_gw.default_location(), via_default);
2523
2524        serve_via.stop("test cleanup");
2525        serve_via.join().await.unwrap();
2526        accept_handle.stop("test cleanup");
2527        accept_handle.join().await.unwrap();
2528    }
2529
2530    #[tokio::test]
2531    async fn test_gateway_serve_with_listener_takes_precedence_after_via() {
2532        let server_gw = Gateway::new();
2533        let mut accept_handle = server_gw
2534            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2535            .unwrap();
2536        let server_addr = server_gw.default_location().addr().clone();
2537        let _server_proc = Proc::legacy_service_pseudo_singleton_on_gateway(server_gw.clone());
2538
2539        let client_gw = Gateway::new();
2540        let pre_default = client_gw.default_location();
2541        let mut serve_via = client_gw.serve_via(server_addr).await.unwrap();
2542        let via_default = client_gw.default_location();
2543        assert!(via_default.as_via().is_some());
2544
2545        let mut frontend = client_gw
2546            .serve_with_listener(ChannelAddr::any(ChannelTransport::Unix), None)
2547            .unwrap();
2548        let frontend_location = client_gw.default_location();
2549        assert!(matches!(frontend_location.addr(), ChannelAddr::Unix(_)));
2550        assert_ne!(frontend_location, via_default);
2551
2552        frontend.stop("test cleanup");
2553        frontend.join().await.unwrap();
2554        assert_eq!(client_gw.default_location(), via_default);
2555
2556        serve_via.stop("test cleanup");
2557        serve_via.join().await.unwrap();
2558        assert_eq!(client_gw.default_location(), pre_default);
2559
2560        accept_handle.stop("test cleanup");
2561        accept_handle.join().await.unwrap();
2562    }
2563
2564    #[tokio::test]
2565    async fn test_set_default_location_updates_fallback_while_serving() {
2566        let gateway = Gateway::new();
2567        let mut serve =
2568            Gateway::serve(&gateway, ChannelAddr::any(ChannelTransport::Local)).unwrap();
2569        let served_location = gateway.default_location();
2570        let fallback_location = Location::from(ChannelAddr::Local(9876));
2571
2572        gateway.set_default_location(fallback_location.clone());
2573        assert_eq!(gateway.default_location(), served_location);
2574
2575        serve.stop("test cleanup");
2576        serve.join().await.unwrap();
2577        assert_eq!(gateway.default_location(), fallback_location);
2578    }
2579
2580    #[tokio::test]
2581    async fn test_gateway_serve_via_delivers_peeled_instance_proc_destination() {
2582        let server_gw = Gateway::new();
2583        let mut accept_handle = server_gw
2584            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2585            .unwrap();
2586        let server_addr = server_gw.default_location().addr().clone();
2587
2588        let client_gw = Gateway::new();
2589        let mut serve_via = client_gw.serve_via(server_addr).await.unwrap();
2590        let client_proc = Proc::builder()
2591            .proc_id(ProcId::anonymous())
2592            .shared_gateway(client_gw.clone())
2593            .build()
2594            .unwrap();
2595        let client = client_proc.client("recv");
2596        let (port, mut rx) = client.bind_handler_port::<u64>();
2597        let PortLocation::Bound(via_dest) = port.location() else {
2598            panic!("client port must be bound");
2599        };
2600
2601        server_gw.post(
2602            MessageEnvelope::serialize(
2603                test_actor_id("server", "sender"),
2604                via_dest,
2605                &7u64,
2606                Flattrs::new(),
2607            )
2608            .unwrap(),
2609            monitored_return_handle(),
2610        );
2611
2612        let received = time::timeout(Duration::from_secs(5), rx.recv())
2613            .await
2614            .expect("rx timed out")
2615            .expect("rx closed");
2616        assert_eq!(received, 7);
2617
2618        serve_via.stop("test cleanup");
2619        serve_via.join().await.unwrap();
2620        accept_handle.stop("test cleanup");
2621        accept_handle.join().await.unwrap();
2622    }
2623
2624    #[tokio::test]
2625    async fn test_gateway_serve_via_does_not_shadow_peer_legacy_proc() {
2626        let server_gw = Gateway::new();
2627        let mut accept_handle = server_gw
2628            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2629            .unwrap();
2630        let server_addr = server_gw.default_location().addr().clone();
2631        let server_proc = Proc::legacy_service_pseudo_singleton_on_gateway(server_gw.clone());
2632        let server = server_proc.client("recv");
2633        let (server_port, mut server_rx) = server.bind_handler_port::<u64>();
2634        let PortLocation::Bound(server_dest) = server_port.location() else {
2635            panic!("server port must be bound");
2636        };
2637
2638        let client_gw = Gateway::new();
2639        let mut serve_via = client_gw.serve_via(server_addr).await.unwrap();
2640        let client_proc = Proc::legacy_service_pseudo_singleton_on_gateway(client_gw.clone());
2641        let (client_shadow_tx, mut client_shadow_rx) = mpsc::unbounded_channel();
2642        assert!(
2643            client_proc.muxer().bind(
2644                server_dest.actor_addr().id().clone(),
2645                RecordingSender(client_shadow_tx),
2646            ),
2647            "client shadow handler should bind"
2648        );
2649
2650        client_gw.post(
2651            MessageEnvelope::serialize(
2652                test_actor_id("client", "sender"),
2653                server_dest.clone(),
2654                &7u64,
2655                Flattrs::new(),
2656            )
2657            .unwrap(),
2658            monitored_return_handle(),
2659        );
2660
2661        let received = time::timeout(Duration::from_secs(5), server_rx.recv())
2662            .await
2663            .expect("server_rx timed out")
2664            .expect("server_rx closed");
2665        assert_eq!(received, 7);
2666        time::timeout(Duration::from_millis(200), client_shadow_rx.recv())
2667            .await
2668            .expect_err("peer legacy proc location must not deliver to the attached client proc");
2669
2670        serve_via.stop("test cleanup");
2671        serve_via.join().await.unwrap();
2672        accept_handle.stop("test cleanup");
2673        accept_handle.join().await.unwrap();
2674    }
2675
2676    #[tokio::test]
2677    async fn test_gateway_serve_via_relay_delivers_to_client_instance_proc() {
2678        let relay_gw = Gateway::new();
2679        let mut accept_handle = relay_gw
2680            .serve_duplex(ChannelAddr::any(ChannelTransport::Unix))
2681            .unwrap();
2682        let relay_addr = relay_gw.default_location().addr().clone();
2683
2684        let client_gw = Gateway::new();
2685        let mut serve_via = client_gw.serve_via(relay_addr.clone()).await.unwrap();
2686        let client_proc = Proc::builder()
2687            .proc_id(ProcId::anonymous())
2688            .shared_gateway(client_gw.clone())
2689            .build()
2690            .unwrap();
2691        let client = client_proc.client("recv");
2692        let (port, mut rx) = client.bind_handler_port::<u64>();
2693        let PortLocation::Bound(via_dest) = port.location() else {
2694            panic!("client port must be bound");
2695        };
2696
2697        // A third gateway with no direct peer relationship to the
2698        // client can still reach client-local refs by dialing the
2699        // relay's raw address while preserving the canonical destination. The
2700        // relay peels `Via(client_uid, relay_addr)` from the routing
2701        // destination and forwards over the attached duplex; the client then
2702        // delivers against canonical `local@Via(client_uid, relay_addr)`.
2703        let relay_dialer = DialMailboxRouter::new();
2704        relay_dialer.post(
2705            MessageEnvelope::serialize(
2706                test_actor_id("third_party", "sender"),
2707                via_dest,
2708                &7u64,
2709                Flattrs::new(),
2710            )
2711            .unwrap(),
2712            monitored_return_handle(),
2713        );
2714
2715        let received = time::timeout(Duration::from_secs(5), rx.recv())
2716            .await
2717            .expect("rx timed out")
2718            .expect("rx closed");
2719        assert_eq!(received, 7);
2720
2721        serve_via.stop("test cleanup");
2722        serve_via.join().await.unwrap();
2723        accept_handle.stop("test cleanup");
2724        accept_handle.join().await.unwrap();
2725    }
2726
2727    /// `Gateway::attach(&Gateway)` is purely via-based:
2728    /// 1. Each gateway's `default_location` becomes a `Via` form
2729    ///    that advertises destinations through the peer.
2730    /// 2. Procs bound *after* attach inherit the via prefix and
2731    ///    route across the link in both directions without any
2732    ///    per-id registration.
2733    /// 3. On guard drop: restore both `default_location`s and
2734    ///    remove the peer entries.
2735    ///
2736    /// No snapshot cross-bind: pre-attach destinations are not
2737    /// reachable across the link because their addresses lack the
2738    /// via prefix. (Motivating use case: a client attached to a
2739    /// host inside a kubernetes cluster cannot dial the cluster's
2740    /// internal addresses directly, so any "shortcut" route would
2741    /// be unreachable.)
2742    #[tokio::test]
2743    async fn test_gateway_attach_bidirectional() {
2744        use crate::testing::ids::test_actor_id;
2745
2746        let gw_a = Gateway::isolated();
2747        let pre_a_default = gw_a.default_location();
2748        let gw_b = Gateway::isolated();
2749        let pre_b_default = gw_b.default_location();
2750
2751        // Connect the two gateways before binding any procs, so we
2752        // exercise the via-only path. (Pre-attach procs are not
2753        // reachable across the link by design.)
2754        let attach = gw_a.attach(&gw_b);
2755
2756        // default_location on each side is now Via(self_uid, peer_default).
2757        let post_a_default = gw_a.default_location();
2758        let post_b_default = gw_b.default_location();
2759        let (uid_a, inner_a) = post_a_default.as_via().expect("gw_a default is via");
2760        assert_eq!(uid_a, gw_a.uid());
2761        assert_eq!(inner_a.as_ref(), &pre_b_default);
2762        let (uid_b, inner_b) = post_b_default.as_via().expect("gw_b default is via");
2763        assert_eq!(uid_b, gw_b.uid());
2764        assert_eq!(inner_b.as_ref(), &pre_a_default);
2765
2766        // Bind procs on each side *after* attach so their port
2767        // addresses inherit the via prefix.
2768        let proc_a = Proc::builder()
2769            .proc_id(ProcId::instance(Label::strip("alpha")))
2770            .shared_gateway(gw_a.clone())
2771            .build()
2772            .unwrap();
2773        let alpha_client = proc_a.client("client");
2774        let (alpha_port, mut alpha_rx) = alpha_client.bind_handler_port::<u64>();
2775        let PortLocation::Bound(alpha_dest) = alpha_port.location() else {
2776            panic!("alpha port must be bound");
2777        };
2778        assert!(
2779            alpha_dest.location().as_via().is_some(),
2780            "alpha carries via prefix"
2781        );
2782
2783        let proc_b = Proc::builder()
2784            .proc_id(ProcId::instance(Label::strip("beta")))
2785            .shared_gateway(gw_b.clone())
2786            .build()
2787            .unwrap();
2788        let beta_client = proc_b.client("client");
2789        let (beta_port, mut beta_rx) = beta_client.bind_handler_port::<u64>();
2790        let PortLocation::Bound(beta_dest) = beta_port.location() else {
2791            panic!("beta port must be bound");
2792        };
2793        assert!(
2794            beta_dest.location().as_via().is_some(),
2795            "beta carries via prefix"
2796        );
2797
2798        // gw_a → alpha: gw_a consumes its own outermost Via from the routing
2799        // destination and delivers locally.
2800        let sender = test_actor_id("client", "sender");
2801        gw_a.post(
2802            MessageEnvelope::serialize(sender.clone(), alpha_dest.clone(), &11u64, Flattrs::new())
2803                .unwrap(),
2804            monitored_return_handle(),
2805        );
2806        assert_eq!(
2807            time::timeout(Duration::from_secs(2), alpha_rx.recv())
2808                .await
2809                .expect("alpha_rx timed out")
2810                .expect("alpha_rx closed"),
2811            11
2812        );
2813
2814        // gw_b → alpha: gw_b's post sees Via(gw_a.uid, ..), finds
2815        // gw_a in gw_b.peers, peels, forwards to gw_a.
2816        gw_b.post(
2817            MessageEnvelope::serialize(sender.clone(), alpha_dest.clone(), &22u64, Flattrs::new())
2818                .unwrap(),
2819            monitored_return_handle(),
2820        );
2821        assert_eq!(
2822            time::timeout(Duration::from_secs(2), alpha_rx.recv())
2823                .await
2824                .expect("alpha_rx timed out")
2825                .expect("alpha_rx closed"),
2826            22
2827        );
2828
2829        // gw_a → beta: symmetric direction.
2830        gw_a.post(
2831            MessageEnvelope::serialize(sender.clone(), beta_dest.clone(), &33u64, Flattrs::new())
2832                .unwrap(),
2833            monitored_return_handle(),
2834        );
2835        assert_eq!(
2836            time::timeout(Duration::from_secs(2), beta_rx.recv())
2837                .await
2838                .expect("beta_rx timed out")
2839                .expect("beta_rx closed"),
2840            33
2841        );
2842
2843        // Dropping the AttachGuard restores default_location and
2844        // removes the peer entries.
2845        drop(attach);
2846        assert_eq!(gw_a.default_location(), pre_a_default);
2847        assert_eq!(gw_b.default_location(), pre_b_default);
2848        assert!(gw_a.inner.peers.read().unwrap().is_empty());
2849        assert!(gw_b.inner.peers.read().unwrap().is_empty());
2850    }
2851}