core: prefer-direct — suppress the relay once P2P covers the room

The engine sent clipboard over every transport at once, so the server always
saw the payload even with RTC/LAN up. Now the RTC and LAN transports report
their directly-reachable peers (data-channel open / TCP connected) into a shared
set, and the RTC transport records who's present from presence chirps. send()
skips the SSE relay when every present peer is directly reachable — so once P2P
is established the server only ever carries signaling/presence, never content.
Falls back to the relay whenever a present peer isn't directly reachable, or
before the direct link is up.

Also drop empty-text clipboards in the sink (were surfacing as blank "unknown"
feed rows).

Proven by examples/prefer_direct.rs: two engines link directly on the live
server; a send reaches the peer but never appears on the server bus (only
presence does).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:22:09 -05:00
parent cd0ac012e0
commit 7ccb32bb64
4 changed files with 114 additions and 10 deletions

View File

@@ -0,0 +1,49 @@
//! Proves the relay carries content only until a direct path exists. A and B
//! join a room on the LIVE server and link directly (LAN/RTC). After that, A's
//! send must reach B but must NOT appear on the server bus.
//! cargo run --example prefer_direct -- <room>
//! (run an external SSE listener on the same room to confirm suppression)
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<String>);
impl MessageHandler for Collector {
fn on_message(&self, m: Message) {
eprintln!(" B.on_message: kind={:?} text={:?} from={:?}", m.kind, m.text, m.from);
let _ = self.0.send(m.text);
}
fn on_status(&self, _c: bool) {}
}
fn main() {
let server = "https://tether.pecord.io".to_string();
let room = std::env::args().nth(1).unwrap_or_else(|| "prefer-direct-demo".into());
let (tx, rx) = mpsc::channel();
let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), "".into());
let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), "".into());
a.start(Box::new(Collector(mpsc::channel().0)));
b.start(Box::new(Collector(tx)));
// Let presence + the direct (LAN/RTC) link establish.
eprintln!("waiting ~12s for presence + direct link…");
std::thread::sleep(Duration::from_secs(12));
let secret = "DIRECT-ONLY-SECRET";
eprintln!("A sends — should go P2P, NOT via the relay");
a.send(secret.to_string());
let mut got = false;
while let Ok(t) = rx.recv_timeout(Duration::from_secs(4)) {
if t == secret { got = true; break; }
}
println!(
"{}",
if got { "✅ B received the secret over the direct path" } else { "❌ B never got the secret" }
);
a.stop();
b.stop();
}

View File

@@ -23,7 +23,7 @@ use tokio::net::{TcpListener, TcpStream};
use tokio::runtime::Handle;
use tokio::sync::Mutex;
use crate::{Config, Discovered, Message, Peer, Sink, Status, Transport};
use crate::{Config, DirectSet, Discovered, Message, Peer, Sink, Status, Transport};
const SERVICE: &str = "_tether._tcp.local.";
@@ -32,15 +32,17 @@ pub(crate) struct LanTransport {
peers: Arc<Mutex<HashMap<String, OwnedWriteHalf>>>, // peer `from` → its write half
sink: StdMutex<Option<Sink>>,
discovered: Discovered, // nearby devices (any room), for the engine's nearby()
direct: DirectSet, // LAN-connected peers (a direct path; suppresses relay)
}
impl LanTransport {
pub(crate) fn new(cfg: Config, discovered: Discovered) -> Self {
pub(crate) fn new(cfg: Config, discovered: Discovered, direct: DirectSet) -> Self {
Self {
cfg,
peers: Arc::new(Mutex::new(HashMap::new())),
sink: StdMutex::new(None),
discovered,
direct,
}
}
@@ -240,6 +242,7 @@ impl LanTransport {
}
map.insert(peer_from.clone(), wr);
}
self.direct.lock().unwrap().insert(peer_from.clone()); // direct path up
eprintln!("tethercore[lan]: peer {peer_from} connected");
while let Ok(Some(line)) = lines.next_line().await {
@@ -253,6 +256,7 @@ impl LanTransport {
}
self.peers.lock().await.remove(&peer_from);
self.direct.lock().unwrap().remove(&peer_from); // relay needed again
eprintln!("tethercore[lan]: peer {peer_from} disconnected");
}
}

View File

