From cd91baf98f91b72a205a7aac51d9f3d82ddabe8f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 20:07:11 +0200 Subject: [PATCH] =?UTF-8?q?optimiseur=20:=20le=20moteur=20n'=C3=A9met=20ja?= =?UTF-8?q?mais=20de=20ligne=20de=20plus=20de=201=20Mio=20(Q7,=20protocole?= =?UTF-8?q?=201.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ferme l'écart Q7 trouvé au lot protocole 1.2. Une requête GetPlan dont la ligne dépasserait maxLineBytes n'est plus écrite sur le socket : OptimizerClient::requestPlan() sérialise d'abord, rend l'identifiant, puis émet planFailed(id, "request-too-long") au tour de boucle suivant. L'échec compte (règle des échecs consécutifs, §7), la connexion est conservée, et le journal le dit une fois par connexion, avec la taille, jamais le contenu. Avant : la ligne partait, l'optimiseur la jetait, et la requête finissait en délai dépassé sans que la cause se voie. Le handshake n'est pas contrôlé : sa ligne est bornée par construction (token de 4 096 octets au plus, 24 Kio une fois échappé). - knownErrorCodes() : + request-too-long ; LastErrorCode du contrat régénéré (--enums-from), blocs générés de la prose régénérés. - OPTIMIZER_PROTOCOL.md §0/§2 et x-etm-transport : l'écart connu devient la règle tenue. Pas de changement de version. - Tests : testARequestOverTheLineLimitIsNeverSent remplace le XFAIL (aucun octet émis, limite exacte éprouvée des deux côtés), testARequestTooLongIsLoggedOncePerConnection, testARequestTooLongCountsAsAFailure (socketscheduler). Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 26 ++-- ...-powersync-optimizer-protocol.openrpc.json | 5 +- .../etm/scheduler/optimizerclient.cpp | 56 ++++++-- energyplugin/etm/scheduler/optimizerclient.h | 14 +- .../optimizerclient/testoptimizerclient.cpp | 123 +++++++++++++++--- .../socketscheduler/testsocketscheduler.cpp | 50 +++++++ 6 files changed, 232 insertions(+), 42 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 8e7f376..ac27fdc 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -45,16 +45,16 @@ Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protoco ### 1.2 (2026-10) Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne, -lots, propriétaire de `/run/powersync`. **Côté moteur, seule la version annoncée change** : il -n'émet aucun message nouveau et juge les réponses exactement comme en 1.1. +lots, propriétaire de `/run/powersync`. **Côté moteur, la version annoncée change, et il borne +ce qu'il émet** (§2 : une requête de plus de 1 Mio n'est pas envoyée) ; il n'émet aucun message +nouveau et juge les réponses exactement comme en 1.1. **Compatibilité.** Même majeure : un moteur 1.2 accepte un optimiseur 1.0 ou 1.1, et un optimiseur 1.2 accepte un moteur 1.0 ou 1.1 (seule la majeure est comparée, §3). Les règles nouvelles portent sur ce que fait l'optimiseur dans des situations que le moteur ne produit pas — -contexte invalide, `GetPlan` avant handshake, autre majeure, lot — et sur une qu'il peut produire, -la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de -fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de -s'annoncer 1.2. +contexte invalide, `GetPlan` avant handshake, autre majeure, lot, ligne de plus de 1 Mio (le +moteur ne l'émet pas, §2). Un optimiseur 1.1 qui y répond autrement continue de fonctionner avec +ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de s'annoncer 1.2. - §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une abstention ; `error.data` liste les chemins fautifs et la nature de chaque problème, jamais les @@ -68,7 +68,8 @@ s'annoncer 1.2. - §8 **`UpdateLearned`** : sens optimiseur → moteur uniquement ; reçu par un optimiseur, notification ignorée, requête avec `id` → `-32601`. Sa **forme reste non normée en 1.2**. - §2 **`maxLineBytes` (1 Mio) dans les deux sens** ; ligne trop longue reçue par l'optimiseur → - `-32600`, connexion gardée. + `-32600`, connexion gardée. Le moteur n'en émet jamais : une requête trop longue n'est pas + envoyée et compte comme un échec (`request-too-long`). - §2 **Lots JSON-RPC interdits** dans les deux sens : un message par ligne ; un lot reçu → `-32600`. - §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le suppose. @@ -161,11 +162,10 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr `-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée (`line-too-long`), comptée, connexion conservée ; la requête en vol continue d'attendre — si sa réponse était cette ligne, elle finit en délai dépassé (10 s, échec compté, §4). - > **Écart connu du moteur (1.2)** : il n'applique pas encore la borne à ce qu'il **émet**. - > Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une - > telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et - > la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent` - > (suite `optimizerclient`, échec attendu tant que l'écart vit). + **Le moteur n'émet jamais de ligne de plus de 1 Mio** : une requête trop longue n'est pas + envoyée et compte comme un échec (`request-too-long`, règle des échecs consécutifs du §7), + connexion conservée. Éprouvé par `testARequestOverTheLineLimitIsNeverSent` (suite + `optimizerclient`) et `testARequestTooLongCountsAsAFailure` (suite `socketscheduler`). - Le contrat est transport-agnostique : mêmes messages sur unix et tcp. - Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion : stratégie de repli active, `optimizerAlive = false`. @@ -1242,7 +1242,7 @@ une abstention, à ce qu'il ne peut pas traiter. En particulier : | `-32600` | toute | ligne trop longue | conservée | Ligne de plus de maxLineBytes octets, '\n' non compté (1.2). Jetée sans être analysée ni gardée en mémoire ; réponse à id null. | | `-32601` | toute | méthode inconnue | conservée | Requête (avec id) d'une méthode que l'optimiseur ne sert pas — dont UpdateLearned, qui ne va que de l'optimiseur au moteur (1.2). Une notification (sans id) : ignorée, sans réponse. | -Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard. +Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune — pas même la ligne trop longue, qu'il n'émet jamais (§2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard. Ce que fait le moteur d'une réponse error : diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index b387e36..16bc9a5 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -337,6 +337,7 @@ "rpc-error", "invalid-response", "disconnected", + "request-too-long", "malformed-plan", "bad-time", "slots-unordered", @@ -1821,7 +1822,7 @@ "server": "optimizer" }, "maxLineBytes": 1048576, - "maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée.", + "maxLineBytesScope": "les deux sens (1.2) : octets de la ligne, '\\n' non compté. Reçue trop longue : côté optimiseur -32600 (id null), connexion conservée ; côté moteur, ligne écartée (line-too-long), connexion conservée. Émise : le moteur n'émet jamais de ligne de plus de maxLineBytes ; une requête trop longue n'est pas envoyée et compte comme un échec (request-too-long, §7), connexion conservée.", "batches": "interdits dans les deux sens (1.2) : un message JSON-RPC, un objet, par ligne. Un lot reçu : côté optimiseur -32600 (id null), aucun de ses messages traité ; côté moteur, ligne écartée (invalid-json), connexion conservée.", "handshakeTimeoutS": 5, "requestTimeoutS": 10, @@ -1837,7 +1838,7 @@ "runtimeDirectory": "/run/powersync appartient au paquet de l'OPTIMISEUR (RuntimeDirectory=powersync de son unité, groupe powersync) (1.2). Le moteur ne le crée ni ne le suppose : sans optimiseur, il n'en a pas besoin." }, "x-etm-message-errors": { - "description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune, sauf la ligne trop longue (écart connu du moteur 1.2 : il ne borne pas encore ce qu'il émet, §2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.", + "description": "Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que soit la méthode (1.2). Connexion conservée dans tous les cas. Le moteur n'émet que des objets, un par ligne, des méthodes Handshake et GetPlan : il n'en provoque aucune — pas même la ligne trop longue, qu'il n'émet jamais (§2). Reçue par le moteur, une réponse error à id null est une ligne écartée (missing-id), connexion conservée. Toute autre erreur : un code JSON-RPC 2.0 standard.", "items": [ { "code": -32700, diff --git a/energyplugin/etm/scheduler/optimizerclient.cpp b/energyplugin/etm/scheduler/optimizerclient.cpp index d337979..bd04902 100644 --- a/energyplugin/etm/scheduler/optimizerclient.cpp +++ b/energyplugin/etm/scheduler/optimizerclient.cpp @@ -209,7 +209,7 @@ QStringList OptimizerClient::knownErrorCodes() QStringLiteral("major-mismatch"), QStringLiteral("line-too-long"), QStringLiteral("invalid-json"), QStringLiteral("missing-id"), QStringLiteral("unknown-id"), QStringLiteral("timeout"), QStringLiteral("rpc-error"), QStringLiteral("invalid-response"), - QStringLiteral("disconnected")}; + QStringLiteral("disconnected"), QStringLiteral("request-too-long")}; } int OptimizerClient::tokenLength(const QByteArray &token) @@ -254,13 +254,45 @@ quint64 OptimizerClient::requestPlan(const QVariantMap ¶ms) const quint64 id = ++m_lastId; m_pendingId = id; - m_requestDeadline = m_clock->nowMs() + kRequestTimeoutMs; - send(QVariantMap{ + const QByteArray line = serialize(QVariantMap{ {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, {QStringLiteral("id"), id}, {QStringLiteral("method"), QStringLiteral("GetPlan")}, {QStringLiteral("params"), params}, }); + + // §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur + // conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause + // perdue. Rien n'est écrit ; la requête échoue tout de suite, et l'échec COMPTE (règle des + // échecs consécutifs, §7) : un contexte hors protocole est un défaut du moteur. + if (line.size() > kMaxLineBytes) { + m_requestDeadline = -1; + rearm(); + // Une ligne par connexion, avec la taille — jamais le contenu : il est volumineux, et le + // champ en cause est une saisie de l'installateur. + if (!m_errorsLoggedThisConnection.contains(QStringLiteral("request-too-long"))) { + m_errorsLoggedThisConnection.append(QStringLiteral("request-too-long")); + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[Optimiseur] GetPlan %1 NON ENVOYÉ : %2 octets, au-delà de la limite " + "de %3 octets par ligne (§2) — échec de la requête (request-too-long), " + "repli rule-based ; les suivants sur cette connexion ne sont pas " + "journalisés").arg(id).arg(line.size()).arg(kMaxLineBytes); + } + // En différé : l'appelant n'a pas encore l'identifiant, et un échec émis avant le retour + // serait inattribuable. Une requête remplacée, abandonnée par stop() ou déjà signalée par + // une perte de connexion entre-temps n'émet rien de plus. + QMetaObject::invokeMethod(this, [this, id] { + if (m_pendingId != id) + return; + m_pendingId = 0; + m_lastErrorCode = QStringLiteral("request-too-long"); + emit planFailed(id, QStringLiteral("request-too-long")); + }, Qt::QueuedConnection); + return id; + } + + m_requestDeadline = m_clock->nowMs() + kRequestTimeoutMs; + send(line); // Un échec d'écriture n'est pas traité ici : le socket signale sa perte (→ planFailed // « disconnected »), et à défaut les 10 s bornent l'attente. Émettre planFailed() avant // d'avoir rendu l'identifiant le rendrait inattribuable par l'appelant. @@ -343,12 +375,15 @@ void OptimizerClient::onConnected() m_handshakeId = ++m_lastId; // Ce message porte le secret : il n'est JAMAIS journalisé, à aucun niveau. - send(QVariantMap{ + // Pas de contrôle de maxLineBytes ici : la ligne est bornée par construction, loin sous + // 1 Mio — version fixe, version du moteur fixée à la compilation, token lu sur au plus + // kMaxTokenBytes octets (au pire ×6 une fois échappé en JSON : 24 Kio). + send(serialize(QVariantMap{ {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, {QStringLiteral("id"), m_handshakeId}, {QStringLiteral("method"), QStringLiteral("Handshake")}, {QStringLiteral("params"), params}, - }); + })); qCDebug(dcNymeaEnergy()) << "[Optimiseur] connecté, handshake envoyé ( id" << m_handshakeId << ", token" << (m_tokenSent ? "configuré" : "absent") << ")"; } @@ -669,13 +704,16 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det emit protocolError(code); } -bool OptimizerClient::send(const QVariantMap &message) +QByteArray OptimizerClient::serialize(const QVariantMap &message) +{ + return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); +} + +bool OptimizerClient::send(const QByteArray &line) { if (!m_socket) return false; - QByteArray line = QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); - line.append('\n'); - return m_socket->write(line) == line.size(); + return m_socket->write(line + '\n') == line.size() + 1; } QByteArray OptimizerClient::readToken() diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 5c1c890..7526e9c 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -149,7 +149,7 @@ public: static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan. static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion. static constexpr qint64 kBackoffMaxMs = 60000; //!< §2, plafond du backoff. - /*! \brief Plus longue ligne acceptée, octets hors \c '\\n' (1 Mio, proposition T0 ; écart E24). */ + /*! \brief Plus longue ligne acceptée OU émise, octets hors \c '\\n' (1 Mio, §2 ; écart E24). */ static constexpr int kMaxLineBytes = 1 << 20; /*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */ @@ -226,6 +226,12 @@ public: * \param params Les \c params de la requête, tels quels (\c SurplusContext sérialisé, T1). * \return L'identifiant JSON-RPC de la requête, ou 0 si le client n'est pas \c Ready (rien * n'est envoyé : l'appelant reste sur sa stratégie de repli). + * + * **Ligne trop longue** (§2, 1.2) : si la requête sérialisée dépasse \c kMaxLineBytes, RIEN + * n'est écrit sur le socket. L'identifiant est rendu quand même, et \c planFailed() suit au + * tour de boucle suivant avec le code \c request-too-long — un échec de requête comme un + * autre. La connexion est conservée. Journalisé une fois par connexion, avec la taille, + * jamais le contenu. */ quint64 requestPlan(const QVariantMap ¶ms); @@ -275,7 +281,8 @@ signals: * \brief La requête attendue n'aura pas de réponse utilisable. * \param id Son identifiant. * \param code \c timeout (10 s), \c disconnected (connexion perdue), \c rpc-error (réponse - * \c error), \c invalid-response (ni \c result objet ni \c error). + * \c error), \c invalid-response (ni \c result objet ni \c error), \c request-too-long + * (requête de plus de \c kMaxLineBytes, jamais envoyée). * \note Une requête REMPLACÉE n'émet rien : son abandon est le fait de l'appelant. */ void planFailed(quint64 id, const QString &code); @@ -303,7 +310,8 @@ private: void scheduleReconnect(); void rearm(); void countProtocolError(const QString &code, const QString &detail); - bool send(const QVariantMap &message); + static QByteArray serialize(const QVariantMap &message); // une ligne, sans son '\n' + bool send(const QByteArray &line); QByteArray readToken(); OptimizerEndpoint m_endpoint; diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index b869b6a..aa3b703 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -148,7 +149,8 @@ private slots: void testARefusedHandshakeIsClosedByTheEngine(); void testAReceivedBatchIsNeverUnpacked(); void testEachLineSentIsOneObject(); - void testAContextOverTheLineLimitIsStillSent(); + void testARequestOverTheLineLimitIsNeverSent(); + void testARequestTooLongIsLoggedOncePerConnection(); private: // Démarre le client et mène le handshake jusqu'à Ready. @@ -987,32 +989,123 @@ void TestOptimizerClient::testEachLineSentIsOneObject() } } -void TestOptimizerClient::testAContextOverTheLineLimitIsStillSent() +void TestOptimizerClient::testARequestOverTheLineLimitIsNeverSent() { - // ÉCART au protocole 1.2 (Q7, maxLineBytes dans les deux sens), constaté et NON CORRIGÉ dans le - // lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne - // borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig - // suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé. - // Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit. + // Protocole 1.2 (Q7, maxLineBytes dans les deux sens), sens moteur → optimiseur. Une requête + // dont la ligne dépasserait 1 Mio n'est PAS écrite : un optimiseur conforme la jetterait + // (-32600 à id null) et la requête finirait en délai dépassé, la cause perdue. Elle échoue + // tout de suite, sous son propre code, et la connexion reste utilisable. La limite est + // éprouvée des deux côtés : exactement kMaxLineBytes part, un octet de plus ne part pas. RawOptimizer raw; const QString endpoint = raw.listen(m_dir.filePath("brut.sock")); QVERIFY(!endpoint.isEmpty()); SimClock clock; OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock); + QSignalSpy failSpy(&client, &OptimizerClient::planFailed); + QSignalSpy planSpy(&client, &OptimizerClient::planReceived); client.start(); QVERIFY(waitFor([&] { return raw.lines().size() == 1; })); raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})"); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); - const QVariantMap context{{"loads", QVariantList{QVariantMap{ - {"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}}; - QCOMPARE(client.requestPlan(context), quint64(2)); + const int recuApresHandshake = raw.received.size(); + + // Un contexte dont la ligne GetPlan fait exactement `octets` octets, '\n' non compté. Le + // libellé est en ASCII sans caractère à échapper : un caractère, un octet. Le gabarit est + // mesuré sur la forme compacte ; une erreur de calcul ferait échouer la comparaison de taille + // sur le fil, plus bas — elle ne peut pas faire passer le test à tort. + auto contexte = [](quint64 id, int octets) { + auto ligne = [id](const QString &label) { + return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)}, {"method", "GetPlan"}, + {"params", QJsonObject{{"loads", QJsonArray{QJsonObject{ + {"id", "ecs"}, {"label", label}}}}}}}) + .toJson(QJsonDocument::Compact).size(); + }; + const int gabarit = int(ligne(QString())); + return QVariantMap{{"loads", QVariantList{QVariantMap{ + {"id", "ecs"}, {"label", QString(octets - gabarit, QLatin1Char('x'))}}}}}; + }; + + // Un octet de trop : l'identifiant est rendu, mais l'échec n'est PAS émis avant le retour — + // l'appelant ne pourrait pas l'attribuer. + const quint64 tropLong = client.requestPlan(contexte(2, OptimizerClient::kMaxLineBytes + 1)); + QCOMPARE(tropLong, quint64(2)); + QCOMPARE(failSpy.count(), 0); + QVERIFY(waitFor([&] { return failSpy.count() == 1; })); + QCOMPARE(failSpy.at(0).at(0).value(), tropLong); + QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("request-too-long")); + QCOMPARE(client.lastErrorCode(), QStringLiteral("request-too-long")); + QCOMPARE(client.pendingRequestId(), quint64(0)); + QVERIFY(!clock.armed()); // aucun délai de 10 s ne court pour une requête jamais partie + QCOMPARE(client.state(), OptimizerClient::Ready); + QCOMPARE(client.protocolErrors(), 0); // ce n'est pas une ligne REÇUE écartée + + // AUCUN octet n'est parti : le serveur n'a rien reçu de plus que le handshake. + settle(); + raw.lines(); + QCOMPARE(raw.received.size(), recuApresHandshake); + + // La connexion sert la requête suivante, exactement à la limite : elle part, entière. + const quint64 limite = client.requestPlan(contexte(3, OptimizerClient::kMaxLineBytes)); + QCOMPARE(limite, quint64(3)); QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000)); - const QByteArray line = raw.lines().at(1); - QVERIFY(line.contains("\"GetPlan\"")); - QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue); - QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes, - qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size()))); + const QByteArray ligne = raw.lines().at(1); + QCOMPARE(ligne.size(), OptimizerClient::kMaxLineBytes); + QCOMPARE(QJsonDocument::fromJson(ligne).object().value("id").toInteger(), qint64(limite)); + raw.send(R"({"jsonrpc":"2.0","id":3,"result":{"planId":"servi"}})"); + QVERIFY(waitFor([&] { return planSpy.count() == 1; })); + QCOMPARE(planSpy.at(0).at(0).value(), limite); + QCOMPARE(failSpy.count(), 1); +} + +void TestOptimizerClient::testARequestTooLongIsLoggedOncePerConnection() +{ + // Q7 : une requête trop longue se journalise UNE fois par connexion, avec sa taille et jamais + // son contenu. Une charge au libellé démesuré la reproduirait à chaque cycle : une ligne par + // minute noierait le journal. Chaque échec reste émis, donc compté par l'appelant. + FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); + QVERIFY(server.listen()); + SimClock clock; + OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); + bringUp(client, server); + QSignalSpy failSpy(&client, &OptimizerClient::planFailed); + + const QString marque = QStringLiteral("contenu-jamais-journalise-"); + const QVariantMap contexte{{"loads", QVariantList{QVariantMap{ + {"id", "ecs"}, {"label", marque + QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}}; + const QString ligneJournal = QStringLiteral("NON ENVOYÉ"); + + for (int i = 1; i <= 3; ++i) { + client.requestPlan(contexte); + QVERIFY(waitFor([&] { return failSpy.count() == i; })); + } + QCOMPARE(countLog(ligneJournal), 1); + QCOMPARE(countLog(QStringLiteral("request-too-long")), 1); // aucune ligne de rappel non plus + QCOMPARE(countLog(marque), 0); + // La taille y est, et c'est celle de la requête, pas la limite. + const QRegularExpression taille(QStringLiteral("NON ENVOYÉ : (\\d+) octets")); + QRegularExpressionMatch m; + for (const QString &l : std::as_const(s_log)) + if ((m = taille.match(l)).hasMatch()) + break; + QVERIFY(m.hasMatch()); + QVERIFY(m.captured(1).toInt() > OptimizerClient::kMaxLineBytes + marque.size()); + + // Nouvelle connexion : la première se redit. + server.abortClient(); + QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; })); + clock.advance(client.nextBackoffMs()); + QJsonObject hs; + QVERIFY(server.waitMessage(&hs)); + server.replyResult(hs.value("id").toInteger(), handshakeResult()); + QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); + client.requestPlan(contexte); + client.requestPlan(contexte); // remplace la précédente avant son échec différé + QVERIFY(waitFor([&] { return failSpy.count() == 4; })); + settle(); + QCOMPARE(failSpy.count(), 4); // la requête remplacée n'émet rien (contrat de requestPlan) + QCOMPARE(countLog(ligneJournal), 2); + QCOMPARE(countLog(marque), 0); } QTEST_GUILESS_MAIN(TestOptimizerClient) diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index e44bb24..3a84fe3 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -125,6 +125,8 @@ private slots: void testGetPlanPublishesThePlanInForce(); void testGetPlanShowsTheAppliedPowerUnderALock(); void testUpdateLearnedIsLoggedOnceAndIgnored(); + // Protocole 1.2, Q7 — une requête trop longue n'est pas envoyée, et l'échec compte + void testARequestTooLongCountsAsAFailure(); // Brief 4 T6 — traces de décision (annexe D) void testTraceIsOffByDefault(); void testTraceSettingByRpc(); @@ -1563,6 +1565,54 @@ QList TestSocketScheduler::traces(const QString &dossier, int n) return lignes; } +void TestSocketScheduler::testARequestTooLongCountsAsAFailure() +{ + // Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un + // ÉCHEC de la requête, compté par la règle des deux échecs consécutifs (§7) — même logique + // qu'une erreur renvoyée par l'optimiseur (Q2) : un contexte hors protocole est un défaut du + // moteur, le taire serait pire. Banc autonome : le vrai SocketScheduler et le vrai client sur + // un faux optimiseur, sans arbitre ; le contexte est fabriqué, c'est son libellé qui déborde. + struct Ombre : IScheduler { + Plan getPlan(const SurplusContext &) override { return Plan(); } + } ombre; + QTemporaryDir dir; + QVERIFY(dir.isValid()); + FakeOptimizer serveur(FakeOptimizer::Unix, dir.path()); + QVERIFY(serveur.listen()); + QStringList methodes; + serveur.responder = [&](const QJsonObject &m) { + methodes << m.value("method").toString(); + if (m.value("method").toString() == QLatin1String("Handshake")) + serveur.replyResult(m.value("id").toInteger(), handshakeResult()); + return true; + }; + auto *client = new OptimizerClient(OptimizerEndpoint::parse(serveur.endpoint()), QString(), "9.9"); + SocketScheduler s(&ombre, client, nullptr); + QVERIFY(waitFor([&] { return client->state() == OptimizerClient::Ready; })); + QSignalSpy echecs(&s, &SocketScheduler::echecRequete); + + SurplusContext ctx; + ctx.timestamp = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + LoadContext charge; + charge.id = QStringLiteral("ecs"); + charge.adapter = QStringLiteral("relay-router"); + charge.label = QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x')); + ctx.loads << charge; + + s.getPlan(ctx); + QVERIFY(waitFor([&] { return echecs.count() == 1; })); + QCOMPARE(echecs.at(0).at(0).toString(), QStringLiteral("request-too-long")); + QCOMPARE(s.vivacite().echecsConsecutifs(), 1); + s.getPlan(ctx); + QVERIFY(waitFor([&] { return echecs.count() == 2; })); + QCOMPARE(s.vivacite().echecsConsecutifs(), 2); + QVERIFY(!s.vivacite().vivant()); + QCOMPARE(s.vivacite().dernierCodeErreur(), QStringLiteral("request-too-long")); + QCOMPARE(client->state(), OptimizerClient::Ready); + settle(); + QCOMPARE(methodes, QStringList{QStringLiteral("Handshake")}); // aucun GetPlan n'est parti +} + void TestSocketScheduler::testTraceIsOffByDefault() { // Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.