#include "rpc/RpcClient.hpp" #include #include #include "rpc/RpcConnection.hpp" namespace velox::gui::rpc { // Off by default; enable with QT_LOGGING_RULES="velox.rpc.info=true". Q_LOGGING_CATEGORY(lcRpc, "velox.rpc") RpcClient::RpcClient(QString socketPath, QObject *parent) : QObject(parent) { conn_ = new RpcConnection(std::move(socketPath)); conn_->moveToThread(&thread_); // QThread flushes DeferredDelete for its objects after the loop exits, so this is the // safe way to destroy an object that lives on the worker thread. connect(&thread_, &QThread::finished, conn_, &QObject::deleteLater); connect(conn_, &RpcConnection::stateChanged, this, &RpcClient::onConnectionState); connect(conn_, &RpcConnection::notificationReceived, this, &RpcClient::onNotification); connect(conn_, &RpcConnection::callCompleted, this, &RpcClient::onCallCompleted); } RpcClient::~RpcClient() { if (thread_.isRunning()) { stop(); // joins thread_; the finished -> deleteLater above disposes of conn_ } else { delete conn_; // start() was never called: no loop will service deleteLater } conn_ = nullptr; // Nothing will ever complete the in-flight callbacks now. const auto pending = std::move(callbacks_); callbacks_.clear(); for (const auto &cb : pending) { if (cb) { RpcReply reply; reply.error = {-1, QStringLiteral("client shutting down"), {}}; cb(reply); } } } void RpcClient::start() { if (!thread_.isRunning()) { thread_.start(); } QMetaObject::invokeMethod(conn_, "start", Qt::QueuedConnection); } void RpcClient::stop() { if (!thread_.isRunning()) { return; } QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection); thread_.quit(); thread_.wait(); // thread_.wait() does not return until thread_'s own finish() has already flushed the // DeferredDelete this class's own connect(&thread_, &QThread::finished, conn_, // &QObject::deleteLater) posted — conn_ is gone by now. Null it out so a later call // (stop() is a public slot; a caller stopping and then destroying the client is normal // use, and the destructor's own `delete conn_` for the never-started case must not // run a second time against memory this path already freed). conn_ = nullptr; } void RpcClient::call(const QString &methodName, const QJsonObject ¶ms, std::function cb) { const quint64 tag = nextTag_++; callbacks_.insert(tag, std::move(cb)); QMetaObject::invokeMethod(conn_, "sendCall", Qt::QueuedConnection, Q_ARG(quint64, tag), Q_ARG(QString, methodName), Q_ARG(QJsonObject, params)); } void RpcClient::onConnectionState(int state) { state_ = static_cast(state); qCInfo(lcRpc, "connection state: %s", toString(state_)); emit stateChanged(state_); if (state_ == ConnectionState::Connected) { requestInitialList(); } } void RpcClient::requestInitialList() { fetchListPage(0, {}); } // download.list.schema.json: "Filtering, sorting and paging all happen in the daemon so // the GUI never materializes 100k rows to show 40" — limit maxes out at 5000, so one call // cannot ever return everything for a table the DoD's own gate says can hold 10 000 rows. // A single fixed-limit call here silently truncated the table below that (caught by // gui/tests/dod's scroll-60fps gate refusing to run against a 1000-row table when mockd // seeded 10000). Page until `total` is satisfied, then reset the model exactly once. void RpcClient::fetchListPage(int offset, QJsonArray accumulated) { constexpr int kPageSize = 5000; // download.list's own maximum constexpr int kMaxPages = 100; // 500 000 rows — a safety cap, not an expected ceiling call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"offset", offset}, {"limit", kPageSize}}, [this, offset, accumulated](const RpcReply &reply) mutable { if (!reply.ok()) { qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code, qUtf8Printable(reply.error.message)); if (!accumulated.isEmpty()) { emit taskListReset(accumulated); // show what we got rather than nothing } return; } const QJsonObject result = reply.result.toObject(); const QJsonArray page = result.value("items").toArray(); const qint64 total = static_cast(result.value("total").toDouble()); for (const QJsonValue &item : page) { accumulated.append(item); } const bool morePages = !page.isEmpty() && accumulated.size() < total && (offset / kPageSize) < kMaxPages; if (morePages) { fetchListPage(offset + static_cast(page.size()), accumulated); return; } qCInfo(lcRpc, "initial download.list: %lld of %lld row(s)", static_cast(accumulated.size()), static_cast(total)); emit taskListReset(accumulated); }); } void RpcClient::onNotification(const QString &methodName, const QJsonObject ¶ms) { qCDebug(lcRpc, "notification: %s", qUtf8Printable(methodName)); if (methodName == QLatin1String(event::kTaskProgress)) { const QJsonArray tasks = params.value("tasks").toArray(); qCDebug(lcRpc, "progress batch: %lld task(s)", static_cast(tasks.size())); emit taskProgress(tasks); } else if (methodName == QLatin1String(event::kTaskAdded)) { emit taskAdded(params.value("summary").toObject()); } else if (methodName == QLatin1String(event::kTaskState)) { emit taskStateChanged(params); } else if (methodName == QLatin1String(event::kTaskRemoved)) { emit taskRemoved(params.value("taskId").toString()); } else if (methodName == QLatin1String(event::kSpeedGlobal)) { emit speedGlobal(params); } else if (methodName == QLatin1String(event::kNotify)) { emit notify(params); } else if (methodName == QLatin1String(event::kSettingsChanged)) { emit settingsChanged(params); } else if (methodName == QLatin1String(event::kGrabberProgress)) { emit grabberProgress(params); } } void RpcClient::onCallCompleted(quint64 tag, const QJsonObject &envelope) { const auto cb = callbacks_.take(tag); if (!cb) { return; } RpcReply reply; if (envelope.contains("error")) { const QJsonObject e = envelope.value("error").toObject(); reply.error = {e.value("code").toInt(), e.value("message").toString(), e.value("data")}; } else { reply.result = envelope.value("result"); } cb(reply); } } // namespace velox::gui::rpc