@@ -13,7 +13,7 @@
//! across all transports and de-duplicates inbound so the same clipboard
//! arriving over two channels surfaces once.
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -88,6 +88,15 @@ pub struct Receipt {
type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
/// Peers reachable over a direct channel right now (RTC data channel or LAN TCP),
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
/// the relay is still needed.
type DirectSet = Arc<Mutex<HashSet<String>>>;
/// Peers currently in the room, by `from`, last-seen via presence chirps. Lets
/// `send` know the full audience so it only suppresses the relay when *every*
/// present peer is directly reachable.
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
/// the SAME room — that's what puts all of one account's devices together across
@@ -136,6 +145,8 @@ pub struct Engine {
dedup: Arc<Mutex<Dedup>>,
discovered: Discovered,
receipts: Receipts,
direct: DirectSet,
present: PresentSet,
}
#[uniffi::export]
@@ -162,13 +173,15 @@ impl Engine {
.expect("tokio runtime");
let cfg = Config { server, room, from, source, name, account };
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
let direct: DirectSet = Arc::new(Mutex::new(HashSet::new()));
let present: PresentSet = Arc::new(Mutex::new(HashMap::new()));
let transports: Vec<Arc<dyn Transport>> = vec![
Arc::new(SseTransport {
cfg: cfg.clone(),
http: reqwest::Client::new(),
}),
Arc::new(rtc::RtcTransport::new(cfg.clone())),
Arc::new(lan::LanTransport::new(cfg.clone(), discovered.clone())),
Arc::new(rtc::RtcTransport::new(cfg.clone(), direct.clone(), present.clone())),
Arc::new(lan::LanTransport::new(cfg.clone(), discovered.clone(), direct.clone())),
];
Arc::new(Self {
cfg,
@@ -178,6 +191,8 @@ impl Engine {
dedup: Arc::new(Mutex::new(Dedup::default())),
discovered,
receipts: Arc::new(Mutex::new(Vec::new())),
direct,
present,
})
}
@@ -232,7 +247,10 @@ impl Engine {
}
return;
}
// Clipboard: de-dup, ack the sender, then deliver.
// Clipboard: ignore empties, de-dup, ack the sender, then deliver.
if m.text.is_empty() {
return;
}
if dedup.lock().unwrap().seen(&m) {
return; // already delivered via another transport
}
@@ -267,7 +285,10 @@ impl Engine {
}
}
/// Publish clipboard text to the room across all transports.
/// Publish clipboard text to the room. Always sent over the direct
/// transports (RTC/LAN); sent over the SSE relay ONLY when a direct path
/// doesn't already reach every present peer — so once P2P is up the server
/// never sees the payload (it stays just signaling + fallback).
pub fn send(&self, text: String) {
let msg = Message {
kind: "clipboard".into(),
@@ -279,12 +300,27 @@ impl Engine {
room: self.cfg.room.clone(),
ts: 0, // server stamps authoritative ts
};
let covered = self.directly_covered();
let rt = self.rt.handle();
for t in &self.transports {
if covered && t.name() == "sse" {
continue; // direct channels reach everyone → keep content off the relay
}
t.publish(rt, msg.clone());
}
}
/// True when every peer currently present in the room is reachable over a
/// direct channel — i.e. the relay isn't needed for delivery. False if we
/// don't yet know who's present (be safe, relay).
fn directly_covered(&self) -> bool {
let direct = self.direct.lock().unwrap();
let now = Instant::now();
let mut present = self.present.lock().unwrap();
present.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20));
!present.is_empty() && present.keys().all(|p| direct.contains(p))
}
/// Stop all transports at their next checkpoint.
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);

View File

@@ -13,7 +13,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use serde_json::{json, Value};
@@ -31,7 +31,7 @@ use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
use webrtc::peer_connection::RTCPeerConnection;
use crate::{Config, Message, Sink, Status, Transport};
use crate::{Config, DirectSet, Message, PresentSet, Sink, Status, Transport};
struct Peer {
pc: Arc<RTCPeerConnection>,
@@ -46,6 +46,8 @@ pub(crate) struct RtcTransport {
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
sink: StdMutex<Option<Sink>>,
ice: StdMutex<Vec<RTCIceServer>>, // refreshed from /api/turn-cred at start
direct: DirectSet, // peers with an open data channel
present: PresentSet, // peers seen via presence chirps
}
fn default_ice() -> Vec<RTCIceServer> {
@@ -56,7 +58,7 @@ fn default_ice() -> Vec<RTCIceServer> {
}
impl RtcTransport {
pub(crate) fn new(cfg: Config) -> Self {
pub(crate) fn new(cfg: Config, direct: DirectSet, present: PresentSet) -> Self {
// Data-channel-only: a bare media engine, no codecs/interceptors needed.
let api = APIBuilder::new()
.with_media_engine(MediaEngine::default())
@@ -68,6 +70,8 @@ impl RtcTransport {
peers: Arc::new(Mutex::new(HashMap::new())),
sink: StdMutex::new(None),
ice: StdMutex::new(default_ice()),
direct,
present,
}
}
@@ -188,9 +192,11 @@ impl RtcTransport {
// Drop the peer when the connection dies.
{
let peers = self.peers.clone();
let direct = self.direct.clone();
let remote = remote.to_owned();
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
let peers = peers.clone();
let direct = direct.clone();
let remote = remote.clone();
Box::pin(async move {
if matches!(
@@ -200,6 +206,7 @@ impl RtcTransport {
| RTCPeerConnectionState::Disconnected
) {
peers.lock().await.remove(&remote);
direct.lock().unwrap().remove(&remote); // relay needed again
}
})
}));
@@ -243,9 +250,12 @@ impl RtcTransport {
async fn wire_channel(self: &Arc<Self>, remote: &str, dc: Arc<RTCDataChannel>, peer: &Arc<Peer>) {
{
let remote_log = remote.to_owned();
let direct = self.direct.clone();
dc.on_open(Box::new(move || {
let remote_log = remote_log.clone();
let direct = direct.clone();
Box::pin(async move {
direct.lock().unwrap().insert(remote_log.clone()); // now P2P-reachable
eprintln!("tethercore[rtc]: data channel open ↔ {remote_log}");
})
}));
@@ -405,6 +415,11 @@ impl RtcTransport {
}
match v["type"].as_str().unwrap_or("") {
"presence" => {
// Track who's in the room so send() knows the full audience.
self.present
.lock()
.unwrap()
.insert(from.to_string(), Instant::now());
// Deterministic initiator: the smaller id offers.
if self.cfg.from.as_str() < from {
self.initiate_offer(from).await;