// SPDX-License-Identifier: AGPL-3.0-only //! Threaded `tokio` runtime bridge between the async network and the synchronous simulation. use std::net::SocketAddr; use std::sync::{Arc, OnceLock}; use std::thread; use shared::protocol::authority::AuthorityMessage; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::{ClientHello, HandshakeAck}; use tracing::{info, warn}; use crate::authority::{AuthoritySink, client_authority_task, server_authority_task}; use crate::chunk::{ChunkSink, ChunkSubscriber, chunk_stream_task, client_chunk_task}; use crate::endpoint::{client_endpoint, server_endpoint}; use crate::error::NetError; use crate::handshake::{ServerConnection, accept_connection, connect}; use crate::stats::{self, NetCounters, NetStats}; /// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure. pub type ConnectOutcome = crossbeam_channel::Receiver>; /// Bounded receiver of chunks delivered by the server, drained by the UI thread with `try_recv`. pub type ChunkStream = tokio::sync::mpsc::Receiver; /// Capacity of the client's chunk-delivery channel, in [`ChunkMessage`]s. // TODO: revisit once meshing moves to a worker pool; the right depth follows the UI's consume rate, so this is a candidate to derive from the meshing budget / view distance in a config layer rather than a hand-set constant. const CHUNK_DELIVERY_CAPACITY: usize = 32; /// Bounded receiver of authority-stream messages, drained by the UI thread with `try_recv`. pub type AuthorityStream = tokio::sync::mpsc::Receiver; /// Capacity of the client's authority channel, in messages. /// /// Shallow on purpose: the server pushes roughly one message per second, so anything beyond a small backlog is stale by the time the UI would read it. The network task drops rather than blocks when this fills. const AUTHORITY_CAPACITY: usize = 4; /// Handles a background client connection exposes to the synchronous UI thread. /// /// The network task keeps the QUIC connection alive on its own thread; this bundle is how the winit loop observes the handshake outcome, pushes subscription updates, and drains chunk deliveries, all without touching the async runtime. pub struct ClientLink { /// Handshake outcome, drained once for the negotiated ack or the failure reason. pub handshake: ConnectOutcome, /// Sends subscription updates (center and radius) to the server as the camera moves. pub subscribe: ChunkSubscriber, /// Receives chunk deliveries from the server, drained non-blocking each frame. pub chunks: ChunkStream, /// Receives periodic server-authoritative state, drained non-blocking each frame. pub authority: AuthorityStream, /// The live QUIC connection, published by the network thread once the handshake completes. Held privately so the `quinn` types stay inside this crate; the UI thread reads through [`ClientLink::stats`]. connection: Arc>, /// Application-level message counters shared with the chunk task. counters: Arc, } impl ClientLink { /// Snapshots the connection's transport statistics. /// /// Safe to call before the handshake completes; the result then reports the disconnected state rather than failing. #[must_use] pub fn stats(&self) -> NetStats { stats::snapshot(self.connection.get(), &self.counters) } } /// An event surfaced by the network thread to the synchronous server loop. #[derive(Debug)] pub enum ServerEvent { /// A client completed the Synvael handshake. Carries the stable per-session id, the `ClientHello` it presented, and the sink the simulation loop uses to deliver chunks to this connection's chunk stream. ClientConnected { /// Stable identifier assigned to this session for the lifetime of the connection. id: u64, /// The identity and build parameters the client advertised. hello: ClientHello, /// Outbound handle for delivering [`shared::protocol::chunk::ChunkMessage`]s to this client. The simulation loop retains it, keyed by `id`, until the matching [`ServerEvent::ClientDisconnected`]. chunks: ChunkSink, /// Outbound handle for pushing [`shared::protocol::authority::AuthorityMessage`]s to this client, retained alongside `chunks` for the same lifetime. authority: AuthoritySink, }, /// A previously connected client's session ended. ClientDisconnected { /// Identifier of the session that ended, matching the earlier [`ServerEvent::ClientConnected`]. id: u64, /// Human-readable description of why the connection closed. reason: String, }, /// A connected client updated its chunk subscription: the initial subscribe on connect, or a later update as its center chunk moves. ChunkSubscribe { /// Identifier of the session that sent the subscription, matching its [`ServerEvent::ClientConnected`]. id: u64, /// The center and radius the client wants resident. request: ChunkSubscribe, }, } /// Handle to the background networking thread and its owned `tokio` runtime. #[derive(Debug)] pub struct NetworkServer { /// Events produced by the accept loop, drained by the synchronous simulation thread. events: crossbeam_channel::Receiver, /// Shutdown signal. Dropping this sender resolves the accept loop's receiver and breaks the loop. shutdown: Option>, /// Join handle for the network thread, awaited on drop for an orderly shutdown. thread: Option>, } impl NetworkServer { /// Spawns a dedicated network thread, builds a current-thread `tokio` runtime on it, binds a QUIC server endpoint on `bind`, and runs the accept loop. /// /// # Errors /// /// Returns [`NetError::Io`] if the runtime cannot be built, the network thread cannot be spawned, or the thread exits before reporting a bound address, and any error from [`server_endpoint`] if the endpoint cannot be constructed or bound. pub fn spawn( bind: SocketAddr, server_build: String, tick_rate_hint: u16, ) -> Result<(Self, SocketAddr), NetError> { let (events_tx, events_rx) = crossbeam_channel::unbounded(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); // Reports the bound address (or the error that prevented binding) back to this thread, so `spawn` can surface it synchronously. let (ready_tx, ready_rx) = crossbeam_channel::bounded::>(1); let thread = thread::Builder::new() .name("net-server".to_owned()) .spawn(move || { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(runtime) => runtime, Err(error) => { let _ = ready_tx.send(Err(NetError::Io(error))); return; } }; runtime.block_on(async move { // The endpoint is constructed inside the runtime context because `quinn` binds its driver task onto the current runtime. let endpoint = match server_endpoint(bind) { Ok(endpoint) => endpoint, Err(error) => { let _ = ready_tx.send(Err(error)); return; } }; let local_addr = match endpoint.local_addr() { Ok(addr) => addr, Err(error) => { let _ = ready_tx.send(Err(NetError::Io(error))); return; } }; // If the caller has already given up, there is nothing to serve. if ready_tx.send(Ok(local_addr)).is_err() { return; } accept_loop( endpoint, events_tx, shutdown_rx, server_build, tick_rate_hint, ) .await; }); }) .map_err(NetError::Io)?; let local_addr = match ready_rx.recv() { Ok(Ok(addr)) => addr, Ok(Err(error)) => return Err(error), Err(_) => { return Err(NetError::Io(std::io::Error::other( "network thread exited before reporting a bound address", ))); } }; Ok(( Self { events: events_rx, shutdown: Some(shutdown_tx), thread: Some(thread), }, local_addr, )) } /// Drains every [`ServerEvent`] currently queued, without blocking. pub fn poll_events(&self) -> impl Iterator + '_ { self.events.try_iter() } } impl Drop for NetworkServer { fn drop(&mut self) { // Dropping the sender resolves the accept loop's shutdown receiver, breaking the loop and letting `block_on` return so the thread unwinds and the runtime is dropped. self.shutdown.take(); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } /// Accepts incoming QUIC connections until the endpoint stops yielding them or a shutdown is signalled, spawning one handshake task per connection. async fn accept_loop( endpoint: quinn::Endpoint, events: crossbeam_channel::Sender, mut shutdown: tokio::sync::oneshot::Receiver<()>, server_build: String, tick_rate_hint: u16, ) { // Session ids are handed out sequentially; the accept loop is the sole assigner, so a plain counter suffices. let mut next_id: u64 = 0; loop { tokio::select! { incoming = endpoint.accept() => { let Some(incoming) = incoming else { break }; let id = next_id; next_id += 1; tokio::spawn(handle_connection( incoming, id, events.clone(), server_build.clone(), tick_rate_hint, )); } // Resolves when the `NetworkServer` handle is dropped (sender gone) or an explicit signal is sent. _ = &mut shutdown => break, } } info!("network accept loop shutting down"); } /// Performs the handshake for one incoming connection and, on success, reports connect and disconnect events for its session. async fn handle_connection( incoming: quinn::Incoming, id: u64, events: crossbeam_channel::Sender, server_build: String, tick_rate_hint: u16, ) { match accept_connection(incoming, server_build, tick_rate_hint).await { Ok(ServerConnection { connection, hello, .. }) => { // The outbound channels bridge the sync simulation loop to this connection's stream tasks; the sinks are handed to the loop via the connect event. let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel(); let (authority_tx, authority_rx) = tokio::sync::mpsc::unbounded_channel(); // If the receiver is gone the server is shutting down; drop the connection silently. if events .send(ServerEvent::ClientConnected { id, hello, chunks: ChunkSink::new(chunk_tx), authority: AuthoritySink::new(authority_tx), }) .is_err() { return; } // Each pump runs on its own task so the connection-close wait below does not block either. tokio::spawn(chunk_stream_task( connection.clone(), id, events.clone(), chunk_rx, )); tokio::spawn(server_authority_task(connection.clone(), id, authority_rx)); let reason = connection.closed().await; let _ = events.send(ServerEvent::ClientDisconnected { id, reason: reason.to_string(), }); } Err(error) => { warn!(%error, id, "connection handshake failed"); } } } /// Runs a connect and Synvael handshake against `server_addr` on a background `tokio` thread, then pumps the chunk stream, returning the handles the UI thread uses to observe and drive the connection. /// /// The returned [`ClientLink`] is available immediately; its channels buffer until the handshake completes and the chunk task starts. A handshake failure is reported on `handshake` and leaves the subscribe and chunk channels inert. #[must_use] pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> ClientLink { let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1); // Retained so a failure to spawn the thread can still be reported to the caller. let spawn_err_tx = outcome_tx.clone(); // Published by the network thread once the handshake succeeds, so the UI thread can read `quinn`'s own connection statistics without owning the connection. let connection = Arc::new(OnceLock::new()); let task_connection = Arc::clone(&connection); let counters = Arc::new(NetCounters::default()); let task_counters = Arc::clone(&counters); // Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv). let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::(); // The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit. let (chunks_tx, chunks_rx) = tokio::sync::mpsc::channel::(CHUNK_DELIVERY_CAPACITY); let (authority_tx, authority_rx) = tokio::sync::mpsc::channel::(AUTHORITY_CAPACITY); let spawned = thread::Builder::new() .name("net-client".to_owned()) .spawn(move || { let runtime = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(runtime) => runtime, Err(error) => { let _ = outcome_tx.send(Err(format!("failed to build tokio runtime: {error}"))); return; } }; runtime.block_on(async move { let endpoint = match client_endpoint() { Ok(endpoint) => endpoint, Err(error) => { let _ = outcome_tx.send(Err(error.to_string())); return; } }; // The server name matches the self-signed certificate's subject; the current verifier accepts any certificate regardless. match connect(&endpoint, server_addr, "localhost", hello).await { Ok(connected) => { if outcome_tx.send(Ok(connected.ack.clone())).is_err() { return; } // The cell is written exactly once, here; a failure would mean a second handshake on one link, which cannot occur. let _ = task_connection.set(connected.connection.clone()); // Pump both streams concurrently on this thread until the UI drops its handles or the server closes the connection. `join!` rather than `select!`: neither stream ending is a reason to abandon the other mid-frame. tokio::join!( client_chunk_task( connected.connection.clone(), subscribe_rx, chunks_tx, &task_counters, ), client_authority_task(connected.connection, authority_tx), ); warn!("server connection closed"); } Err(error) => { let _ = outcome_tx.send(Err(error.to_string())); } } }); }); if let Err(error) = spawned { let _ = spawn_err_tx.send(Err(format!("failed to spawn network thread: {error}"))); } ClientLink { handshake: outcome_rx, subscribe: ChunkSubscriber::new(subscribe_tx), chunks: chunks_rx, authority: authority_rx, connection, counters, } }