diff --git a/energyplugin/etm/scheduler/socketscheduler.cpp b/energyplugin/etm/scheduler/socketscheduler.cpp index fcb268b..0124a51 100644 --- a/energyplugin/etm/scheduler/socketscheduler.cpp +++ b/energyplugin/etm/scheduler/socketscheduler.cpp @@ -30,11 +30,27 @@ SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, Fou connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue); // §7 « socket absent / fermé → rules, optimizerAlive = false » : un plan ne survit pas à la // connexion qui l'a porté, et un optimiseur qu'on n'atteint pas n'est pas vivant. + // + // La perte se note à la TRANSITION : sortie de Ready ou de Handshaking (fermeture, handshake + // refusé), ou premier échec d'un client qui n'a pas encore été connecté (optimiseur absent dès + // le départ : sans cela, lastErrorCode n'aurait jamais de valeur). Une tentative de reconnexion + // qui échoue pendant une panne déjà notée n'en est pas une nouvelle : ni etatChange(), ni + // dernier code réécrit (constat `drop`, brief 4 T7). connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) { - if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking) { - oublierCache(QStringLiteral("connexion perdue")); - noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode()); + const bool etaitConnecte = m_etatClient == OptimizerClient::Ready + || m_etatClient == OptimizerClient::Handshaking; + m_etatClient = s; + if (s == OptimizerClient::Ready) { + m_perteNotee = false; + return; } + if (s == OptimizerClient::Connecting || s == OptimizerClient::Handshaking) + return; // une tentative en cours n'est pas une perte + if (!etaitConnecte && m_perteNotee) + return; // tentative échouée pendant une panne déjà notée + m_perteNotee = true; + oublierCache(QStringLiteral("connexion perdue")); + noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode()); }); // Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose. connect(m_client, &OptimizerClient::ready, this, &SocketScheduler::etatChange); diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h index 498a5b4..96cd52a 100644 --- a/energyplugin/etm/scheduler/socketscheduler.h +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -38,6 +38,9 @@ * \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue, * déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend * vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié. + * Une connexion perdue se note UNE fois par panne (sortie de \c Ready ou \c Handshaking, ou + * premier échec d'un client jamais connecté) : les tentatives de reconnexion au backoff ne + * réécrivent ni le dernier code ni l'état publié. * * \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante. * \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based. @@ -184,5 +187,7 @@ private: QHash m_ecretageVu; // loadId → dernier écrêtage annoncé QSet m_reservesSignales; // une fois par session OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête + OptimizerClient::State m_etatClient = OptimizerClient::Stopped; // dernier état vu du client + bool m_perteNotee = false; // panne en cours déjà notée : ses tentatives n'en sont pas bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session }; diff --git a/tests/auto/common/etm-powersync-fakeoptimizer.h b/tests/auto/common/etm-powersync-fakeoptimizer.h index 9fd925f..77f4448 100644 --- a/tests/auto/common/etm-powersync-fakeoptimizer.h +++ b/tests/auto/common/etm-powersync-fakeoptimizer.h @@ -117,6 +117,17 @@ public: local->abort(); } + //! L'optimiseur disparaît : la connexion est coupée et le serveur cesse d'écouter — chaque + //! tentative du client échoue jusqu'au prochain listen() (même endpoint, même port). + void fermer() + { + abortClient(); + delete m_local; + m_local = nullptr; + delete m_tcp; + m_tcp = nullptr; + } + int connections = 0; int disconnections = 0; //! Répondeur automatique (brief 4 T3) : appelé sur chaque message reçu ; s'il rend vrai, le diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp index cf1559c..af4d104 100644 --- a/tests/auto/refoptimizer/testrefoptimizer.cpp +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -645,12 +645,11 @@ void TestRefOptimizer::testEachFaultModeGivesItsRow_data() "rules|vivant|0|timeout", "optimizer|vivant|0|timeout"} << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; // drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête - // en vol est compté (`disconnected`). Lu juste après le cycle, avant la reconnexion (1 s) ; - // la TENTATIVE de reconnexion réécrit ensuite le dernier code avec celui du client - // (`connection-lost`) — constaté ici, décrit au rapport T7. + // en vol est compté (`disconnected`). La reconnexion qui suit n'est pas une nouvelle perte : + // le dernier code reste celui de l'incident. QTest::newRow("drop") << "drop" << false << QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected", - "rules|vivant|0|connection-lost", "optimizer|vivant|0|connection-lost"} + "rules|vivant|0|disconnected", "optimizer|vivant|0|disconnected"} << QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"}; // wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part. QTest::newRow("wrong-major") << "wrong-major" << true @@ -744,8 +743,8 @@ void TestRefOptimizer::testAProtocol10OptimizerIsAccepted() QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); const QVariantMap st = rpc("GetOptimizerStatus"); QCOMPARE(st.value("optimizerAlive").toBool(), true); - QCOMPARE(st.value("name").toString(), QString("powersync-optimizer")); QCOMPARE(st.value("protocolVersion").toString(), QString("1.0")); + QCOMPARE(st.value("name").toString(), QString("powersync-optimizer")); QVERIFY(!st.contains("capabilities")); QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 3}})); // Sans reasonCode, le motif vers l'app est le code générique, sans paramètres. diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index c4e0b1f..36490b2 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -117,6 +117,7 @@ private slots: void testStatusFollowsTheFallbackTable_data(); void testStatusFollowsTheFallbackTable(); void testEachTransitionNotifiesOnce(); + void testAReconnectionAttemptIsNotANewLoss(); void testEndpointIsSetByRpcAndAppliedHot(); void testTokenIsWriteOnlyAndNeverLeaks(); void testGetPlanPublishesThePlanInForce(); @@ -1150,6 +1151,58 @@ void TestSocketScheduler::testEachTransitionNotifiesOnce() QCOMPARE(notifie, lu); } +void TestSocketScheduler::testAReconnectionAttemptIsNotANewLoss() +{ + // Constat `drop` (brief 4 T7) : pendant une panne, chaque tentative de reconnexion au backoff + // n'est PAS une nouvelle perte. Une seule transition à la perte, une seule au retour ; entre + // les deux, ni OptimizerStatusChanged, ni etatChange(), ni dernier code réécrit. + monter(); + enableNotifications({"NymeaEnergy"}); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + amenerAOptimiseur(t0); + SocketScheduler *s = m_arb->socketScheduler(); + QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData); + QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged); + QSignalSpy etats(s, &SocketScheduler::etatChange); + QSignalSpy client(s->client(), &OptimizerClient::stateChanged); + auto tentatives = [&client] { + int n = 0; + for (const QList &a : client) + n += a.first().value() == OptimizerClient::Connecting; + return n; + }; + + // Perte : l'optimiseur disparaît, puis N = 3 tentatives échouent au backoff (1, 2, 4 s). + m_serveur->fermer(); + QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Waiting; })); + QCOMPARE(etats.count(), 1); + QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"}); + QVERIFY2(waitFor([&] { return tentatives() >= 3 && s->client()->state() == OptimizerClient::Waiting; }, 15000), + qPrintable(QString::number(tentatives()))); + QCOMPARE(tentatives(), 3); + // Les tentatives ont bien échoué, avec un autre code que la perte : le test serait aveugle sinon. + QCOMPARE(s->client()->lastErrorCode(), QString("connect-failed")); + QCOMPARE(etats.count(), 1); + QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"}); + QVariantMap st = rpc("GetOptimizerStatus"); + QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost")); + QCOMPARE(st.value("consecutiveFailures").toInt(), 0); + QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 1); + + // Retour : l'optimiseur réécoute au même endpoint ; la tentative suivante aboutit. + QVERIFY(m_serveur->listen()); + QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 20000)); + QCOMPARE(etats.count(), 2); // le handshake accepté (identité), et rien d'autre + cycle(t0.addSecs(120), -3000); + cycle(t0.addSecs(180), -3000); + QVERIFY(s->optimiseurAuDernierCycle()); + QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"})); + QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 3); + st = rpc("GetOptimizerStatus"); + QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost")); // jamais effacé (T4 Q5) + QCOMPARE(st.value("consecutiveFailures").toInt(), 0); +} + void TestSocketScheduler::testEndpointIsSetByRpcAndAppliedHot() { monter(false);