fix(server): sleep the remainder of the tick period

The simulation loop slept a flat 50 ms after the tick body, so the real period was the tick's work plus 50 ms and drifted with load; the advertised 20 Hz was never the rate actually achieved. The loop now timestamps the top of the tick and sleeps only the unused remainder of the budget, reporting an overrun instead of sleeping when the body exceeds it.
This commit is contained in:
Serkyo 2026-07-30 22:27:07 +02:00
parent a2b20f0ee9
commit c33643027c

View file

@ -18,7 +18,7 @@ pub mod world_server;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::Context; use anyhow::Context;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World};
@ -32,6 +32,13 @@ use net::{NetworkServer, ServerEvent};
use player::{Player, Position, ViewDistance}; use player::{Player, Position, ViewDistance};
use world_server::{ServerWorld, cylinder_chunks}; use world_server::{ServerWorld, cylinder_chunks};
/// Nominal simulation rate, in ticks per second. Sole source of truth for both the loop's target period and the advisory rate advertised to clients in the handshake.
// TODO: make configurable through server configs once the real tick scheduler lands.
const TICK_RATE_HZ: u16 = 20;
/// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`].
const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64);
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor. /// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
fn stream_chunks( fn stream_chunks(
anchors: Query<(&Position, &ViewDistance), With<Player>>, anchors: Query<(&Position, &ViewDistance), With<Player>>,
@ -124,23 +131,25 @@ fn main() -> anyhow::Result<()> {
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events. // Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events.
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT)); let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
let (network, local_addr) = NetworkServer::spawn( let (network, local_addr) =
bind, NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ)
env!("CARGO_PKG_VERSION").to_owned(), .context("spawning network server")?;
// Placeholder advisory tick rate.
20,
)
.context("spawning network server")?;
info!(%local_addr, "network endpoint listening"); info!(%local_addr, "network endpoint listening");
run_simulation(&mut world, &network)
}
/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`].
fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
// Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery. // Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery.
let empty_baseline = Chunk::default(); let empty_baseline = Chunk::default();
// Per-connection streaming state, keyed by the stable session id the network thread assigns. // Per-connection streaming state, keyed by the stable session id the network thread assigns.
let mut clients: HashMap<u64, ClientStream> = HashMap::new(); let mut clients: HashMap<u64, ClientStream> = HashMap::new();
// Authoritative simulation loop.
loop { loop {
let tick_start = Instant::now();
// Fold network events into per-client subscription state. // Fold network events into per-client subscription state.
for event in network.poll_events() { for event in network.poll_events() {
match event { match event {
@ -182,7 +191,17 @@ fn main() -> anyhow::Result<()> {
} }
} }
// Advisory ~20 Hz cadence until the real tick scheduler lands. // Sleep only the unused remainder of the tick's budget, so the period stays [`TICK_PERIOD`] rather than growing with the cost of the work above. A tick that overruns its budget does not sleep at all; the overrun is reported because it is the signal that the server is falling behind its nominal rate.
std::thread::sleep(Duration::from_millis(50)); let elapsed = tick_start.elapsed();
if elapsed > TICK_PERIOD {
warn!(
elapsed_ms = elapsed.as_secs_f32() * 1000.0,
budget_ms = TICK_PERIOD.as_secs_f32() * 1000.0,
"tick overran its budget"
);
} else {
// `saturating_sub` cannot underflow here (the branch already establishes `elapsed <= TICK_PERIOD`) and is used because `Duration` subtraction panics on overflow.
std::thread::sleep(TICK_PERIOD.saturating_sub(elapsed));
}
} }
} }