use crate::inbox::{BusMessage, Inbox};
use crate::reply::CorrelationId;
use crate::transport::{Inbound, ReplyRoute, Transport};
use crate::{BusError, Result, Topic};
use agent_mesh_discovery::{AnnounceConfig, Announcer, AnnouncerHandle};
use agent_mesh_protocol::{AgentKey, CertChain, Fingerprint, Recipient, SignedEnvelope, UserKey};
use agent_mesh_transport::{
do_handshake,
identity::agent_pubkey_to_iroh,
iroh_reexports::{Connection, Incoming, IncomingAddr, PublicKey},
recv_envelope, send_envelope, Endpoint, PeerResolver, ResolverHandle, TransportError,
};
use async_trait::async_trait;
use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex};
use tokio::task::JoinHandle;
type IrohReverse = Option<(PublicKey, SocketAddr)>;
const RESOLVE_TIMEOUT: Duration = Duration::from_secs(5);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct BusOptions {
pub announce: bool,
}
impl Default for BusOptions {
fn default() -> Self {
Self { announce: true }
}
}
#[derive(Debug, Clone, Copy)]
pub struct PeerEndpoint {
pub agent_pubkey: [u8; 32],
pub addr: SocketAddr,
}
impl PeerEndpoint {
#[must_use]
pub fn new(agent_pubkey: [u8; 32], addr: SocketAddr) -> Self {
Self { agent_pubkey, addr }
}
#[must_use]
pub fn from_parts(agent_pubkey: [u8; 32], ip: IpAddr, port: u16) -> Self {
Self::new(agent_pubkey, SocketAddr::new(ip, port))
}
#[must_use]
pub fn fingerprint(&self) -> Fingerprint {
Fingerprint::of_bytes(&self.agent_pubkey)
}
}
pub struct Bus {
agent: Arc<AgentKey>,
user_fp: Fingerprint,
transport: Arc<dyn Transport>,
inbox: Arc<Inbox>,
sequence: Arc<AtomicU64>,
accept_task: JoinHandle<()>,
}
impl Bus {
pub async fn bind(user: &UserKey, agent: AgentKey, port: u16) -> Result<Self> {
Self::bind_with(user, agent, port, BusOptions::default()).await
}
pub async fn bind_with(
user: &UserKey,
agent: AgentKey,
port: u16,
opts: BusOptions,
) -> Result<Self> {
let user_fp = user.fingerprint();
let agent = Arc::new(agent);
let transport = IrohTransport::bind(user_fp, agent.clone(), port, opts).await?;
Ok(Self::bind_with_transport(
agent,
user_fp,
Arc::new(transport),
))
}
pub fn bind_with_transport(
agent: Arc<AgentKey>,
user_fp: Fingerprint,
transport: Arc<dyn Transport>,
) -> Self {
let inbox = Arc::new(Inbox::new());
let sequence = Arc::new(AtomicU64::new(1));
let accept_task = spawn_accept_loop(
transport.clone(),
agent.clone(),
inbox.clone(),
sequence.clone(),
);
Self {
agent,
user_fp,
transport,
inbox,
sequence,
accept_task,
}
}
#[must_use]
pub fn user_fingerprint(&self) -> Fingerprint {
self.user_fp
}
#[must_use]
pub fn agent_fingerprint(&self) -> Fingerprint {
self.agent.fingerprint()
}
#[must_use]
pub fn local_port(&self) -> u16 {
self.transport.local_port()
}
pub async fn request(
&self,
peer_fp: Fingerprint,
topic: &Topic,
body: Vec<u8>,
timeout: Duration,
) -> Result<Vec<u8>> {
self.request_via(DialRoute::Resolve(peer_fp), topic, body, timeout)
.await
}
pub async fn request_direct(
&self,
peer: PeerEndpoint,
topic: &Topic,
body: Vec<u8>,
timeout: Duration,
) -> Result<Vec<u8>> {
self.request_via(DialRoute::Direct(peer), topic, body, timeout)
.await
}
async fn request_via(
&self,
route: DialRoute,
topic: &Topic,
body: Vec<u8>,
timeout: Duration,
) -> Result<Vec<u8>> {
let correlation = CorrelationId::new_random();
let waiter = self.inbox.register_reply(correlation);
let msg = BusMessage::Request {
topic: topic.wire(),
correlation: correlation.0,
body,
};
if let Err(e) = self.send_via(route, msg).await {
self.inbox.cancel_reply(&correlation);
return Err(e);
}
match tokio::time::timeout(timeout, waiter).await {
Ok(Ok(payload)) => Ok(payload),
Ok(Err(_)) => Err(BusError::LostReply),
Err(_) => {
self.inbox.cancel_reply(&correlation);
Err(BusError::Timeout(timeout))
}
}
}
pub fn handle_requests<F, Fut>(&self, topic: Topic, handler: F)
where
F: Fn(Vec<u8>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Vec<u8>>> + Send + 'static,
{
self.inbox.register_handler(topic, handler);
}
pub async fn publish_to(
&self,
peer_fp: Fingerprint,
topic: &Topic,
body: Vec<u8>,
) -> Result<()> {
let msg = BusMessage::Publish {
topic: topic.wire(),
body,
};
self.send_via(DialRoute::Resolve(peer_fp), msg).await
}
pub async fn publish_to_direct(
&self,
peer: PeerEndpoint,
topic: &Topic,
body: Vec<u8>,
) -> Result<()> {
let msg = BusMessage::Publish {
topic: topic.wire(),
body,
};
self.send_via(DialRoute::Direct(peer), msg).await
}
pub async fn subscribe(&self, topic: &Topic) -> broadcast::Receiver<Vec<u8>> {
self.inbox.subscribe(topic).await
}
pub async fn close(self) -> Result<()> {
self.accept_task.abort();
self.transport.close().await;
Ok(())
}
async fn send_via(&self, route: DialRoute, msg: BusMessage) -> Result<()> {
match route {
DialRoute::Resolve(peer_fp) => {
let env = make_envelope(&self.agent, &self.sequence, peer_fp, msg)?;
self.transport.send_to(peer_fp, env).await
}
DialRoute::Direct(peer) => {
let env = make_envelope(&self.agent, &self.sequence, peer.fingerprint(), msg)?;
self.transport.send_to_endpoint(&peer, env).await
}
}
}
}
fn make_envelope(
agent: &AgentKey,
sequence: &AtomicU64,
peer_fp: Fingerprint,
msg: BusMessage,
) -> Result<SignedEnvelope> {
let seq = sequence.fetch_add(1, Ordering::SeqCst);
let payload = serde_json::to_vec(&msg)?;
Ok(SignedEnvelope::new(
agent,
Recipient::Direct { agent_fp: peer_fp },
seq,
payload,
))
}
#[derive(Debug, Clone, Copy)]
enum DialRoute {
Resolve(Fingerprint),
Direct(PeerEndpoint),
}
async fn dial_endpoint(endpoint: &Endpoint, peer: PeerEndpoint) -> Result<Connection> {
let iroh_pk = agent_pubkey_to_iroh(&peer.agent_pubkey).ok_or_else(|| {
BusError::Unreachable(format!(
"direct-dial endpoint {} has an invalid ed25519 pubkey",
peer.fingerprint().short()
))
})?;
tracing::debug!(
peer = %peer.fingerprint().short(),
addr = %peer.addr,
"bus: direct-dialing peer endpoint (no mDNS)"
);
let conn = endpoint.dial(iroh_pk, [peer.addr]).await?;
Ok(conn)
}
fn is_scopeless_link_local(addr: &SocketAddr) -> bool {
match addr {
SocketAddr::V6(v6) => (v6.ip().segments()[0] & 0xffc0) == 0xfe80 && v6.scope_id() == 0,
SocketAddr::V4(_) => false,
}
}
fn reply_dial_candidates(source: SocketAddr) -> Vec<SocketAddr> {
let port = source.port();
let mut candidates: Vec<SocketAddr> = Vec::with_capacity(3);
for addr in [
source,
SocketAddr::new(std::net::Ipv4Addr::LOCALHOST.into(), port),
SocketAddr::new(std::net::Ipv6Addr::LOCALHOST.into(), port),
] {
if is_scopeless_link_local(&addr) {
continue;
}
if !candidates.contains(&addr) {
candidates.push(addr);
}
}
candidates
}
async fn dial_peer(
endpoint: &Endpoint,
resolver: &PeerResolver,
peer_fp: Fingerprint,
) -> Result<Connection> {
let peer = resolver
.resolve(&peer_fp, RESOLVE_TIMEOUT)
.await
.ok_or_else(|| {
BusError::Unreachable(format!(
"peer {} not announced within {:?}",
peer_fp.short(),
RESOLVE_TIMEOUT
))
})?;
let pubkey = peer.agent_pubkey.ok_or_else(|| {
BusError::Unreachable(format!(
"peer {} announced without ed25519 pubkey",
peer_fp.short()
))
})?;
let iroh_pk = agent_pubkey_to_iroh(&pubkey).ok_or_else(|| {
BusError::Unreachable(format!(
"peer {} advertised invalid ed25519 pubkey",
peer_fp.short()
))
})?;
let mut socket_addrs: Vec<SocketAddr> = peer
.addrs
.iter()
.copied()
.map(|ip| SocketAddr::new(ip, peer.port))
.collect();
let lo_v4: std::net::IpAddr = std::net::Ipv4Addr::LOCALHOST.into();
let lo_v6: std::net::IpAddr = std::net::Ipv6Addr::LOCALHOST.into();
let lo_addrs = [
SocketAddr::new(lo_v4, peer.port),
SocketAddr::new(lo_v6, peer.port),
];
for addr in lo_addrs {
if !socket_addrs.contains(&addr) {
socket_addrs.push(addr);
}
}
socket_addrs.retain(|addr| !is_scopeless_link_local(addr));
tracing::debug!(
peer = %peer_fp.short(),
addrs = ?socket_addrs,
"bus: dialing peer"
);
if socket_addrs.is_empty() {
return Err(BusError::Unreachable(format!(
"peer {} announced without socket addresses",
peer_fp.short()
)));
}
let conn = endpoint.dial(iroh_pk, socket_addrs).await?;
Ok(conn)
}
async fn send_env_on_conn(
conn: &Connection,
our_cert: &CertChain,
env: &SignedEnvelope,
) -> Result<()> {
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| BusError::Transport(TransportError::Iroh(format!("open_bi: {e}"))))?;
tokio::time::timeout(
HANDSHAKE_TIMEOUT,
do_handshake(our_cert, &mut send, &mut recv, true),
)
.await
.map_err(|_| BusError::Timeout(HANDSHAKE_TIMEOUT))??;
send_envelope(&mut send, env).await?;
send.finish()
.map_err(|e| BusError::Transport(TransportError::Iroh(format!("finish: {e}"))))?;
let _ = send.stopped().await;
Ok(())
}
fn spawn_accept_loop(
transport: Arc<dyn Transport>,
agent: Arc<AgentKey>,
inbox: Arc<Inbox>,
sequence: Arc<AtomicU64>,
) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(inbound) = transport.recv().await {
let transport = transport.clone();
let agent = agent.clone();
let inbox = inbox.clone();
let sequence = sequence.clone();
tokio::spawn(async move {
let Inbound {
envelope,
reply_route,
} = inbound;
match inbox.on_envelope(envelope).await {
Ok(Some(reply)) => {
let msg = BusMessage::Reply {
correlation: reply.correlation.0,
body: reply.body,
};
match make_envelope(&agent, &sequence, reply.peer_fp, msg) {
Ok(env) => {
if let Err(e) =
transport.reply(reply.peer_fp, &reply_route, env).await
{
tracing::warn!(error = %e, "bus: reply ship failed");
}
}
Err(e) => tracing::warn!(error = %e, "bus: reply encode failed"),
}
}
Ok(None) => {}
Err(e) => tracing::warn!(error = %e, "bus: inbox rejected envelope"),
}
});
}
tracing::debug!("bus accept loop: transport closed");
})
}
async fn dial_reply_peer(
endpoint: &Endpoint,
resolver: &PeerResolver,
peer_fp: Fingerprint,
reverse: IrohReverse,
) -> Result<Connection> {
if let Some((pubkey, addr)) = reverse {
if Fingerprint::of_bytes(pubkey.as_bytes()) == peer_fp {
let candidates = reply_dial_candidates(addr);
if !candidates.is_empty() {
match endpoint.dial(pubkey, candidates.iter().copied()).await {
Ok(conn) => return Ok(conn),
Err(e) => tracing::debug!(
peer = %peer_fp.short(),
?candidates,
error = %e,
"bus: reply dial-back failed, falling back to mDNS"
),
}
}
} else {
tracing::warn!(
peer = %peer_fp.short(),
"bus: connection key does not match envelope sender; ignoring dial-back route"
);
}
}
dial_peer(endpoint, resolver, peer_fp).await
}
pub struct IrohTransport {
endpoint: Arc<Endpoint>,
resolver: Arc<PeerResolver>,
agent: Arc<AgentKey>,
_resolver_handle: ResolverHandle,
_announcer: Option<AnnouncerHandle>,
inbound: AsyncMutex<mpsc::UnboundedReceiver<Inbound>>,
accept_task: JoinHandle<()>,
}
impl IrohTransport {
pub async fn bind(
user_fp: Fingerprint,
agent: Arc<AgentKey>,
port: u16,
opts: BusOptions,
) -> Result<Self> {
let endpoint = Endpoint::bind(&agent, port).await?;
let local_port = endpoint.port();
let endpoint = Arc::new(endpoint);
let (resolver, resolver_handle) = PeerResolver::start()?;
let resolver = Arc::new(resolver);
let announcer = if opts.announce {
Some(
Announcer::start(AnnounceConfig {
agent_fp: agent.fingerprint(),
agent_pubkey: Some(agent.public_bytes()),
user_fp,
capabilities: agent.cert().metadata.capabilities.clone(),
role: agent.cert().metadata.role.clone(),
host: agent.cert().metadata.host.clone(),
port: local_port,
})
.map_err(|e| {
BusError::Transport(TransportError::Iroh(format!("announce start: {e}")))
})?,
)
} else {
None
};
let (inbound_tx, inbound_rx) = mpsc::unbounded_channel();
let accept_task = spawn_iroh_accept_loop(endpoint.clone(), agent.clone(), inbound_tx);
Ok(Self {
endpoint,
resolver,
agent,
_resolver_handle: resolver_handle,
_announcer: announcer,
inbound: AsyncMutex::new(inbound_rx),
accept_task,
})
}
}
#[async_trait]
impl Transport for IrohTransport {
async fn send_to(&self, fp: Fingerprint, env: SignedEnvelope) -> Result<()> {
let conn = dial_peer(&self.endpoint, &self.resolver, fp).await?;
send_env_on_conn(&conn, self.agent.cert(), &env).await
}
async fn send_to_endpoint(&self, peer: &PeerEndpoint, env: SignedEnvelope) -> Result<()> {
let conn = dial_endpoint(&self.endpoint, *peer).await?;
send_env_on_conn(&conn, self.agent.cert(), &env).await
}
async fn reply(&self, fp: Fingerprint, route: &ReplyRoute, env: SignedEnvelope) -> Result<()> {
let reverse: IrohReverse = route.downcast_ref::<IrohReverse>().cloned().flatten();
let conn = dial_reply_peer(&self.endpoint, &self.resolver, fp, reverse).await?;
send_env_on_conn(&conn, self.agent.cert(), &env).await
}
async fn recv(&self) -> Option<Inbound> {
self.inbound.lock().await.recv().await
}
fn local_port(&self) -> u16 {
self.endpoint.port()
}
async fn close(&self) {
self.accept_task.abort();
}
}
fn spawn_iroh_accept_loop(
endpoint: Arc<Endpoint>,
agent: Arc<AgentKey>,
inbound_tx: mpsc::UnboundedSender<Inbound>,
) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
let Some(incoming) = endpoint.accept().await else {
tracing::debug!("iroh transport accept loop: endpoint closed");
break;
};
let agent = agent.clone();
let inbound_tx = inbound_tx.clone();
tokio::spawn(async move {
if let Err(e) = accept_conn(incoming, agent, inbound_tx).await {
tracing::warn!(error = %e, "iroh transport: incoming connection error");
}
});
}
})
}
async fn accept_conn(
incoming: Incoming,
agent: Arc<AgentKey>,
inbound_tx: mpsc::UnboundedSender<Inbound>,
) -> Result<()> {
let reverse_addr = match incoming.remote_addr() {
IncomingAddr::Ip(addr) => Some(addr),
_ => None,
};
let conn = incoming
.await
.map_err(|e| BusError::Transport(TransportError::Iroh(format!("incoming: {e}"))))?;
let reverse: IrohReverse = reverse_addr.map(|addr| (conn.remote_id(), addr));
let reply_route: ReplyRoute = Arc::new(reverse);
loop {
let (mut send, mut recv) = match conn.accept_bi().await {
Ok(streams) => streams,
Err(e) => {
tracing::debug!(error = %e, "iroh transport: accept_bi ended (peer closed)");
return Ok(());
}
};
let cert = agent.cert().clone();
match tokio::time::timeout(
HANDSHAKE_TIMEOUT,
do_handshake(&cert, &mut send, &mut recv, false),
)
.await
{
Ok(Ok(_peer_cert)) => {}
Ok(Err(e)) => {
tracing::warn!(error = %e, "iroh transport: handshake rejected");
continue;
}
Err(_) => {
tracing::warn!("iroh transport: handshake timed out");
continue;
}
}
let env = match recv_envelope(&mut recv).await {
Ok(env) => env,
Err(e) => {
tracing::warn!(error = %e, "iroh transport: envelope read failed");
continue;
}
};
if inbound_tx
.send(Inbound {
envelope: env,
reply_route: reply_route.clone(),
})
.is_err()
{
tracing::debug!("iroh transport: bus receiver dropped; stopping");
return Ok(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::MeshNet;
use agent_mesh_protocol::{AgentMetadata, Caveats, UserKey};
fn agent(user: &UserKey, role: &str) -> AgentKey {
AgentKey::issue(
user,
AgentMetadata {
role: role.into(),
host: "test".into(),
capabilities: vec!["test".into()],
issued_at: "2026-05-28T00:00:00Z".into(),
expires_at: None,
caveats: Caveats::top(),
},
)
}
#[tokio::test]
async fn request_reply_roundtrip_over_in_memory_transport() {
let user = UserKey::generate();
let alice = Arc::new(agent(&user, "alice"));
let bob = Arc::new(agent(&user, "bob"));
let alice_fp = alice.fingerprint();
let bob_fp = bob.fingerprint();
let net = MeshNet::new();
let alice_bus = Bus::bind_with_transport(
alice,
user.fingerprint(),
Arc::new(net.transport_for(alice_fp)),
);
let bob_bus =
Bus::bind_with_transport(bob, user.fingerprint(), Arc::new(net.transport_for(bob_fp)));
let topic = Topic::new(user.fingerprint(), "echo");
bob_bus.handle_requests(topic.clone(), |body| async move {
Ok(format!("echo: {}", String::from_utf8_lossy(&body)).into_bytes())
});
let reply = alice_bus
.request(bob_fp, &topic, b"hi".to_vec(), Duration::from_secs(5))
.await
.expect("round-trip reply");
assert_eq!(reply, b"echo: hi");
alice_bus.close().await.unwrap();
bob_bus.close().await.unwrap();
}
#[tokio::test]
async fn handle_requests_registers_synchronously_no_spawn_race() {
let user = UserKey::generate();
let bob = Arc::new(agent(&user, "bob"));
let bob_fp = bob.fingerprint();
let net = MeshNet::new();
let bob_bus =
Bus::bind_with_transport(bob, user.fingerprint(), Arc::new(net.transport_for(bob_fp)));
let topic = Topic::new(user.fingerprint(), "echo");
assert_eq!(
bob_bus.inbox.handler_count(),
0,
"no handler registered before handle_requests"
);
bob_bus.handle_requests(topic, |body| async move { Ok(body) });
assert_eq!(
bob_bus.inbox.handler_count(),
1,
"handle_requests must register the handler before returning"
);
bob_bus.close().await.unwrap();
}
#[tokio::test]
async fn in_memory_send_to_unknown_peer_is_unreachable() {
let user = UserKey::generate();
let alice = Arc::new(agent(&user, "alice"));
let alice_fp = alice.fingerprint();
let net = MeshNet::new();
let alice_bus = Bus::bind_with_transport(
alice,
user.fingerprint(),
Arc::new(net.transport_for(alice_fp)),
);
let topic = Topic::new(user.fingerprint(), "echo");
let phantom = Fingerprint([0xfeu8; 32]);
match alice_bus
.request(phantom, &topic, b"x".to_vec(), Duration::from_millis(200))
.await
{
Err(BusError::Unreachable(_)) => {}
other => panic!("expected Unreachable, got {other:?}"),
}
alice_bus.close().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn bind_exposes_local_port_and_fingerprints() {
let user = UserKey::generate();
let a = agent(&user, "worker");
let a_fp = a.fingerprint();
let bus = Bus::bind(&user, a, 0).await.expect("bind");
assert!(bus.local_port() > 0);
assert_eq!(bus.user_fingerprint(), user.fingerprint());
assert_eq!(bus.agent_fingerprint(), a_fp);
bus.close().await.expect("close");
}
#[tokio::test(flavor = "multi_thread")]
async fn request_to_unknown_peer_errors_unreachable() {
let user = UserKey::generate();
let a = agent(&user, "alice");
let bus = Bus::bind(&user, a, 0).await.expect("bind");
let topic = Topic::new(user.fingerprint(), "echo");
let phantom_fp = Fingerprint([0xfeu8; 32]);
let res = bus
.request(
phantom_fp,
&topic,
b"x".to_vec(),
Duration::from_millis(200),
)
.await;
match res {
Err(BusError::Unreachable(_)) => {}
other => panic!("expected Unreachable, got {other:?}"),
}
bus.close().await.unwrap();
}
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6};
fn lo_v4(port: u16) -> SocketAddr {
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), port)
}
fn lo_v6(port: u16) -> SocketAddr {
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), port)
}
#[test]
fn scopeless_link_local_source_dropped_loopback_survives() {
let src: SocketAddr = "[fe80::1]:9000".parse().unwrap(); let cands = reply_dial_candidates(src);
assert!(!cands.is_empty(), "identity must retain a usable path");
assert!(
!cands.contains(&src),
"scopeless link-local is undialable; must be filtered out"
);
assert!(
cands.contains(&lo_v4(9000)),
"same-host v4 loopback must be a candidate"
);
assert!(
cands.contains(&lo_v6(9000)),
"same-host v6 loopback must be a candidate"
);
}
#[test]
fn scoped_link_local_source_is_kept() {
let ip: Ipv6Addr = "fe80::1".parse().unwrap();
let src = SocketAddr::V6(SocketAddrV6::new(ip, 9000, 0, 3)); let cands = reply_dial_candidates(src);
assert!(
cands.contains(&src),
"a scoped link-local is dialable; keep it"
);
}
#[test]
fn routable_source_yields_source_plus_loopback() {
let src: SocketAddr = "192.0.2.50:9000".parse().unwrap(); let cands = reply_dial_candidates(src);
assert!(cands.contains(&src));
assert!(cands.contains(&lo_v4(9000)));
assert!(cands.contains(&lo_v6(9000)));
assert!(cands.len() >= 2, "never a single load-bearing address");
}
#[test]
fn loopback_source_not_duplicated() {
let src = lo_v4(9000);
let cands = reply_dial_candidates(src);
assert_eq!(
cands.iter().filter(|a| **a == src).count(),
1,
"no duplicate loopback candidate"
);
}
#[test]
fn scopeless_link_local_predicate_boundaries() {
assert!(is_scopeless_link_local(&"[fe80::abcd]:1".parse().unwrap()));
assert!(is_scopeless_link_local(&"[febf::1]:1".parse().unwrap())); let scoped = SocketAddr::V6(SocketAddrV6::new("fe80::1".parse().unwrap(), 1, 0, 2));
assert!(!is_scopeless_link_local(&scoped));
assert!(!is_scopeless_link_local(&"[::1]:1".parse().unwrap()));
assert!(!is_scopeless_link_local(
&"[2001:db8::1]:1".parse().unwrap()
));
assert!(!is_scopeless_link_local(&"127.0.0.1:1".parse().unwrap()));
assert!(!is_scopeless_link_local(&"[fec0::1]:1".parse().unwrap())); }
async fn observe_and_dial_back(target_ip: IpAddr) -> (SocketAddr, bool) {
let user = UserKey::generate();
let asker = agent(&user, "probe-asker");
let responder = agent(&user, "probe-responder");
let a_ep = Arc::new(Endpoint::bind(&asker, 0).await.expect("bind asker"));
let b_ep = Arc::new(Endpoint::bind(&responder, 0).await.expect("bind responder"));
let a_pubkey = a_ep.public_key();
let b_pubkey = b_ep.public_key();
let target = SocketAddr::new(target_ip, b_ep.port());
let a_accept = a_ep.clone();
let a_task = tokio::spawn(async move {
while let Some(incoming) = a_accept.accept().await {
tokio::spawn(async move {
let _ = incoming.await;
});
}
});
let (tx, rx) = tokio::sync::oneshot::channel::<Option<SocketAddr>>();
let b_accept = b_ep.clone();
let b_task = tokio::spawn(async move {
if let Some(incoming) = b_accept.accept().await {
let src = match incoming.remote_addr() {
IncomingAddr::Ip(addr) => Some(addr),
_ => None,
};
let _ = tx.send(src);
let _ = incoming.await;
}
});
a_ep.dial(b_pubkey, [target])
.await
.expect("asker dials responder over the chosen local path");
let observed = tokio::time::timeout(Duration::from_secs(5), rx)
.await
.expect("responder observed a source within 5s")
.expect("observation channel not dropped")
.expect("iroh reported an IP source (not a relay path)");
let candidates = reply_dial_candidates(observed);
assert!(
!candidates.is_empty(),
"candidate set must never be empty for a same-host peer"
);
let dial_back_ok = b_ep
.dial(a_pubkey, candidates.iter().copied())
.await
.is_ok();
b_task.abort();
a_task.abort();
(observed, dial_back_ok)
}
#[tokio::test(flavor = "multi_thread")]
async fn probe_loopback_v4_source_is_dialable_and_reconnects() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let (observed, dial_back_ok) = observe_and_dial_back(Ipv4Addr::LOCALHOST.into()).await;
eprintln!("[#61 probe] loopback-v4 dial → iroh observed source = {observed}");
assert!(
!is_scopeless_link_local(&observed),
"a same-host loopback source must not be an undialable scopeless link-local"
);
assert!(
dial_back_ok,
"responder must reconnect to the asker over reply_dial_candidates({observed})"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn probe_loopback_v6_source_is_dialable_and_reconnects() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let (observed, dial_back_ok) = observe_and_dial_back(Ipv6Addr::LOCALHOST.into()).await;
eprintln!("[#61 probe] loopback-v6 dial → iroh observed source = {observed}");
assert!(
!is_scopeless_link_local(&observed),
"a same-host loopback source must not be an undialable scopeless link-local"
);
assert!(
dial_back_ok,
"responder must reconnect to the asker over reply_dial_candidates({observed})"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn probe_scopeless_link_local_source_reconnects_via_loopback_fallback() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let user = UserKey::generate();
let asker = agent(&user, "probe-asker");
let responder = agent(&user, "probe-responder");
let a_ep = Arc::new(Endpoint::bind(&asker, 0).await.expect("bind asker"));
let b_ep = Arc::new(Endpoint::bind(&responder, 0).await.expect("bind responder"));
let a_accept = a_ep.clone();
let a_task = tokio::spawn(async move {
while let Some(incoming) = a_accept.accept().await {
tokio::spawn(async move {
let _ = incoming.await;
});
}
});
let scopeless_source = SocketAddr::V6(SocketAddrV6::new(
"fe80::dead:beef".parse().unwrap(),
a_ep.port(),
0,
0, ));
assert!(
is_scopeless_link_local(&scopeless_source),
"precondition: the source is the undialable #61 shape"
);
let candidates = reply_dial_candidates(scopeless_source);
assert!(
!candidates.contains(&scopeless_source),
"the undialable scopeless link-local must be filtered out of the set"
);
assert!(
!candidates.is_empty(),
"the identity must retain a reachable candidate (loopback)"
);
let dial_back_ok = b_ep
.dial(a_ep.public_key(), candidates.iter().copied())
.await
.is_ok();
a_task.abort();
assert!(
dial_back_ok,
"responder must reach the same-host asker via loopback despite an \
undialable scopeless link-local source ({scopeless_source})"
);
}
}