From ce323a6e1fd3c07584072633d5bc8d3bc99024e0 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 14:34:38 +0200 Subject: [PATCH] fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état non connecté du client (Connecting, Waiting d'une tentative), réécrivait lastErrorCode avec celui du client et émettait etatChange() à chaque tentative au backoff (une par minute au plafond). La perte se note désormais à la transition : sortie de Ready ou de Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui n'a jamais été connecté (sinon un optimiseur absent dès le départ n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié. Publication seule : aucune décision ne change. Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification, lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer : le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) : mord sur les deux. Co-Authored-By: Claude Opus 5.5 --- .../etm/scheduler/socketscheduler.cpp | 22 ++++++-- energyplugin/etm/scheduler/socketscheduler.h | 5 ++ .../auto/common/etm-powersync-fakeoptimizer.h | 11 ++++ tests/auto/refoptimizer/testrefoptimizer.cpp | 9 ++-- .../socketscheduler/testsocketscheduler.cpp | 53 +++++++++++++++++++ 5 files changed, 92 insertions(+), 8 deletions(-) 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);