fix(api): watchdog con timeout su tutte le richieste — mai piu' attese infinite (v0.5.1)

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
This commit is contained in:
2026-09-13 07:37:12 +02:00
parent 7b8e5e1102
commit c5c4049030
10 changed files with 90 additions and 18 deletions
+3 -2
View File
@@ -76,6 +76,7 @@ speaks (validated live against a real instance).
## Status ## 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); (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.
+3 -3
View File
@@ -76,12 +76,12 @@ richiede il probe WebUI su :8899). Esito 12/09/2026: unit 9/9, live completo OK.
```bash ```bash
cd ~/workspace cd ~/workspace
tar czf harbour-hermes-0.5.0.tar.gz \ tar czf harbour-hermes-0.5.1.tar.gz \
--transform 's,^harbour-hermes,harbour-hermes-0.5.0,' \ --transform 's,^harbour-hermes,harbour-hermes-0.5.1,' \
--exclude='.git' harbour-hermes --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). progetti: cercato in modo robusto anche per i sorgenti live di sfdk).
## Traduzioni ## Traduzioni
+9
View File
@@ -30,9 +30,18 @@ Page {
onAuthChecked: page.route(loggedIn) 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() Component.onCompleted: api.checkAuth()
function route(loggedIn) { function route(loggedIn) {
console.log("[hermes] route: loggedIn=" + loggedIn)
if (loggedIn) if (loggedIn)
pageStack.replace(Qt.resolvedUrl("SessionsPage.qml")) pageStack.replace(Qt.resolvedUrl("SessionsPage.qml"))
else else
+8 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-hermes Name: harbour-hermes
Summary: Client for the Hermes Web UI with voice Summary: Client for the Hermes Web UI with voice
Version: 0.5.0 Version: 0.5.1
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -25,6 +25,13 @@ Sessions list, chat with live streaming replies, voice dictation
(server-side TTS), plus a hands-free conversation mode. (server-side TTS), plus a hands-free conversation mode.
%changelog %changelog
* Sun Sep 13 2026 Carlo Baratto <carlo@carlobaratto.it> - 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 <carlo@carlobaratto.it> - 0.5.0-1 * Sat Sep 12 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.5.0-1
- Sezione Sessions nelle Impostazioni: selettore profilo (le sessioni - Sezione Sessions nelle Impostazioni: selettore profilo (le sessioni
mostrate sono quelle del profilo scelto). Switch per-client via mostrate sono quelle del profilo scelto). Switch per-client via
+39 -1
View File
@@ -10,6 +10,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer>
#include <QUrlQuery> #include <QUrlQuery>
ApiClient::ApiClient(Settings *settings, QObject *parent) ApiClient::ApiClient(Settings *settings, QObject *parent)
@@ -35,6 +36,25 @@ ApiClient::ApiClient(Settings *settings, QObject *parent)
connect(m_settings, &Settings::profileChanged, this, &ApiClient::profileChanged); 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 -- // ---------------------------------------------------------------- helpers --
QUrl ApiClient::apiUrl(const QString &path) const QUrl ApiClient::apiUrl(const QString &path) const
@@ -132,10 +152,16 @@ void ApiClient::checkAuth()
return; return;
} }
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/auth/status"))); 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]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
const QByteArray body = reply->readAll(); const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) { 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); setLoggedIn(false);
emit authChecked(false); emit authChecked(false);
return; return;
@@ -149,6 +175,7 @@ void ApiClient::checkAuth()
} else { } else {
setLoggedIn(false); setLoggedIn(false);
} }
qDebug() << "[api] checkAuth:" << (m_loggedIn ? "sessione valida" : "serve il login");
emit authChecked(m_loggedIn); emit authChecked(m_loggedIn);
}); });
} }
@@ -165,6 +192,7 @@ void ApiClient::login(const QString &password)
body.insert(QStringLiteral("password"), password); body.insert(QStringLiteral("password"), password);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/auth/login")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 20);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
setBusy(false); setBusy(false);
@@ -210,9 +238,12 @@ void ApiClient::refreshSessions()
q.addQueryItem(QStringLiteral("exclude_hidden"), QStringLiteral("1")); q.addQueryItem(QStringLiteral("exclude_hidden"), QStringLiteral("1"));
url.setQuery(q); url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url)); 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(); reply->deleteLater();
setBusy(false); setBusy(false);
qDebug() << "[api] sessions in" << (QDateTime::currentMSecsSinceEpoch() - t0) << "ms";
const QByteArray body = reply->readAll(); const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) { if (reply->error() != QNetworkReply::NoError) {
setLastError(errorFromReply(reply, body)); setLastError(errorFromReply(reply, body));
@@ -238,6 +269,7 @@ QString ApiClient::profile() const
void ApiClient::fetchProfiles() void ApiClient::fetchProfiles()
{ {
QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/profiles"))); QNetworkReply *reply = m_nam.get(jsonRequest(QStringLiteral("/api/profiles")));
armTimeout(reply, 20);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
const QByteArray data = reply->readAll(); const QByteArray data = reply->readAll();
@@ -279,6 +311,7 @@ void ApiClient::switchProfile(const QString &name)
body.insert(QStringLiteral("name"), target); body.insert(QStringLiteral("name"), target);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/profile/switch")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/profile/switch")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 20);
connect(reply, &QNetworkReply::finished, this, [this, reply, target]() { connect(reply, &QNetworkReply::finished, this, [this, reply, target]() {
reply->deleteLater(); reply->deleteLater();
const QByteArray data = reply->readAll(); const QByteArray data = reply->readAll();
@@ -307,6 +340,7 @@ void ApiClient::openSession(const QString &id)
q.addQueryItem(QStringLiteral("session_id"), id); q.addQueryItem(QStringLiteral("session_id"), id);
url.setQuery(q); url.setQuery(q);
QNetworkReply *reply = m_nam.get(QNetworkRequest(url)); QNetworkReply *reply = m_nam.get(QNetworkRequest(url));
armTimeout(reply, 60);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
setBusy(false); setBusy(false);
@@ -337,6 +371,7 @@ void ApiClient::newSession()
body.insert(QStringLiteral("workspace"), ws); body.insert(QStringLiteral("workspace"), ws);
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/session/new")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 30);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
setBusy(false); 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")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/chat/start")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 30);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
handleStartReply(reply); handleStartReply(reply);
}); });
@@ -540,6 +576,7 @@ void ApiClient::transcribeFile(const QString &path)
QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe"))); QNetworkRequest req(apiUrl(QStringLiteral("/api/transcribe")));
QNetworkReply *reply = m_nam.post(req, multiPart); QNetworkReply *reply = m_nam.post(req, multiPart);
multiPart->setParent(reply); multiPart->setParent(reply);
armTimeout(reply, 300); // la trascrizione puo' richiedere tempo
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
@@ -584,6 +621,7 @@ void ApiClient::speak(const QString &text)
body.insert(QStringLiteral("engine"), m_settings->ttsEngine()); body.insert(QStringLiteral("engine"), m_settings->ttsEngine());
QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")), QNetworkReply *reply = m_nam.post(jsonRequest(QStringLiteral("/api/tts")),
QJsonDocument(body).toJson(QJsonDocument::Compact)); QJsonDocument(body).toJson(QJsonDocument::Compact));
armTimeout(reply, 120);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
const QByteArray body = reply->readAll(); const QByteArray body = reply->readAll();
+2
View File
@@ -112,6 +112,8 @@ private:
void setTranscribing(bool on); void setTranscribing(bool on);
void setLastError(const QString &error); void setLastError(const QString &error);
void setLoggedIn(bool on); 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; QUrl apiUrl(const QString &path) const;
QNetworkRequest jsonRequest(const QString &path, bool post = false) const; QNetworkRequest jsonRequest(const QString &path, bool post = false) const;
+2 -2
View File
@@ -21,9 +21,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
// AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox). // AppConfigLocation = ~/.config/harbour/hermes (zona persistente della sandbox).
app->setOrganizationName(QStringLiteral("harbour")); app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("hermes")); 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 // Traduzioni: it/fr/de da /usr/share/harbour-hermes/translations, in base
// alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm). // alla lingua di sistema (l'inglese e' la lingua sorgente: nessun .qm).
+8 -3
View File
@@ -4,17 +4,22 @@
<context> <context>
<name>ApiClient</name> <name>ApiClient</name>
<message> <message>
<location filename="../src/apiclient.cpp" line="72" /> <location filename="../src/apiclient.cpp" line="92" />
<source>Authentication required</source> <source>Authentication required</source>
<translation>Authentifizierung erforderlich</translation> <translation>Authentifizierung erforderlich</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="74" /> <location filename="../src/apiclient.cpp" line="94" />
<source>HTTP %1</source> <source>HTTP %1</source>
<translation>HTTP %1</translation> <translation>HTTP %1</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="159" /> <location filename="../src/apiclient.cpp" line="162" />
<source>Server not reachable (timeout)</source>
<translation>Server nicht erreichbar (Zeitüberschreitung)</translation>
</message>
<message>
<location filename="../src/apiclient.cpp" line="186" />
<source>Server address not configured</source> <source>Server address not configured</source>
<translation>Serveradresse nicht konfiguriert</translation> <translation>Serveradresse nicht konfiguriert</translation>
</message> </message>
+8 -3
View File
@@ -4,17 +4,22 @@
<context> <context>
<name>ApiClient</name> <name>ApiClient</name>
<message> <message>
<location filename="../src/apiclient.cpp" line="72" /> <location filename="../src/apiclient.cpp" line="92" />
<source>Authentication required</source> <source>Authentication required</source>
<translation>Authentification requise</translation> <translation>Authentification requise</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="74" /> <location filename="../src/apiclient.cpp" line="94" />
<source>HTTP %1</source> <source>HTTP %1</source>
<translation>HTTP %1</translation> <translation>HTTP %1</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="159" /> <location filename="../src/apiclient.cpp" line="162" />
<source>Server not reachable (timeout)</source>
<translation>Serveur injoignable (délai dépassé)</translation>
</message>
<message>
<location filename="../src/apiclient.cpp" line="186" />
<source>Server address not configured</source> <source>Server address not configured</source>
<translation>Adresse du serveur non configurée</translation> <translation>Adresse du serveur non configurée</translation>
</message> </message>
+8 -3
View File
@@ -4,17 +4,22 @@
<context> <context>
<name>ApiClient</name> <name>ApiClient</name>
<message> <message>
<location filename="../src/apiclient.cpp" line="72" /> <location filename="../src/apiclient.cpp" line="92" />
<source>Authentication required</source> <source>Authentication required</source>
<translation>Autenticazione richiesta</translation> <translation>Autenticazione richiesta</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="74" /> <location filename="../src/apiclient.cpp" line="94" />
<source>HTTP %1</source> <source>HTTP %1</source>
<translation>HTTP %1</translation> <translation>HTTP %1</translation>
</message> </message>
<message> <message>
<location filename="../src/apiclient.cpp" line="159" /> <location filename="../src/apiclient.cpp" line="162" />
<source>Server not reachable (timeout)</source>
<translation>Server non raggiungibile (timeout)</translation>
</message>
<message>
<location filename="../src/apiclient.cpp" line="186" />
<source>Server address not configured</source> <source>Server address not configured</source>
<translation>Indirizzo del server non configurato</translation> <translation>Indirizzo del server non configurato</translation>
</message> </message>