[Network] Measure real server round-trip times (#7153)

* [Network] Measure real server round-trip times

Time each command container from send to response with QElapsedTimer,
aggregate samples in a fixed-size ring buffer (last/median/p95/max),
and emit aggregated pingStatsUpdated at most once per second so the
hot path stays free of signal traffic. Stats are cleared on
disconnect. Forward the signal through ConnectionController for UI
consumers. Unit-tested in latency_tracker_test.

Took 37 minutes

Took 4 minutes

# Commit time for manual adjustment:
# Took 8 minutes

* Move params to struct, more informative debug

Took 56 seconds

Took 53 seconds

Took 2 minutes

Took 33 seconds

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-23 00:03:52 +02:00 • committed by GitHub
parent 157e7022cd
commit b91e872f5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 389 additions and 2 deletions

View file

@ -2,9 +2,9 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS abstract_client.h)
set(HEADERS abstract_client.h latency_tracker.h)
set(SOURCES abstract_client.cpp)
set(SOURCES abstract_client.cpp latency_tracker.cpp)
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})

View file

@ -1,6 +1,7 @@
#include "abstract_client.h"
#include <google/protobuf/descriptor.h>
#include <libcockatrice/protocol/debug_pb_message.h>
#include <libcockatrice/protocol/featureset.h>
#include <libcockatrice/protocol/get_pb_extension.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
@ -28,6 +29,7 @@ AbstractClient::AbstractClient(QObject *parent)
qRegisterMetaType<Response>("Response");
qRegisterMetaType<Response::ResponseCode>("Response::ResponseCode");
qRegisterMetaType<ClientStatus>("ClientStatus");
qRegisterMetaType<LatencyTracker::Stats>("LatencyTracker::Stats");
qRegisterMetaType<RoomEvent>("RoomEvent");
qRegisterMetaType<GameEventContainer>("GameEventContainer");
qRegisterMetaType<Event_ServerIdentification>("Event_ServerIdentification");
@ -71,6 +73,8 @@ void AbstractClient::processProtocolItem(const ServerMessage &item)
}
pendingCommands.remove(cmdId);
recordLatency(*pend);
pend->processResponse(response);
pend->deleteLater();
break;
@ -161,9 +165,62 @@ void AbstractClient::queuePendingCommand(PendingCommand *pend)
pendingCommands.insert(cmdId, pend);
pend->startTiming();
sendCommandContainer(pend->getCommandContainer());
}
namespace
{
constexpr int STATS_EMIT_INTERVAL_MS = 1000;
// Game actions are what players perceive as lag. Surface unusually slow ones
// without requiring debug logging to be enabled.
constexpr qint64 SLOW_GAME_COMMAND_WARN_MS = 1500;
} // namespace
void AbstractClient::recordLatency(PendingCommand &pend)
{
const qint64 elapsed = pend.elapsedMs();
if (elapsed < 0) {
return;
}
latencyTracker.addSample(elapsed);
if (AbstractClientLog().isDebugEnabled()) {
qCDebug(AbstractClientLog).noquote()
<< "command RTT:" << elapsed << "ms (cmd_id" << pend.getCommandContainer().cmd_id() << ")";
}
if (elapsed >= SLOW_GAME_COMMAND_WARN_MS && pend.getCommandContainer().game_command_size() > 0) {
qCWarning(AbstractClientLog).noquote()
<< "slow game command round trip:" << elapsed << "ms | " << getSafeDebugString(pend.getCommandContainer());
}
// Emit aggregated stats at most once per StatsEmitIntervalMs so that the
// per-command hot path stays free of signal traffic. The keepalive ping
// guarantees a fresh sample roughly every second while connected.
if (!statsEmitClockStarted || statsEmitClock.elapsed() >= STATS_EMIT_INTERVAL_MS) {
statsEmitClock.start();
statsEmitClockStarted = true;
const LatencyTracker::Stats stats = latencyTracker.stats();
QList<int> samples;
samples.reserve(stats.sampleCount);
for (qint64 sample : latencyTracker.recentSamples()) {
samples.append(static_cast<int>(sample));
}
emit pingStatsUpdated(stats, samples);
}
}
void AbstractClient::clearLatencyStats()
{
latencyTracker.clear();
statsEmitClockStarted = false;
emit pingStatsUpdated(LatencyTracker::Stats{}, {});
}
PendingCommand *AbstractClient::prepareSessionCommand(const ::google::protobuf::Message &cmd)
{
CommandContainer cont;

View file

@ -7,11 +7,17 @@
#ifndef ABSTRACTCLIENT_H
#define ABSTRACTCLIENT_H
#include "latency_tracker.h"
#include <QElapsedTimer>
#include <QLoggingCategory>
#include <QMutex>
#include <QVariant>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
inline Q_LOGGING_CATEGORY(AbstractClientLog, "abstract_client");
class PendingCommand;
class CommandContainer;
class RoomEvent;
@ -54,6 +60,18 @@ signals:
void statusChanged(ClientStatus _status);
void maxPingTime(int seconds, int maxSeconds);
/**
* @brief Aggregated round-trip statistics and a chronological snapshot of
* the rolling window, emitted at most once per second.
*
* All values in the stats struct are in milliseconds; sampleCount is the
* number of samples currently in the rolling window. The samples list is
* ordered oldest first so graphs can redraw without polling the tracker
* across threads. Emitted from the client thread. The connection to UI
* objects is automatically queued across threads.
*/
void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList<int> &samplesMs);
// Room events
void roomEventReceived(const RoomEvent &event);
// Game events
@ -85,6 +103,11 @@ private:
int nextCmdId;
mutable QMutex clientMutex;
ClientStatus status;
LatencyTracker latencyTracker;
QElapsedTimer statsEmitClock;
bool statsEmitClockStarted = false;
void recordLatency(PendingCommand &pend);
private slots:
void queuePendingCommand(PendingCommand *pend);
protected slots:
@ -113,6 +136,16 @@ public:
void sendCommand(const CommandContainer &cont);
void sendCommand(PendingCommand *pend);
/**
* @brief Drops all recorded round-trip samples and resets the stats
* emission throttle, emitting zeroed stats so that UI listeners can
* clear their display.
*
* Must be called from the client thread (as RemoteClient's disconnect
* path does). The tracker is deliberately lock-free.
*/
void clearLatencyStats();
bool getServerSupportsPasswordHash() const
{
return serverSupportsPasswordHash;

View file

@ -0,0 +1,60 @@
#include "latency_tracker.h"
#include <QtMath>
#include <algorithm>
void LatencyTracker::addSample(qint64 ms)
{
samples[static_cast<size_t>(head)] = ms;
head = (head + 1) % WindowSize;
if (count < WindowSize) {
++count;
}
}
QList<qint64> LatencyTracker::recentSamples() const
{
QList<qint64> result;
result.reserve(count);
for (int i = count; i > 0; --i) {
const int index = (head + WindowSize - i) % WindowSize;
result.append(samples[static_cast<size_t>(index)]);
}
return result;
}
LatencyTracker::Stats LatencyTracker::stats() const
{
if (count == 0) {
return {};
}
QList<qint64> sorted(samples.cbegin(), samples.cbegin() + count);
std::sort(sorted.begin(), sorted.end());
Stats s;
s.sampleCount = count;
s.lastMs = samples[static_cast<size_t>((head + WindowSize - 1) % WindowSize)];
s.maxMs = sorted.last();
const int n = count;
if (n % 2 == 1) {
s.medianMs = sorted[n / 2];
} else {
s.medianMs = (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
}
// Nearest-rank percentile: smallest value in the list such that at least
// 95% of the samples are <= it.
const int p95Index = qMax(0, qCeil(0.95 * static_cast<double>(n)) - 1);
s.p95Ms = sorted[p95Index];
return s;
}
void LatencyTracker::clear()
{
samples.fill(0);
head = 0;
count = 0;
}

View file

@ -0,0 +1,51 @@
/**
* @file latency_tracker.h
* @ingroup Client
*/
#ifndef LATENCY_TRACKER_H
#define LATENCY_TRACKER_H
#include <QList>
#include <QMetaType>
#include <array>
/**
* @brief Fixed-capacity rolling window of network round-trip time samples.
*
* The hot path (addSample) is a single array store and is intentionally free of
* allocations, locks, or signal emissions so that recording one sample per
* completed command cannot affect gameplay performance. Aggregate statistics
* are only computed on demand in stats(), which callers should throttle.
*/
class LatencyTracker
{
public:
static constexpr int WindowSize = 64;
struct Stats
{
qint64 lastMs = 0; ///< most recently added sample
qint64 medianMs = 0; ///< median over the current window
qint64 p95Ms = 0; ///< 95th percentile over the current window
qint64 maxMs = 0; ///< maximum over the current window
int sampleCount = 0; ///< number of samples currently in the window
};
void addSample(qint64 ms);
Stats stats() const;
/// Snapshot of the current window in chronological order (oldest first).
QList<qint64> recentSamples() const;
void clear();
private:
std::array<qint64, WindowSize> samples{};
int head = 0; ///< index where the next sample will be written
int count = 0; ///< number of valid samples, capped at WindowSize
};
Q_DECLARE_METATYPE(LatencyTracker::Stats)
#endif

View file

@ -543,6 +543,7 @@ void RemoteClient::doDisconnectFromServer()
delete i;
}
pendingCommands.clear();
clearLatencyStats();
setStatus(StatusDisconnected);
if (websocket->isValid()) {