feat(net): add UDP server/client with connection management
Adds NetSocket (non-blocking UdpSocket wrapper with local_addr), NetServer (connection tracking via HashMap, poll/broadcast/send_to_client), and NetClient (connect/poll/send/disconnect lifecycle). Includes an integration test on 127.0.0.1:0 that validates ClientConnected, Connected, and UserData receipt end-to-end with 50ms sleeps to ensure UDP packet delivery. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -432,6 +432,21 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deferred_demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"pollster",
|
||||||
|
"voltex_math",
|
||||||
|
"voltex_platform",
|
||||||
|
"voltex_renderer",
|
||||||
|
"wgpu",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2029,6 +2044,13 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "voltex_ai"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"voltex_math",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voltex_asset"
|
name = "voltex_asset"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2051,6 +2073,10 @@ dependencies = [
|
|||||||
name = "voltex_math"
|
name = "voltex_math"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "voltex_net"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voltex_physics"
|
name = "voltex_physics"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
96
crates/voltex_net/src/client.rs
Normal file
96
crates/voltex_net/src/client.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::packet::Packet;
|
||||||
|
use crate::socket::NetSocket;
|
||||||
|
|
||||||
|
/// Events produced by the client during polling.
|
||||||
|
pub enum ClientEvent {
|
||||||
|
Connected { client_id: u32 },
|
||||||
|
Disconnected,
|
||||||
|
PacketReceived { packet: Packet },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-blocking UDP client.
|
||||||
|
pub struct NetClient {
|
||||||
|
socket: NetSocket,
|
||||||
|
server_addr: SocketAddr,
|
||||||
|
client_id: Option<u32>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetClient {
|
||||||
|
/// Create and bind a new client socket.
|
||||||
|
///
|
||||||
|
/// - `local_addr`: local bind address (e.g. "127.0.0.1:0")
|
||||||
|
/// - `server_addr`: the server's `SocketAddr`
|
||||||
|
/// - `name`: client display name used in Connect packet
|
||||||
|
pub fn new(local_addr: &str, server_addr: SocketAddr, name: &str) -> Result<Self, String> {
|
||||||
|
let socket = NetSocket::bind(local_addr)?;
|
||||||
|
Ok(NetClient {
|
||||||
|
socket,
|
||||||
|
server_addr,
|
||||||
|
client_id: None,
|
||||||
|
name: name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Connect packet to the server.
|
||||||
|
pub fn connect(&self) -> Result<(), String> {
|
||||||
|
let packet = Packet::Connect {
|
||||||
|
client_name: self.name.clone(),
|
||||||
|
};
|
||||||
|
self.socket.send_to(&packet, self.server_addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll for incoming packets and return any resulting client events.
|
||||||
|
pub fn poll(&mut self) -> Vec<ClientEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
while let Some((packet, _addr)) = self.socket.recv_from() {
|
||||||
|
match &packet {
|
||||||
|
Packet::Accept { client_id } => {
|
||||||
|
self.client_id = Some(*client_id);
|
||||||
|
events.push(ClientEvent::Connected {
|
||||||
|
client_id: *client_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Packet::Disconnect { .. } => {
|
||||||
|
self.client_id = None;
|
||||||
|
events.push(ClientEvent::Disconnected);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
events.push(ClientEvent::PacketReceived {
|
||||||
|
packet: packet.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an arbitrary packet to the server.
|
||||||
|
pub fn send(&self, packet: Packet) -> Result<(), String> {
|
||||||
|
self.socket.send_to(&packet, self.server_addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the client has received an Accept from the server.
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
self.client_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the client id assigned by the server, or None if not yet connected.
|
||||||
|
pub fn client_id(&self) -> Option<u32> {
|
||||||
|
self.client_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a Disconnect packet to the server and clear local state.
|
||||||
|
pub fn disconnect(&mut self) -> Result<(), String> {
|
||||||
|
if let Some(id) = self.client_id {
|
||||||
|
let packet = Packet::Disconnect { client_id: id };
|
||||||
|
self.socket.send_to(&packet, self.server_addr)?;
|
||||||
|
self.client_id = None;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
195
crates/voltex_net/src/server.rs
Normal file
195
crates/voltex_net/src/server.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::packet::Packet;
|
||||||
|
use crate::socket::NetSocket;
|
||||||
|
|
||||||
|
/// Information about a connected client.
|
||||||
|
pub struct ClientInfo {
|
||||||
|
pub id: u32,
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events produced by the server during polling.
|
||||||
|
pub enum ServerEvent {
|
||||||
|
ClientConnected { client_id: u32, name: String },
|
||||||
|
ClientDisconnected { client_id: u32 },
|
||||||
|
PacketReceived { client_id: u32, packet: Packet },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-blocking UDP server that manages multiple clients.
|
||||||
|
pub struct NetServer {
|
||||||
|
socket: NetSocket,
|
||||||
|
clients: HashMap<u32, ClientInfo>,
|
||||||
|
addr_to_id: HashMap<SocketAddr, u32>,
|
||||||
|
next_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetServer {
|
||||||
|
/// Bind the server to the given address.
|
||||||
|
pub fn new(addr: &str) -> Result<Self, String> {
|
||||||
|
let socket = NetSocket::bind(addr)?;
|
||||||
|
Ok(NetServer {
|
||||||
|
socket,
|
||||||
|
clients: HashMap::new(),
|
||||||
|
addr_to_id: HashMap::new(),
|
||||||
|
next_id: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the local address the server is listening on.
|
||||||
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
|
self.socket.local_addr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll for incoming packets and return any resulting server events.
|
||||||
|
pub fn poll(&mut self) -> Vec<ServerEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
while let Some((packet, addr)) = self.socket.recv_from() {
|
||||||
|
match &packet {
|
||||||
|
Packet::Connect { client_name } => {
|
||||||
|
// Assign a new id and send Accept
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
let name = client_name.clone();
|
||||||
|
|
||||||
|
let info = ClientInfo {
|
||||||
|
id,
|
||||||
|
addr,
|
||||||
|
name: name.clone(),
|
||||||
|
};
|
||||||
|
self.clients.insert(id, info);
|
||||||
|
self.addr_to_id.insert(addr, id);
|
||||||
|
|
||||||
|
let accept = Packet::Accept { client_id: id };
|
||||||
|
if let Err(e) = self.socket.send_to(&accept, addr) {
|
||||||
|
eprintln!("[NetServer] Failed to send Accept to {}: {}", addr, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(ServerEvent::ClientConnected { client_id: id, name });
|
||||||
|
}
|
||||||
|
Packet::Disconnect { client_id } => {
|
||||||
|
let id = *client_id;
|
||||||
|
if let Some(info) = self.clients.remove(&id) {
|
||||||
|
self.addr_to_id.remove(&info.addr);
|
||||||
|
events.push(ServerEvent::ClientDisconnected { client_id: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Map address to client id
|
||||||
|
if let Some(&client_id) = self.addr_to_id.get(&addr) {
|
||||||
|
events.push(ServerEvent::PacketReceived {
|
||||||
|
client_id,
|
||||||
|
packet: packet.clone(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
eprintln!("[NetServer] Packet from unknown addr {}", addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a packet to every connected client.
|
||||||
|
pub fn broadcast(&self, packet: &Packet) {
|
||||||
|
for info in self.clients.values() {
|
||||||
|
if let Err(e) = self.socket.send_to(packet, info.addr) {
|
||||||
|
eprintln!("[NetServer] broadcast failed for client {}: {}", info.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a packet to a specific client by id.
|
||||||
|
pub fn send_to_client(&self, id: u32, packet: &Packet) {
|
||||||
|
if let Some(info) = self.clients.get(&id) {
|
||||||
|
if let Err(e) = self.socket.send_to(packet, info.addr) {
|
||||||
|
eprintln!("[NetServer] send_to_client {} failed: {}", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a slice of all connected clients.
|
||||||
|
pub fn clients(&self) -> impl Iterator<Item = &ClientInfo> {
|
||||||
|
self.clients.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of connected clients.
|
||||||
|
pub fn client_count(&self) -> usize {
|
||||||
|
self.clients.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::{ClientEvent, NetClient};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_integration_connect_and_userdata() {
|
||||||
|
// Step 1: Start server on OS-assigned port
|
||||||
|
let mut server = NetServer::new("127.0.0.1:0").expect("server bind failed");
|
||||||
|
let server_addr = server.local_addr();
|
||||||
|
|
||||||
|
// Step 2: Create client on OS-assigned port, point at server
|
||||||
|
let mut client = NetClient::new("127.0.0.1:0", server_addr, "TestClient")
|
||||||
|
.expect("client bind failed");
|
||||||
|
|
||||||
|
// Step 3: Client sends Connect
|
||||||
|
client.connect().expect("connect send failed");
|
||||||
|
|
||||||
|
// Step 4: Give the packet time to travel
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Step 5: Server poll → should get ClientConnected
|
||||||
|
let server_events = server.poll();
|
||||||
|
let mut connected_id = None;
|
||||||
|
for event in &server_events {
|
||||||
|
if let ServerEvent::ClientConnected { client_id, name } = event {
|
||||||
|
connected_id = Some(*client_id);
|
||||||
|
assert_eq!(name, "TestClient");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(connected_id.is_some(), "Server did not receive ClientConnected");
|
||||||
|
|
||||||
|
// Step 6: Client poll → should get Connected
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
let client_events = client.poll();
|
||||||
|
let mut got_connected = false;
|
||||||
|
for event in &client_events {
|
||||||
|
if let ClientEvent::Connected { client_id } = event {
|
||||||
|
assert_eq!(Some(*client_id), connected_id);
|
||||||
|
got_connected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(got_connected, "Client did not receive Connected event");
|
||||||
|
|
||||||
|
// Step 7: Client sends UserData, server should receive it
|
||||||
|
let cid = client.client_id().unwrap();
|
||||||
|
let user_packet = Packet::UserData {
|
||||||
|
client_id: cid,
|
||||||
|
data: vec![1, 2, 3, 4],
|
||||||
|
};
|
||||||
|
client.send(user_packet.clone()).expect("send userdata failed");
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let server_events2 = server.poll();
|
||||||
|
let mut got_packet = false;
|
||||||
|
for event in server_events2 {
|
||||||
|
if let ServerEvent::PacketReceived { client_id, packet } = event {
|
||||||
|
assert_eq!(client_id, cid);
|
||||||
|
assert_eq!(packet, user_packet);
|
||||||
|
got_packet = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(got_packet, "Server did not receive UserData packet");
|
||||||
|
|
||||||
|
// Cleanup: disconnect
|
||||||
|
client.disconnect().expect("disconnect send failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/voltex_net/src/socket.rs
Normal file
58
crates/voltex_net/src/socket.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
|
||||||
|
use crate::Packet;
|
||||||
|
|
||||||
|
/// Maximum UDP datagram size we'll allocate for receiving.
|
||||||
|
const MAX_PACKET_SIZE: usize = 65535;
|
||||||
|
|
||||||
|
/// A non-blocking UDP socket wrapper.
|
||||||
|
pub struct NetSocket {
|
||||||
|
inner: UdpSocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetSocket {
|
||||||
|
/// Bind a new non-blocking UDP socket to the given address.
|
||||||
|
pub fn bind(addr: &str) -> Result<Self, String> {
|
||||||
|
let socket = UdpSocket::bind(addr)
|
||||||
|
.map_err(|e| format!("UdpSocket::bind({}) failed: {}", addr, e))?;
|
||||||
|
socket
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.map_err(|e| format!("set_nonblocking failed: {}", e))?;
|
||||||
|
Ok(NetSocket { inner: socket })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the local address this socket is bound to.
|
||||||
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
|
self.inner.local_addr().expect("local_addr unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize and send a packet to the given address.
|
||||||
|
pub fn send_to(&self, packet: &Packet, addr: SocketAddr) -> Result<(), String> {
|
||||||
|
let bytes = packet.to_bytes();
|
||||||
|
self.inner
|
||||||
|
.send_to(&bytes, addr)
|
||||||
|
.map_err(|e| format!("send_to failed: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to receive one packet. Returns None on WouldBlock (no data available).
|
||||||
|
pub fn recv_from(&self) -> Option<(Packet, SocketAddr)> {
|
||||||
|
let mut buf = vec![0u8; MAX_PACKET_SIZE];
|
||||||
|
match self.inner.recv_from(&mut buf) {
|
||||||
|
Ok((len, addr)) => {
|
||||||
|
match Packet::from_bytes(&buf[..len]) {
|
||||||
|
Ok(packet) => Some((packet, addr)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[NetSocket] Failed to parse packet from {}: {}", addr, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => None,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[NetSocket] recv_from error: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user