[Client] Detect main-thread event loop stalls

LagMonitor ticks the GUI event loop every 500 ms and records gaps
beyond 2 s as stalls, warning with their duration and keeping a
bounded ring of recent records for diagnostics. Measurement uses a
monotonic QElapsedTimer so wall-clock steps and suspend do not
fabricate stalls; recorded timestamps stay in wall time for
correlating with user reports.

Took 1 minute

Took 13 minutes


Took 2 minutes
This commit is contained in:
Lukas Brübach 2026-08-22 09:16:46 +02:00
parent b91e872f5f
commit 8a9635949f
4 changed files with 112 additions and 0 deletions

View file

@ -15,6 +15,7 @@ set(cockatrice_SOURCES
src/client/network/update/client/client_update_checker.cpp
src/client/network/update/client/release_channel.cpp
src/client/network/update/card_spoiler/spoiler_background_updater.cpp
src/client/lag_monitor.cpp
src/client/sound_engine.cpp
src/client/settings/cache_settings.cpp
src/client/settings/card_counter_settings.cpp

View file

@ -0,0 +1,43 @@
#include "lag_monitor.h"
#include <QTimer>
LagMonitor::LagMonitor(QObject *parent) : QObject(parent)
{
timer = new QTimer(this);
timer->setInterval(TickIntervalMs);
connect(timer, &QTimer::timeout, this, &LagMonitor::checkTick);
tickClock.start();
timer->start();
}
QList<LagMonitor::StallRecord> LagMonitor::recentStalls() const
{
return stalls;
}
void LagMonitor::clearStalls()
{
stalls.clear();
}
void LagMonitor::checkTick()
{
const qint64 gap = tickClock.restart();
if (gap <= StallThresholdMs) {
return;
}
StallRecord record;
record.timestampMsSinceEpoch = QDateTime::currentMSecsSinceEpoch(); // wall time, for log correlation
record.durationMs = gap;
stalls.append(record);
while (stalls.size() > MaxRecordedStalls) {
stalls.removeFirst();
}
qCWarning(LagMonitorLog, "Event loop stalled for %lld ms (threshold: %d ms)", static_cast<long long>(gap),
StallThresholdMs);
}

View file

@ -0,0 +1,66 @@
/**
* @file lag_monitor.h
* @ingroup Client
*/
#ifndef LAG_MONITOR_H
#define LAG_MONITOR_H
#include <QDateTime>
#include <QElapsedTimer>
#include <QList>
#include <QLoggingCategory>
#include <QObject>
inline Q_LOGGING_CATEGORY(LagMonitorLog, "lag_monitor");
class QTimer;
/**
* @brief Detects main-thread event loop stalls ("UI freezes") from the inside.
*
* A timer is expected to fire every TickIntervalMs of wall time. When the
* observed gap greatly exceeds that interval, some other task blocked the
* event loop for roughly the overshooting duration. This is what separates
* "my client froze" from "the network is lagging" in user reports.
*
* Healthy operation costs one timer wakeup per tick and two integer
* comparisons. Allocations happen only when a stall is actually recorded.
*/
class LagMonitor : public QObject
{
Q_OBJECT
public:
struct StallRecord
{
qint64 timestampMsSinceEpoch = 0; ///< when the stalled period ended
qint64 durationMs = 0; ///< approximate length of the freeze
};
static constexpr int TickIntervalMs = 500;
static constexpr int StallThresholdMs = 2000;
static constexpr int MaxRecordedStalls = 32;
explicit LagMonitor(QObject *parent = nullptr);
/**
* @brief Stalls recorded during this session, oldest first.
*
* Intended consumers are log output and the diagnostics export. The list
* holds at most MaxRecordedStalls entries.
*/
QList<StallRecord> recentStalls() const;
void clearStalls();
private slots:
void checkTick();
private:
QTimer *timer;
QElapsedTimer tickClock; ///< monotonic clock, so wall clock steps and suspend do not fabricate stalls
QList<StallRecord> stalls;
};
#endif

View file

@ -25,6 +25,7 @@
#ifndef WINDOW_H
#define WINDOW_H
#include "../client/lag_monitor.h"
#include "connection_controller/remote_connection_controller.h"
#include "widgets/dialogs/dlg_local_game_options.h"
@ -145,6 +146,7 @@ private:
WndSets *wndSets;
ConnectionController *connectionController;
LocalServer *localServer;
LagMonitor lagMonitor; ///< watches the main thread for event loop stalls
bool bHasActivated, askedForDbUpdater;
QProcess *cardUpdateProcess;
DlgViewLog *logviewDialog;