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:
2026-03-25 14:29:41 +09:00
parent 4519c5c4a6
commit 566990b7af
4 changed files with 375 additions and 0 deletions

26
Cargo.lock generated
View File

@@ -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"

View 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(())
}
}

View 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");
}
}

View 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
}
}
}
}