From c5c40490301bd696139f706a6add57f6dae0809c Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Sun, 13 Sep 2026 07:37:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=20watchdog=20con=20timeout=20su=20tut?= =?UTF-8?q?te=20le=20richieste=20=E2=80=94=20mai=20piu'=20attese=20infinit?= =?UTF-8?q?e=20(v0.5.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sintomo sul device: l'app restava sullo splash in caricamento per sempre. Causa: QNetworkAccessManager non applica timeout — una richiesta che non riceve risposta (rete del telefono, server occupato) lasciava la UI in attesa infinita, senza errori visibili. - ApiClient::armTimeout(): watchdog per reply (abort -> normale percorso d'errore del chiamante). Applicato a: checkAuth (12 s), login (20 s), refreshSessions e openSession (60 s), newSession/chat-start/switchProfile (20-30 s), fetchProfiles (20 s), transcribe (300 s), tts (120 s). Nessun watchdog sullo stream SSE (longevo per design). - checkAuth: imposta lastError anche in errore (messaggio tradotto "Server not reachable (timeout)") e logga indirizzo ed esito - refreshSessions: log della durata (misurato sul probe: il primo fetch dopo un cambio profilo puo' prendere 11-16 s lato server, anche su DB minuscolo — l'app ora lo gestisce senza sembrare bloccata) - SplashPage: rete di sicurezza (se authChecked non arriva entro 15 s si va comunque al login) + log di instradamento - i18n: nuova stringa tradotta it/fr/de --- README.md | 5 ++-- docs/BUILD.md | 6 ++--- qml/pages/SplashPage.qml | 9 +++++++ rpm/harbour-hermes.spec | 9 ++++++- src/apiclient.cpp | 40 ++++++++++++++++++++++++++++++- src/apiclient.h | 2 ++ src/main.cpp | 4 ++-- translations/harbour-hermes-de.ts | 11 ++++++--- translations/harbour-hermes-fr.ts | 11 ++++++--- translations/harbour-hermes-it.ts | 11 ++++++--- 10 files changed, 90 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index def386f..9e4608d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ speaks (validated live against a real instance). ## Status -Version 0.5.0 — protocol and C++ core validated against a real `hermes-webui` +Version 0.5.1 — protocol and C++ core validated against a real `hermes-webui` (login, sessions, streaming chat, TTS, STT, voice dialog, profile selection); -UI in English, Italian, French and German; on-device testing still ongoing. +UI in English, Italian, French and German; every request has a watchdog +timeout (no infinite waits); on-device testing still ongoing. diff --git a/docs/BUILD.md b/docs/BUILD.md index 591b915..55983f0 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -76,12 +76,12 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK. ```bash cd ~/workspace -tar czf harbour-hermes-0.5.0.tar.gz \ - --transform 's,^harbour-hermes,harbour-hermes-0.5.0,' \ +tar czf harbour-hermes-0.5.1.tar.gz \ + --transform 's,^harbour-hermes,harbour-hermes-0.5.1,' \ --exclude='.git' harbour-hermes ``` -Lo spec si aspetta la directory `harbour-hermes-0.5.0/` (pattern degli altri +Lo spec si aspetta la directory `harbour-hermes-0.5.1/` (pattern degli altri progetti: cercato in modo robusto anche per i sorgenti live di sfdk). ## Traduzioni diff --git a/qml/pages/SplashPage.qml b/qml/pages/SplashPage.qml index 4318635..2c586cb 100644 --- a/qml/pages/SplashPage.qml +++ b/qml/pages/SplashPage.qml @@ -30,9 +30,18 @@ Page { onAuthChecked: page.route(loggedIn) } + // Rete di sicurezza: se il controllo d'avvio non risponde entro 15 s + // (bug o rete muta), si va comunque al login invece di restare qui. + Timer { + interval: 15000 + running: true + onTriggered: page.route(false) + } + Component.onCompleted: api.checkAuth() function route(loggedIn) { + console.log("[hermes] route: loggedIn=" + loggedIn) if (loggedIn) pageStack.replace(Qt.resolvedUrl("SessionsPage.qml")) else diff --git a/rpm/harbour-hermes.spec b/rpm/harbour-hermes.spec index 3bc27ec..f1cfa3e 100644 --- a/rpm/harbour-hermes.spec +++ b/rpm/harbour-hermes.spec @@ -1,6 +1,6 @@ Name: harbour-hermes Summary: Client for the Hermes Web UI with voice -Version: 0.5.0 +Version: 0.5.1 Release: 1 Group: Qt/Qt License: MIT @@ -25,6 +25,13 @@ Sessions list, chat with live streaming replies, voice dictation (server-side TTS), plus a hands-free conversation mode. %changelog +* Sun Sep 13 2026 Carlo Baratto - 0.5.1-1 +- Fix blocco sullo splash all'avvio: watchdog con timeout su tutte le + richieste (QNAM non ne applica: una richiesta senza risposta lasciava + la UI in attesa infinita). checkAuth imposta un errore visibile + ("Server non raggiungibile (timeout)") e logga esito e indirizzo; + refreshSessions logga la durata; splash con rete di sicurezza verso il + login dopo 15 s; nessun watchdog sullo stream SSE (longevo per design) * Sat Sep 12 2026 Carlo Baratto - 0.5.0-1 - Sezione Sessions nelle Impostazioni: selettore profilo (le sessioni mostrate sono quelle del profilo scelto). Switch per-client via diff --git a/src/apiclient.cpp b/src/apiclient.cpp index 0d58049..5613698 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include ApiClient::ApiClient(Settings *settings, QObject *parent) @@ -35,6 +36,25 @@ ApiClient::ApiClient(Settings *settings, QObject *parent) connect(m_settings, &Settings::profileChanged, this, &ApiClient::profileChanged); } +// QNetworkAccessManager non applica timeout: senza watchdog una richiesta +// senza risposta (rete assente, server occupato) lascerebbe la UI in attesa +// per sempre. armTimeout aborta la reply dopo N secondi: poi si attiva il +// normale percorso d'errore del chiamante (messaggio visibile, stato resettato). +void ApiClient::armTimeout(QNetworkReply *reply, int seconds) +{ + QTimer *timer = new QTimer(reply); + timer->setSingleShot(true); + timer->setInterval(seconds * 1000); + connect(timer, &QTimer::timeout, reply, [reply, seconds]() { + if (reply->isRunning()) { + qDebug() << "[api] timeout dopo" << seconds << "s su" << reply->url().path(); + reply->abort(); + } + }); + connect(reply, &QNetworkReply::finished, timer, &QTimer::stop); + timer->start(); +} + // ---------------------------------------------------------------- helpers -- QUrl ApiClient::apiUrl(const QString &path) const @@ -132,10 +152,16 @@ void ApiClient::checkAuth() return; } QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status"))); + armTimeout(reply, 12); // il controllo d'avvio deve risolversi, sempre + qDebug() << "[api] checkAuth ->" << m_settings->baseUrl(); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); const QByteArray body = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { + const bool wasTimeout = (reply->error() == QNetworkReply::OperationCanceledError); + setLastError(wasTimeout ? tr("Server not reachable (timeout)") + : errorFromReply(reply, body)); + qDebug() << "[api] checkAuth FALLITO:" << m_lastError; setLoggedIn(false); emit authChecked(false); return; @@ -149,6 +175,7 @@ void ApiClient::checkAuth() } else { setLoggedIn(false); } + qDebug() << "[api] checkAuth:" << (m_loggedIn ? "sessione valida" : "serve il login"); emit authChecked(m_loggedIn); }); } @@ -165,6 +192,7 @@ void ApiClient::login(const QString &password) body.insert(QStringLiteral("password"), password); QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")), QJsonDocument(body).toJson(QJsonDocument::Compact)); + armTimeout(reply, 20); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); setBusy(false); @@ -210,9 +238,12 @@ void ApiClient::refreshSessions() q.addQueryItem(QStringLiteral("exclude_hidden"), QStringLiteral("1")); url.setQuery(q); QNetworkReply *reply = m_nam.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + armTimeout(reply, 60); // il primo fetch dopo un cambio profilo puo' essere lento + const qint64 t0 = QDateTime::currentMSecsSinceEpoch(); + connect(reply, &QNetworkReply::finished, this, [this, reply, t0]() { reply->deleteLater(); setBusy(false); + qDebug() << "[api] sessions in" << (QDateTime::currentMSecsSinceEpoch() - t0) << "ms"; const QByteArray body = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { setLastError(errorFromReply(reply, body)); @@ -238,6 +269,7 @@ QString ApiClient::profile() const void ApiClient::fetchProfiles() { QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/profiles"))); + armTimeout(reply, 20); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); const QByteArray data = reply->readAll(); @@ -279,6 +311,7 @@ void ApiClient::switchProfile(const QString &name) body.insert(QStringLiteral("name"), target); QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/profile/switch")), QJsonDocument(body).toJson(QJsonDocument::Compact)); + armTimeout(reply, 20); connect(reply, &QNetworkReply::finished, this, [this, reply, target]() { reply->deleteLater(); const QByteArray data = reply->readAll(); @@ -307,6 +340,7 @@ void ApiClient::openSession(const QString &id) q.addQueryItem(QStringLiteral("session_id"), id); url.setQuery(q); QNetworkReply *reply = m_nam.get(QNetworkRequest(url)); + armTimeout(reply, 60); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); setBusy(false); @@ -337,6 +371,7 @@ void ApiClient::newSession() body.insert(QStringLiteral("workspace"), ws); QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")), QJsonDocument(body).toJson(QJsonDocument::Compact)); + armTimeout(reply, 30); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); setBusy(false); @@ -374,6 +409,7 @@ void ApiClient::startChat(const QString &sessionId, const QString &message) QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/chat/start")), QJsonDocument(body).toJson(QJsonDocument::Compact)); + armTimeout(reply, 30); connect(reply, &QNetworkReply::finished, this, [this, reply]() { handleStartReply(reply); }); @@ -540,6 +576,7 @@ void ApiClient::transcribeFile(const QString &path) QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe"))); QNetworkReply *reply = m_nam.post(req, multiPart); multiPart->setParent(reply); + armTimeout(reply, 300); // la trascrizione puo' richiedere tempo connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); @@ -584,6 +621,7 @@ void ApiClient::speak(const QString &text) body.insert(QStringLiteral("engine"), m_settings->ttsEngine()); QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")), QJsonDocument(body).toJson(QJsonDocument::Compact)); + armTimeout(reply, 120); connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); const QByteArray body = reply->readAll(); diff --git a/src/apiclient.h b/src/apiclient.h index 9b06929..e0fb8d0 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -112,6 +112,8 @@ private: void setTranscribing(bool on); void setLastError(const QString &error); void setLoggedIn(bool on); + // Watchdog: aborta la reply dopo N secondi (QNAM non applica timeout). + void armTimeout(QNetworkReply *reply, int seconds); QUrl apiUrl(const QString &path) const; QNetworkRequest jsonRequest(const QString &path, bool post = false) const; diff --git a/src/main.cpp b/src/main.cpp index c66d9dc..475c6c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,9 +21,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) // AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox). app->setOrganizationName(QStringLiteral("harbour")); app->setApplicationName(QStringLiteral("hermes")); - app->setApplicationVersion(QStringLiteral("0.5.0")); + app->setApplicationVersion(QStringLiteral("0.5.1")); - qDebug() << "harbour-hermes v0.5.0 build" << __DATE__ << __TIME__; + qDebug() << "harbour-hermes v0.5.1 build" << __DATE__ << __TIME__; // Traduzioni: it/fr/de da /usr/share/harbour-hermes/translations, in base // alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm). diff --git a/translations/harbour-hermes-de.ts b/translations/harbour-hermes-de.ts index 845c50b..73ed7fb 100644 --- a/translations/harbour-hermes-de.ts +++ b/translations/harbour-hermes-de.ts @@ -4,17 +4,22 @@ ApiClient - + Authentication required Authentifizierung erforderlich - + HTTP %1 HTTP %1 - + + Server not reachable (timeout) + Server nicht erreichbar (Zeitüberschreitung) + + + Server address not configured Serveradresse nicht konfiguriert diff --git a/translations/harbour-hermes-fr.ts b/translations/harbour-hermes-fr.ts index 340c90e..485c45a 100644 --- a/translations/harbour-hermes-fr.ts +++ b/translations/harbour-hermes-fr.ts @@ -4,17 +4,22 @@ ApiClient - + Authentication required Authentification requise - + HTTP %1 HTTP %1 - + + Server not reachable (timeout) + Serveur injoignable (délai dépassé) + + + Server address not configured Adresse du serveur non configurée diff --git a/translations/harbour-hermes-it.ts b/translations/harbour-hermes-it.ts index 49fb60e..5d52c75 100644 --- a/translations/harbour-hermes-it.ts +++ b/translations/harbour-hermes-it.ts @@ -4,17 +4,22 @@ ApiClient - + Authentication required Autenticazione richiesta - + HTTP %1 HTTP %1 - + + Server not reachable (timeout) + Server non raggiungibile (timeout) + + + Server address not configured Indirizzo del server non configurato