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