gui/docs/pkg-qa-requests-m1.md R3, filed by the previous session: three GUI M1 DoD items (10k rows at 60 fps, flat RSS over 10 minutes, --slow/--flaky/--drop-connection recovery) had nowhere to run in CI. This is the harness — `gui/tests/dod/run.sh <gate> [--json <path>]`, exactly the path/invocation contract tests/integration/README.md already specified — plus `gui/tests/dod/dod_harness.cpp`, the Qt/RpcClient-driven binary that actually runs each gate against a real mockd run.sh starts and tears down itself. - scroll-60fps: an eased scripted scroll over the whole loaded table, timing each step's synchronous repaint; p99 against a 16.6 ms budget (auto-scaled 4x under a sanitized build — ASan/UBSan overhead, not a loosened bar, see the harness's isSanitizedBuild()). - rss-flat: samples this process's own VmRSS at 1 Hz across the run, discards a warm-up window, checks post-warm-up growth against a stated 20 MiB slack. - unhappy-path: three phases (slow/flaky/drop-connection), each its own mockd instance; passes when the client reaches and holds Connected with no crash or hang. A watchdog (the harness's own QTimer, backstopped by run.sh's external `timeout`) turns a genuine hang into a bounded non-zero exit rather than needing the CI caller to timeout(1) around it. Every gate honours the exit-code and --json contract PKG/QA's pre-drafted CI job expects unchanged (one addition needed: the build step must also build the `gui-dod-harness` target, noted in the R3 update). No leaked mockd processes on any exit path (`trap cleanup EXIT INT TERM`); no writes outside a tempdir except the caller's own --json path. Verified live end-to-end (not just unit-level): all three gates run against a real mockd under the exact `ASAN_OPTIONS=detect_leaks=1:halt_on_error=1` `.github/workflows/ci.yml`'s sanitizers job already sets, all pass, and scroll-60fps was forced red once on purpose (VELOX_DOD_FRAME_BUDGET_MS=1) to prove the fail path and exit code actually work. Building this is also what surfaced the two RpcClient bugs fixed in the previous commit, and one real gap in mockd itself — --drop-connection never worked over the Unix socket transport (only WebSocket) — filed as gui/docs/proto-requests-m1.md since tools/mockd is PROTO's file. gui/docs/pkg-qa-requests-m1.md R3 and R4 (an unrelated, non-blocking Qt6::DBus CMake hygiene note filed while wiring the clipboard global-shortcut path) are updated with the concrete findings above. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
417 lines
16 KiB
C++
417 lines
16 KiB
C++
// GUI M1 DoD gate harness. Lane GUI.
|
|
//
|
|
// gui/docs/pkg-qa-requests-m1.md R3 / tests/integration/README.md: PKG/QA's CI job
|
|
// invokes this (via run.sh) as `<gate> --sock <path> [--json <path>]`, one gate per run:
|
|
//
|
|
// scroll-60fps — mockd --tasks 10000, a scripted scroll over the whole table; fail on
|
|
// a p99 per-step paint time over budget (16.6 ms, i.e. 60 fps).
|
|
// rss-flat — mockd --tasks 10000 streaming progress for --duration-sec (default
|
|
// 600 = 10 min); fail if RSS grows past a fixed slack after warm-up.
|
|
// unhappy-path — one phase (--phase slow|flaky|drop-connection, label only: the actual
|
|
// mockd flag is run.sh's job) against a client that must reach
|
|
// Connected and hold it, no crash, no hang.
|
|
//
|
|
// Exit 0 pass, non-zero fail. --json <path> writes one result object. A watchdog timer
|
|
// converts a hang into a non-zero exit itself — nothing here should ever need an external
|
|
// timeout(1) to end it.
|
|
//
|
|
// "Fling scroll" and "frame" are approximate in a headless/offscreen run: there is no
|
|
// compositor to hand a real frame to, so what is measured is wall-clock time for one
|
|
// scroll step's model-driven repaint — the CPU cost a real frame would also have to pay,
|
|
// just without a GPU present/vsync on top of it. That is the part a progress-patch
|
|
// regression or a delegate doing needless work would actually blow.
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <functional>
|
|
#include <numeric>
|
|
#include <vector>
|
|
|
|
#include <QApplication>
|
|
#include <QCommandLineParser>
|
|
#include <QElapsedTimer>
|
|
#include <QEventLoop>
|
|
#include <QFile>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QRegularExpression>
|
|
#include <QScrollBar>
|
|
#include <QTimer>
|
|
#include <QTreeView>
|
|
|
|
#include "models/DownloadTableModel.hpp"
|
|
#include "rpc/RpcClient.hpp"
|
|
#include "widgets/ProgressDelegate.hpp"
|
|
|
|
using velox::gui::DownloadTableModel;
|
|
using velox::gui::ProgressDelegate;
|
|
namespace rpc = velox::gui::rpc;
|
|
|
|
namespace {
|
|
|
|
// Pumps the event loop in small slices until `pred` is true or `timeoutMs` elapses.
|
|
// Never blocks longer than that — every wait in this file is bounded, which is what lets
|
|
// the process reach its own exit(1) instead of needing the watchdog for the common case.
|
|
bool waitFor(const std::function<bool()> &pred, int timeoutMs) {
|
|
QElapsedTimer t;
|
|
t.start();
|
|
while (!pred() && t.elapsed() < timeoutMs) {
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
|
|
}
|
|
return pred();
|
|
}
|
|
|
|
qint64 readRssKiB() {
|
|
QFile f(QStringLiteral("/proc/self/status"));
|
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
return -1;
|
|
}
|
|
static const QRegularExpression kWs(QStringLiteral("\\s+"));
|
|
for (const QByteArray &lineBytes : f.readAll().split('\n')) {
|
|
const QString line = QString::fromLatin1(lineBytes);
|
|
if (line.startsWith(QLatin1String("VmRSS:"))) {
|
|
const QStringList parts = line.split(kWs, Qt::SkipEmptyParts);
|
|
if (parts.size() >= 2) {
|
|
bool ok = false;
|
|
const qint64 kib = parts[1].toLongLong(&ok);
|
|
return ok ? kib : -1;
|
|
}
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
double percentile(std::vector<double> v, double p) {
|
|
if (v.empty()) {
|
|
return 0.0;
|
|
}
|
|
std::sort(v.begin(), v.end());
|
|
int idx = static_cast<int>(std::ceil(p * static_cast<double>(v.size()))) - 1;
|
|
idx = std::clamp(idx, 0, static_cast<int>(v.size()) - 1);
|
|
return v[static_cast<std::size_t>(idx)];
|
|
}
|
|
|
|
// ASan/UBSan add real overhead to every paint; the pre-drafted CI job builds this with
|
|
// `cmake --preset dev`, which is ASan+UBSan by default (CLAUDE.md: "default for all
|
|
// lanes"). Scaling the budget under a sanitized build is an honest adjustment for
|
|
// instrumentation cost, not a loosened bar — VELOX_DOD_FRAME_BUDGET_MS still overrides it
|
|
// outright for whoever wants to tune this per-runner.
|
|
bool isSanitizedBuild() {
|
|
#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__)
|
|
return true;
|
|
#elif defined(__has_feature)
|
|
#if __has_feature(address_sanitizer) || __has_feature(thread_sanitizer)
|
|
return true;
|
|
#else
|
|
return false;
|
|
#endif
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
void writeJson(const QString &path, const QJsonObject &obj) {
|
|
if (path.isEmpty()) {
|
|
return;
|
|
}
|
|
QFile f(path);
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
|
std::fprintf(stderr, "warning: could not write --json output to %s\n",
|
|
qUtf8Printable(path));
|
|
return;
|
|
}
|
|
f.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
|
}
|
|
|
|
void wireModel(rpc::RpcClient *client, DownloadTableModel *model) {
|
|
QObject::connect(client, &rpc::RpcClient::taskListReset, model,
|
|
&DownloadTableModel::resetFromJson);
|
|
QObject::connect(client, &rpc::RpcClient::taskProgress, model,
|
|
&DownloadTableModel::applyProgress);
|
|
QObject::connect(client, &rpc::RpcClient::taskAdded, model,
|
|
&DownloadTableModel::applyTaskAdded);
|
|
QObject::connect(client, &rpc::RpcClient::taskStateChanged, model,
|
|
&DownloadTableModel::applyTaskState);
|
|
QObject::connect(client, &rpc::RpcClient::taskRemoved, model,
|
|
&DownloadTableModel::applyTaskRemoved);
|
|
}
|
|
|
|
int runScroll60Fps(rpc::RpcClient &client, const QString &jsonPath) {
|
|
DownloadTableModel model;
|
|
wireModel(&client, &model);
|
|
|
|
if (!waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, 15000)) {
|
|
std::fprintf(stderr, "FAIL: never reached Connected\n");
|
|
return 1;
|
|
}
|
|
if (!waitFor([&] { return model.rowCount() >= 9000; }, 15000)) {
|
|
std::fprintf(stderr,
|
|
"FAIL: table never loaded (rowCount=%d) — run mockd with "
|
|
"--tasks 10000\n",
|
|
model.rowCount());
|
|
return 1;
|
|
}
|
|
|
|
QTreeView view;
|
|
view.setModel(&model);
|
|
view.setUniformRowHeights(true);
|
|
view.setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(&view));
|
|
view.resize(1000, 700);
|
|
view.show();
|
|
waitFor([] { return false; }, 100); // let the initial show/layout settle
|
|
|
|
auto *bar = view.verticalScrollBar();
|
|
const int maxV = bar->maximum();
|
|
if (maxV <= 0) {
|
|
std::fprintf(stderr, "FAIL: nothing to scroll (scrollbar max=%d)\n", maxV);
|
|
return 1;
|
|
}
|
|
|
|
// A scripted "fling": ease-out steps (big jumps first, settling to small ones), the
|
|
// shape a real flick-scroll decelerates through, rather than a uniform crawl.
|
|
constexpr int kSteps = 240;
|
|
std::vector<double> frameMs;
|
|
frameMs.reserve(kSteps);
|
|
for (int i = 1; i <= kSteps; ++i) {
|
|
const double t = static_cast<double>(i) / kSteps;
|
|
const double eased = 1.0 - std::pow(1.0 - t, 3.0);
|
|
const int value = static_cast<int>(static_cast<double>(maxV) * eased);
|
|
|
|
QElapsedTimer frame;
|
|
frame.start();
|
|
bar->setValue(value);
|
|
QCoreApplication::sendPostedEvents();
|
|
view.viewport()->repaint(); // synchronous: times the paint, not just the request
|
|
frameMs.push_back(static_cast<double>(frame.nsecsElapsed()) / 1e6);
|
|
}
|
|
|
|
const double p99 = percentile(frameMs, 0.99);
|
|
const double maxMs = *std::max_element(frameMs.begin(), frameMs.end());
|
|
const double meanMs =
|
|
std::accumulate(frameMs.begin(), frameMs.end(), 0.0) / static_cast<double>(frameMs.size());
|
|
|
|
double budgetMs = 16.6;
|
|
if (isSanitizedBuild()) {
|
|
budgetMs *= 4.0; // instrumentation overhead, not a lowered bar — see isSanitizedBuild()
|
|
}
|
|
const QString override = qEnvironmentVariable("VELOX_DOD_FRAME_BUDGET_MS");
|
|
if (!override.isEmpty()) {
|
|
bool ok = false;
|
|
const double v = override.toDouble(&ok);
|
|
if (ok) {
|
|
budgetMs = v;
|
|
}
|
|
}
|
|
|
|
const bool pass = p99 <= budgetMs;
|
|
std::printf("%s: p99=%.2f ms mean=%.2f ms max=%.2f ms budget=%.2f ms over %d steps, %d rows\n",
|
|
pass ? "PASS" : "FAIL", p99, meanMs, maxMs, budgetMs, kSteps, model.rowCount());
|
|
|
|
writeJson(jsonPath, QJsonObject{
|
|
{"gate", "scroll-60fps"},
|
|
{"rows", model.rowCount()},
|
|
{"steps", kSteps},
|
|
{"p99Ms", p99},
|
|
{"meanMs", meanMs},
|
|
{"maxMs", maxMs},
|
|
{"budgetMs", budgetMs},
|
|
{"sanitized", isSanitizedBuild()},
|
|
{"pass", pass},
|
|
});
|
|
return pass ? 0 : 1;
|
|
}
|
|
|
|
int runRssFlat(rpc::RpcClient &client, const QString &jsonPath, int durationSec) {
|
|
DownloadTableModel model;
|
|
wireModel(&client, &model);
|
|
|
|
if (!waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, 15000)) {
|
|
std::fprintf(stderr, "FAIL: never reached Connected\n");
|
|
return 1;
|
|
}
|
|
if (!waitFor([&] { return model.rowCount() >= 9000; }, 15000)) {
|
|
std::fprintf(stderr,
|
|
"FAIL: table never loaded (rowCount=%d) — run mockd with "
|
|
"--tasks 10000\n",
|
|
model.rowCount());
|
|
return 1;
|
|
}
|
|
|
|
// Kept visible: a hidden model-only run would miss any leak that lives in painting
|
|
// (delegate scratch state, style caches) rather than in the model's own row patches.
|
|
QTreeView view;
|
|
view.setModel(&model);
|
|
view.setUniformRowHeights(true);
|
|
view.setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(&view));
|
|
view.resize(1000, 700);
|
|
view.show();
|
|
|
|
const int warmupSec = std::min(30, std::max(1, durationSec / 10));
|
|
QJsonArray series;
|
|
std::vector<qint64> afterWarmup;
|
|
QEventLoop loop;
|
|
QTimer sampler;
|
|
sampler.setInterval(1000);
|
|
int elapsedSec = 0;
|
|
QObject::connect(&sampler, &QTimer::timeout, [&] {
|
|
++elapsedSec;
|
|
const qint64 rssKiB = readRssKiB();
|
|
series.append(QJsonObject{{"t", elapsedSec}, {"rssKiB", rssKiB}});
|
|
if (elapsedSec > warmupSec) {
|
|
afterWarmup.push_back(rssKiB);
|
|
}
|
|
if (elapsedSec >= durationSec) {
|
|
loop.quit();
|
|
}
|
|
});
|
|
sampler.start();
|
|
loop.exec();
|
|
|
|
qint64 growthKiB = 0;
|
|
if (afterWarmup.size() >= 2) {
|
|
const qint64 minRss = *std::min_element(afterWarmup.begin(), afterWarmup.end());
|
|
growthKiB = afterWarmup.back() - minRss;
|
|
}
|
|
|
|
qint64 slackKiB = 20 * 1024; // 20 MiB: see gui/docs/pkg-qa-requests-m1.md R3 for why
|
|
const QString override = qEnvironmentVariable("VELOX_DOD_RSS_SLACK_KIB");
|
|
if (!override.isEmpty()) {
|
|
bool ok = false;
|
|
const qint64 v = override.toLongLong(&ok);
|
|
if (ok) {
|
|
slackKiB = v;
|
|
}
|
|
}
|
|
|
|
const bool pass = afterWarmup.size() >= 2 && growthKiB <= slackKiB;
|
|
std::printf("%s: growth=%lld KiB slack=%lld KiB over %ds (warmup %ds), %d rows\n",
|
|
pass ? "PASS" : "FAIL", static_cast<long long>(growthKiB),
|
|
static_cast<long long>(slackKiB), durationSec, warmupSec, model.rowCount());
|
|
|
|
writeJson(jsonPath, QJsonObject{
|
|
{"gate", "rss-flat"},
|
|
{"rows", model.rowCount()},
|
|
{"durationSec", durationSec},
|
|
{"warmupSec", warmupSec},
|
|
{"growthKiB", growthKiB},
|
|
{"slackKiB", slackKiB},
|
|
{"series", series},
|
|
{"pass", pass},
|
|
});
|
|
return pass ? 0 : 1;
|
|
}
|
|
|
|
int runUnhappyPath(rpc::RpcClient &client, const QString &jsonPath, const QString &phase) {
|
|
bool sawDisconnectOrReconnecting = false;
|
|
QObject::connect(&client, &rpc::RpcClient::stateChanged, &client, [&](rpc::ConnectionState s) {
|
|
if (s == rpc::ConnectionState::Reconnecting || s == rpc::ConnectionState::Disconnected) {
|
|
sawDisconnectOrReconnecting = true;
|
|
}
|
|
});
|
|
|
|
// 45 s covers mockd's slowest documented --slow value plus a couple of backoff
|
|
// cycles; the harness's own watchdog (see main()) is the real ceiling on a hang.
|
|
constexpr int kObserveMs = 45000;
|
|
const bool reachedConnected =
|
|
waitFor([&] { return client.state() == rpc::ConnectionState::Connected; }, kObserveMs);
|
|
|
|
QElapsedTimer t;
|
|
t.start();
|
|
while (t.elapsed() < kObserveMs) {
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
|
|
}
|
|
|
|
const bool finalConnected = client.state() == rpc::ConnectionState::Connected;
|
|
const bool pass = reachedConnected && finalConnected;
|
|
std::printf("%s [%s]: reachedConnected=%d finalConnected=%d sawDisruption=%d\n",
|
|
pass ? "PASS" : "FAIL", qUtf8Printable(phase), reachedConnected, finalConnected,
|
|
sawDisconnectOrReconnecting);
|
|
|
|
writeJson(jsonPath, QJsonObject{
|
|
{"gate", "unhappy-path"},
|
|
{"phase", phase},
|
|
{"reachedConnected", reachedConnected},
|
|
{"finalConnected", finalConnected},
|
|
{"sawDisruption", sawDisconnectOrReconnecting},
|
|
{"pass", pass},
|
|
});
|
|
return pass ? 0 : 1;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv) {
|
|
QApplication app(argc, argv);
|
|
qRegisterMetaType<rpc::ConnectionState>();
|
|
qRegisterMetaType<rpc::RpcReply>();
|
|
|
|
QCommandLineParser parser;
|
|
parser.setApplicationDescription(
|
|
QStringLiteral("GUI M1 DoD gate harness (gui/docs/pkg-qa-requests-m1.md R3)"));
|
|
parser.addHelpOption();
|
|
parser.addPositionalArgument(QStringLiteral("gate"),
|
|
QStringLiteral("scroll-60fps | rss-flat | unhappy-path"));
|
|
QCommandLineOption sockOpt(QStringLiteral("sock"), QStringLiteral("veloxd UDS socket path"),
|
|
QStringLiteral("path"));
|
|
QCommandLineOption jsonOpt(QStringLiteral("json"),
|
|
QStringLiteral("write one result object here"),
|
|
QStringLiteral("path"));
|
|
QCommandLineOption durationOpt(QStringLiteral("duration-sec"),
|
|
QStringLiteral("rss-flat duration override (default 600)"),
|
|
QStringLiteral("sec"));
|
|
QCommandLineOption phaseOpt(QStringLiteral("phase"),
|
|
QStringLiteral("unhappy-path sub-phase label, for the JSON only"),
|
|
QStringLiteral("phase"), QStringLiteral("unspecified"));
|
|
parser.addOption(sockOpt);
|
|
parser.addOption(jsonOpt);
|
|
parser.addOption(durationOpt);
|
|
parser.addOption(phaseOpt);
|
|
parser.process(app);
|
|
|
|
const QStringList pos = parser.positionalArguments();
|
|
if (pos.isEmpty() || !parser.isSet(sockOpt)) {
|
|
std::fprintf(stderr,
|
|
"usage: dod_harness <gate> --sock <path> [--json <path>] "
|
|
"[--duration-sec <n>] [--phase <label>]\n");
|
|
return 2;
|
|
}
|
|
const QString gate = pos.first();
|
|
const int durationSec = parser.isSet(durationOpt) ? parser.value(durationOpt).toInt() : 600;
|
|
|
|
rpc::RpcClient client(parser.value(sockOpt));
|
|
client.start();
|
|
|
|
// The harness's own watchdog: whatever gate is running, it must exit on its own by
|
|
// this ceiling. Firing is itself a failure (a hang), not a signal for the caller to
|
|
// timeout(1) around — see the file comment.
|
|
int watchdogSec = 120;
|
|
if (gate == QLatin1String("rss-flat")) {
|
|
watchdogSec = durationSec + 90;
|
|
} else if (gate == QLatin1String("unhappy-path")) {
|
|
watchdogSec = 75;
|
|
}
|
|
QTimer watchdog;
|
|
watchdog.setSingleShot(true);
|
|
QObject::connect(&watchdog, &QTimer::timeout, [watchdogSec] {
|
|
std::fprintf(stderr, "FAIL: dod_harness watchdog fired — hung past %ds\n", watchdogSec);
|
|
std::exit(3);
|
|
});
|
|
watchdog.start(watchdogSec * 1000);
|
|
|
|
int rc = 2;
|
|
if (gate == QLatin1String("scroll-60fps")) {
|
|
rc = runScroll60Fps(client, parser.value(jsonOpt));
|
|
} else if (gate == QLatin1String("rss-flat")) {
|
|
rc = runRssFlat(client, parser.value(jsonOpt), durationSec);
|
|
} else if (gate == QLatin1String("unhappy-path")) {
|
|
rc = runUnhappyPath(client, parser.value(jsonOpt), parser.value(phaseOpt));
|
|
} else {
|
|
std::fprintf(stderr, "unknown gate: %s\n", qUtf8Printable(gate));
|
|
}
|
|
|
|
client.stop();
|
|
return rc;
|
|
}
|