Fusion fix/q7-request-too-long : le moteur n'émet jamais de ligne de plus de 1 Mio, libellé de charge borné à 256 caractères

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
Patrick Schurig 2026-10-22 19:30:58 +02:00
commit 052d728137
10 changed files with 343 additions and 42 deletions

View File

@ -45,16 +45,16 @@ Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protoco
### 1.2 (2026-10) ### 1.2 (2026-10)
Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par 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, 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 lots, propriétaire de `/run/powersync`. **Côté moteur, la version annoncée change, et il borne
n'émet aucun message nouveau et juge les réponses exactement comme en 1.1. 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 **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 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 — 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, contexte invalide, `GetPlan` avant handshake, autre majeure, lot, ligne de plus de 1 Mio (le
la ligne de plus de 1 Mio (§2, écart connu). Un optimiseur 1.1 qui y répond autrement continue de moteur ne l'émet pas, §2). Un optimiseur 1.1 qui y répond autrement continue de fonctionner avec
fonctionner avec ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de ce moteur ; il n'est pas conforme 1.2 sur ces points, et s'aligne avant de s'annoncer 1.2.
s'annoncer 1.2.
- §7 **Contexte `GetPlan` invalide** : l'optimiseur répond une **erreur `-32602`**, pas une - §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 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, - §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**. 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 → - §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 **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 - §2 **`/run/powersync` appartient au paquet de l'optimiseur** ; le moteur ne le crée ni ne le
suppose. 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 `-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 (`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). 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**. **Le moteur n'émet jamais de ligne de plus de 1 Mio** : une requête trop longue n'est pas
> Rien ne borne la taille d'un contexte `GetPlan` (un `label` de charge très long suffit) ; une envoyée et compte comme un échec (`request-too-long`, règle des échecs consécutifs du §7),
> telle requête part sur une seule ligne de plus de 1 Mio, un optimiseur conforme la refuse, et connexion conservée. Éprouvé par `testARequestOverTheLineLimitIsNeverSent` (suite
> la requête finit en délai dépassé. Éprouvé par `testAContextOverTheLineLimitIsStillSent` `optimizerclient`) et `testARequestTooLongCountsAsAFailure` (suite `socketscheduler`).
> (suite `optimizerclient`, échec attendu tant que l'écart vit).
- Le contrat est transport-agnostique : mêmes messages sur unix et tcp. - Le contrat est transport-agnostique : mêmes messages sur unix et tcp.
- Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion : - Reconnexion : backoff exponentiel (1 s → 60 s max). Pendant la déconnexion :
stratégie de repli active, `optimizerAlive = false`. 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. | | `-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. | | `-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 : Ce que fait le moteur d'une réponse error :

View File

@ -337,6 +337,7 @@
"rpc-error", "rpc-error",
"invalid-response", "invalid-response",
"disconnected", "disconnected",
"request-too-long",
"malformed-plan", "malformed-plan",
"bad-time", "bad-time",
"slots-unordered", "slots-unordered",
@ -1821,7 +1822,7 @@
"server": "optimizer" "server": "optimizer"
}, },
"maxLineBytes": 1048576, "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.", "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, "handshakeTimeoutS": 5,
"requestTimeoutS": 10, "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." "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": { "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": [ "items": [
{ {
"code": -32700, "code": -32700,

View File

@ -89,6 +89,15 @@ bool LoadConfigStore::load()
<< "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à" << "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à"
<< "des identifiants, donc à personne. À réparer par un SetLoadConfig."; << "des identifiants, donc à personne. À réparer par un SetLoadConfig.";
} }
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
// client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet.
{
QString pourquoi;
if (!validateLabels(loaded, &pourquoi))
qCWarning(dcNymeaEnergy()).noquote()
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
<< "— chargé tel quel ; toute écriture sera refusée tant qu'il n'est pas raccourci.";
}
m_configs = loaded; m_configs = loaded;
return true; return true;
@ -196,8 +205,27 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
return true; return true;
} }
bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error)
{
for (const LoadConfig &c : configs) {
const qsizetype caracteres = c.label().toUcs4().size();
if (caracteres > MaxLabelChars) {
if (error)
*error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus")
.arg(c.id().left(64)).arg(caracteres).arg(MaxLabelChars);
return false;
}
}
return true;
}
bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
{ {
// Les libellés D'ABORD : les motifs de refus suivants citent le libellé, et un libellé
// démesuré ne doit entrer ni dans la configuration ni dans le journal (Q7).
if (!validateLabels(configs, error))
return false;
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
for (const LoadConfig &c : configs) { for (const LoadConfig &c : configs) {
QString why; QString why;

View File

@ -86,6 +86,27 @@ public:
*/ */
static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr); static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr);
//! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode).
static constexpr int MaxLabelChars = 256;
/*!
* \brief Borne la longueur des libellés, à l'ÉCRITURE seulement (protocole optimiseur 1.2, Q7).
*
* Le libellé part tel quel dans chaque \c GetPlan (§5.3). Sans borne, une saisie démesurée
* ferait dépasser la limite de 1 Mio par ligne du protocole, et l'optimiseur ne recevrait plus
* rien. Le client d'optimiseur refuse d'émettre une telle ligne (\c request-too-long) : cette
* borne-ci arrête la saisie avant qu'elle n'y arrive.
*
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme
* la charge par son identifiant et donne la longueur, jamais le libellé.
* \return Faux si un libellé compte plus de \c MaxLabelChars caractères.
*
* \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais
* un écart au chargement. \c load() ne l'appelle pas — un libellé plus long déjà persisté est
* **chargé tel quel, signalé, jamais tronqué** ; la première écriture devra le raccourcir.
*/
static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr);
/*! /*!
* \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. * \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c.
* *

View File

@ -209,7 +209,7 @@ QStringList OptimizerClient::knownErrorCodes()
QStringLiteral("major-mismatch"), QStringLiteral("line-too-long"), QStringLiteral("major-mismatch"), QStringLiteral("line-too-long"),
QStringLiteral("invalid-json"), QStringLiteral("missing-id"), QStringLiteral("unknown-id"), QStringLiteral("invalid-json"), QStringLiteral("missing-id"), QStringLiteral("unknown-id"),
QStringLiteral("timeout"), QStringLiteral("rpc-error"), QStringLiteral("invalid-response"), QStringLiteral("timeout"), QStringLiteral("rpc-error"), QStringLiteral("invalid-response"),
QStringLiteral("disconnected")}; QStringLiteral("disconnected"), QStringLiteral("request-too-long")};
} }
int OptimizerClient::tokenLength(const QByteArray &token) int OptimizerClient::tokenLength(const QByteArray &token)
@ -254,13 +254,45 @@ quint64 OptimizerClient::requestPlan(const QVariantMap &params)
const quint64 id = ++m_lastId; const quint64 id = ++m_lastId;
m_pendingId = id; m_pendingId = id;
m_requestDeadline = m_clock->nowMs() + kRequestTimeoutMs; const QByteArray line = serialize(QVariantMap{
send(QVariantMap{
{QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, {QStringLiteral("jsonrpc"), QStringLiteral("2.0")},
{QStringLiteral("id"), id}, {QStringLiteral("id"), id},
{QStringLiteral("method"), QStringLiteral("GetPlan")}, {QStringLiteral("method"), QStringLiteral("GetPlan")},
{QStringLiteral("params"), params}, {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 // 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 // « disconnected »), et à défaut les 10 s bornent l'attente. Émettre planFailed() avant
// d'avoir rendu l'identifiant le rendrait inattribuable par l'appelant. // d'avoir rendu l'identifiant le rendrait inattribuable par l'appelant.
@ -343,12 +375,15 @@ void OptimizerClient::onConnected()
m_handshakeId = ++m_lastId; m_handshakeId = ++m_lastId;
// Ce message porte le secret : il n'est JAMAIS journalisé, à aucun niveau. // 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("jsonrpc"), QStringLiteral("2.0")},
{QStringLiteral("id"), m_handshakeId}, {QStringLiteral("id"), m_handshakeId},
{QStringLiteral("method"), QStringLiteral("Handshake")}, {QStringLiteral("method"), QStringLiteral("Handshake")},
{QStringLiteral("params"), params}, {QStringLiteral("params"), params},
}); }));
qCDebug(dcNymeaEnergy()) << "[Optimiseur] connecté, handshake envoyé ( id" << m_handshakeId qCDebug(dcNymeaEnergy()) << "[Optimiseur] connecté, handshake envoyé ( id" << m_handshakeId
<< ", token" << (m_tokenSent ? "configuré" : "absent") << ")"; << ", token" << (m_tokenSent ? "configuré" : "absent") << ")";
} }
@ -669,13 +704,16 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det
emit protocolError(code); 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) if (!m_socket)
return false; return false;
QByteArray line = QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); return m_socket->write(line + '\n') == line.size() + 1;
line.append('\n');
return m_socket->write(line) == line.size();
} }
QByteArray OptimizerClient::readToken() QByteArray OptimizerClient::readToken()

View File

@ -149,7 +149,7 @@ public:
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan. 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 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
static constexpr qint64 kBackoffMaxMs = 60000; //!< §2, plafond du backoff. 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; static constexpr int kMaxLineBytes = 1 << 20;
/*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */ /*! \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). * \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 * \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). * 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 &params); quint64 requestPlan(const QVariantMap &params);
@ -275,7 +281,8 @@ signals:
* \brief La requête attendue n'aura pas de réponse utilisable. * \brief La requête attendue n'aura pas de réponse utilisable.
* \param id Son identifiant. * \param id Son identifiant.
* \param code \c timeout (10 s), \c disconnected (connexion perdue), \c rpc-error (réponse * \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. * \note Une requête REMPLACÉE n'émet rien : son abandon est le fait de l'appelant.
*/ */
void planFailed(quint64 id, const QString &code); void planFailed(quint64 id, const QString &code);
@ -303,7 +310,8 @@ private:
void scheduleReconnect(); void scheduleReconnect();
void rearm(); void rearm();
void countProtocolError(const QString &code, const QString &detail); 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(); QByteArray readToken();
OptimizerEndpoint m_endpoint; OptimizerEndpoint m_endpoint;

View File

@ -471,6 +471,66 @@ void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
QCOMPARE(relu.configs().count(), 2); QCOMPARE(relu.configs().count(), 2);
} }
void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad()
{
// Protocole optimiseur 1.2, Q7 : le libellé part tel quel dans chaque GetPlan. Borné à
// l'ÉCRITURE (256 caractères), jamais au chargement — même partage que les rangs (LM-1209-a) :
// une règle resserrée après coup refuse une écriture, elle ne fait pas disparaître une charge.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-labels.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QString err;
// La borne compte des CARACTÈRES : 256 « é » (deux octets UTF-8 chacun) passent, et un
// caractère hors plan de base (deux unités UTF-16) compte pour un.
auto avecLibelle = [](const QString &label) {
QVariantMap m = borneMap(BORNE, 1);
m.insert("label", label);
return LoadConfigs(QList<LoadConfig>{LoadConfig::fromMap(m)});
};
const QString limite = QString(LoadConfigStore::MaxLabelChars - 1, QChar(0x00e9)) + QString::fromUcs4(U"\U0001F50C");
QCOMPARE(limite.toUcs4().size(), qsizetype(256));
QVERIFY(limite.size() > 256); // en unités UTF-16, il dépasserait : c'est bien le caractère qui compte
LoadConfigStore store;
QVERIFY2(store.setConfigs(avecLibelle(limite), &err), qUtf8Printable(err));
QCOMPARE(store.configs().at(0).label(), limite);
QFile f(cfgPath);
QVERIFY(f.open(QIODevice::ReadOnly));
const QByteArray persiste = f.readAll();
f.close();
// 257 : refusé, RIEN n'est modifié — ni en mémoire, ni sur disque. Le motif nomme la charge
// par son identifiant et donne la longueur, jamais le libellé.
const QString tropLong = QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('L'));
QVERIFY(!store.setConfigs(avecLibelle(tropLong), &err));
QVERIFY2(err.contains(QStringLiteral("257 caractères")) && err.contains(BORNE), qUtf8Printable(err));
QVERIFY(!err.contains(tropLong.left(32)));
QCOMPARE(store.configs().count(), 1);
QCOMPARE(store.configs().at(0).label(), limite);
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), persiste);
f.close();
// ---- Au CHARGEMENT : une configuration héritée garde son libellé, entier ----------------
QVariantMap herite = borneMap(BORNE, 1);
herite.insert("label", tropLong);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap(herite)}}}).toJson());
f.close();
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 1);
QCOMPARE(relu.configs().at(0).label(), tropLong); // ni écarté, ni tronqué
// L'aller-retour verbatim est refusé tant que le libellé n'est pas raccourci ; raccourci, il passe.
QVERIFY(!relu.setConfigs(relu.configs(), &err));
QCOMPARE(relu.configs().at(0).label(), tropLong);
LoadConfigs repare = relu.configs();
repare[0].setLabel(QStringLiteral("Borne garage"));
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
QFile::remove(cfgPath);
}
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
{ {
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est

View File

@ -75,6 +75,8 @@ private slots:
//! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un //! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un
//! client ne peut pas fabriquer un "auto" (LM-1209-c). //! client ne peut pas fabriquer un "auto" (LM-1209-c).
void testRankOriginFollowsTheRankNotTheWrite(); void testRankOriginFollowsTheRankNotTheWrite();
// Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement
void testLabelIsBoundedOnWriteNeverOnLoad();
// ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── // ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait

View File

@ -21,6 +21,7 @@
#include <QLocalSocket> #include <QLocalSocket>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QPointer> #include <QPointer>
#include <QRegularExpression>
#include <QSignalSpy> #include <QSignalSpy>
#include <QTcpServer> #include <QTcpServer>
#include <QTcpSocket> #include <QTcpSocket>
@ -148,7 +149,8 @@ private slots:
void testARefusedHandshakeIsClosedByTheEngine(); void testARefusedHandshakeIsClosedByTheEngine();
void testAReceivedBatchIsNeverUnpacked(); void testAReceivedBatchIsNeverUnpacked();
void testEachLineSentIsOneObject(); void testEachLineSentIsOneObject();
void testAContextOverTheLineLimitIsStillSent(); void testARequestOverTheLineLimitIsNeverSent();
void testARequestTooLongIsLoggedOncePerConnection();
private: private:
// Démarre le client et mène le handshake jusqu'à Ready. // 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 // Protocole 1.2 (Q7, maxLineBytes dans les deux sens), sens moteur → optimiseur. Une requête
// lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne // dont la ligne dépasserait 1 Mio n'est PAS écrite : un optimiseur conforme la jetterait
// borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig // (-32600 à id null) et la requête finirait en délai dépassé, la cause perdue. Elle échoue
// suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé. // tout de suite, sous son propre code, et la connexion reste utilisable. La limite est
// Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit. // éprouvée des deux côtés : exactement kMaxLineBytes part, un octet de plus ne part pas.
RawOptimizer raw; RawOptimizer raw;
const QString endpoint = raw.listen(m_dir.filePath("brut.sock")); const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
QVERIFY(!endpoint.isEmpty()); QVERIFY(!endpoint.isEmpty());
SimClock clock; SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock); OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
client.start(); client.start();
QVERIFY(waitFor([&] { return raw.lines().size() == 1; })); QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})"); raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; })); QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const QVariantMap context{{"loads", QVariantList{QVariantMap{ const int recuApresHandshake = raw.received.size();
{"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
QCOMPARE(client.requestPlan(context), quint64(2)); // 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<quint64>(), 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)); QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000));
const QByteArray line = raw.lines().at(1); const QByteArray ligne = raw.lines().at(1);
QVERIFY(line.contains("\"GetPlan\"")); QCOMPARE(ligne.size(), OptimizerClient::kMaxLineBytes);
QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue); QCOMPARE(QJsonDocument::fromJson(ligne).object().value("id").toInteger(), qint64(limite));
QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes, raw.send(R"({"jsonrpc":"2.0","id":3,"result":{"planId":"servi"}})");
qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size()))); QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(planSpy.at(0).at(0).value<quint64>(), 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) QTEST_GUILESS_MAIN(TestOptimizerClient)

View File

@ -125,6 +125,8 @@ private slots:
void testGetPlanPublishesThePlanInForce(); void testGetPlanPublishesThePlanInForce();
void testGetPlanShowsTheAppliedPowerUnderALock(); void testGetPlanShowsTheAppliedPowerUnderALock();
void testUpdateLearnedIsLoggedOnceAndIgnored(); 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) // Brief 4 T6 — traces de décision (annexe D)
void testTraceIsOffByDefault(); void testTraceIsOffByDefault();
void testTraceSettingByRpc(); void testTraceSettingByRpc();
@ -1563,6 +1565,54 @@ QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
return lignes; 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() void TestSocketScheduler::testTraceIsOffByDefault()
{ {
// Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal. // Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.