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 <noreply@anthropic.com>
353 lines
17 KiB
C++
353 lines
17 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QByteArray>
|
|
#include <QElapsedTimer>
|
|
#include <QObject>
|
|
#include <QPointer>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QVariantMap>
|
|
|
|
class QIODevice;
|
|
class QJsonObject;
|
|
class QTimer;
|
|
|
|
/*!
|
|
* \brief Adresse d'un optimiseur : \c unix:///chemin ou \c tcp://hôte:port (OPTIMIZER_PROTOCOL §2).
|
|
*
|
|
* \invariant Une adresse non reconnue est \c Invalid, jamais « à peu près » interprétée : un
|
|
* client construit dessus ne se connecte pas.
|
|
*/
|
|
struct OptimizerEndpoint
|
|
{
|
|
/*! \brief Famille de transport. */
|
|
enum Kind { Invalid, Unix, Tcp };
|
|
|
|
Kind kind = Invalid; //!< Famille ; \c Invalid si l'adresse n'a pas été reconnue.
|
|
QString path; //!< Chemin du socket local (\c Unix seulement).
|
|
QString host; //!< Hôte (\c Tcp seulement), sans crochets pour une IPv6.
|
|
quint16 port = 0; //!< Port (\c Tcp seulement), 1-65535.
|
|
|
|
/*!
|
|
* \brief Lit une adresse d'optimiseur.
|
|
* \param text \c unix:///run/powersync/optimizer.sock ou \c tcp://192.168.1.10:7400
|
|
* (IPv6 entre crochets : \c tcp://[fd00::1]:7400).
|
|
* \return L'adresse, ou \c kind == Invalid : schéma inconnu, chemin non absolu, hôte vide,
|
|
* port absent, nul ou hors bornes, ou tout reste après le port.
|
|
*/
|
|
static OptimizerEndpoint parse(const QString &text);
|
|
|
|
/*! \brief \return Vrai si l'adresse est utilisable. */
|
|
bool isValid() const { return kind != Invalid; }
|
|
|
|
/*! \brief \return La forme textuelle canonique, pour le journal (aucun secret n'y figure). */
|
|
QString toString() const;
|
|
};
|
|
|
|
/*!
|
|
* \brief Source de temps et réveil du client, injectable (\c tools/check-simulation-clock.py).
|
|
*
|
|
* Le client ne lit JAMAIS l'horloge murale : ses trois délais (handshake 5 s, \c GetPlan 10 s,
|
|
* backoff 1 → 60 s) sont des durées, mesurées sur cette horloge MONOTONE. Il n'arme qu'un réveil
|
|
* à la fois — l'échéance la plus proche — et réévalue toutes ses échéances quand il sonne.
|
|
*
|
|
* L'implémentation par défaut lit un \c QElapsedTimer et arme un \c QTimer à coup unique. Une
|
|
* suite de test la dérive pour avancer le temps à la main : aucun délai ne s'écoule alors tant
|
|
* que le test ne l'a pas décidé.
|
|
*
|
|
* \invariant \c nowMs() ne décroît jamais.
|
|
* \invariant Après \c armWakeUp(d), \c wakeUp() est émis au plus une fois, au plus tôt \c d ms
|
|
* plus tard ; un nouvel \c armWakeUp() ou \c disarmWakeUp() remplace le réveil en attente.
|
|
*/
|
|
class OptimizerClientClock : public QObject
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*! \brief Horloge réelle (monotone). \param parent Propriétaire Qt. */
|
|
explicit OptimizerClientClock(QObject *parent = nullptr);
|
|
|
|
/*! \brief \return Millisecondes monotones depuis une origine arbitraire. */
|
|
virtual qint64 nowMs() const;
|
|
|
|
/*! \brief Arme le réveil unique. \param delayMs Délai, en ms (0 = au prochain tour de boucle). */
|
|
virtual void armWakeUp(qint64 delayMs);
|
|
|
|
/*! \brief Désarme le réveil en attente, s'il y en a un. */
|
|
virtual void disarmWakeUp();
|
|
|
|
signals:
|
|
/*! \brief Le délai armé est écoulé. */
|
|
void wakeUp();
|
|
|
|
private:
|
|
QElapsedTimer m_elapsed;
|
|
QTimer *m_timer = nullptr;
|
|
};
|
|
|
|
/*!
|
|
* \brief Transport client vers un optimiseur externe (OPTIMIZER_PROTOCOL §2-§4) — brief 4 T2.
|
|
*
|
|
* Un socket \c unix:// (\c QLocalSocket) ou \c tcp:// (\c QTcpSocket), JSON-RPC 2.0, un message
|
|
* par ligne. Le client se connecte, fait le handshake §3, puis porte des \c GetPlan à la demande.
|
|
* Il ne sait rien du contenu d'un plan : il rend le \c result tel quel, la validation est à
|
|
* l'appelant (T3).
|
|
*
|
|
* **Machine d'états** :
|
|
* \code
|
|
* Stopped ──start()──► Connecting ──connecté──► Handshaking ──réponse valide──► Ready
|
|
* │ │ │
|
|
* └──── échec, 5 s ──────────┴── refus, majeure, 5 s ───┤ fermeture
|
|
* ▼ ▼
|
|
* Waiting (backoff 1, 2, 4 … 60 s) ──échéance──► Connecting
|
|
* \endcode
|
|
* Les 5 s du handshake courent depuis le DÉBUT de la tentative : une connexion TCP qui ne
|
|
* s'établit jamais (hôte filtré) est bornée par le même délai. Le backoff double à chaque échec
|
|
* et revient à 1 s après un handshake réussi.
|
|
*
|
|
* **Une requête en vol au plus.** \c requestPlan() pendant qu'une requête attend la remplace :
|
|
* l'ancienne n'est plus attendue, sa réponse sera ignorée à son arrivée (comptée dans
|
|
* \c staleResponses()), et elle ne produit ni \c planReceived() ni \c planFailed(). Les
|
|
* identifiants croissent sur toute la vie du client, à travers les reconnexions.
|
|
*
|
|
* **Erreurs de ligne** — ligne non JSON, message sans identifiant, identifiant jamais émis, ligne
|
|
* de plus de \c kMaxLineBytes : comptées (\c protocolErrors()), annoncées par
|
|
* \c protocolError(), et la connexion est CONSERVÉE. La requête en vol continue d'attendre.
|
|
*
|
|
* **Le token** est relu dans \c tokenPath à CHAQUE tentative de connexion : le remplacer prend
|
|
* effet à la suivante, sans redémarrage. Il ne figure dans aucune ligne de journal, ni en clair
|
|
* ni par sa longueur ; le message de handshake qui le porte n'est jamais journalisé.
|
|
* **Un optimiseur qui le recopie** (\c reason, \c planId, \c name…) ne le fait pas sortir : chaque
|
|
* message reçu est masqué dès son analyse (\c OptimizerSecret::masquerJson(), avec le token
|
|
* envoyé sur CETTE connexion), avant toute journalisation et avant tout signal — journal, traces
|
|
* et charges utiles de l'app ne voient que \c "[token masqué]". Le fait est dit une fois par
|
|
* client, jamais la valeur.
|
|
*
|
|
* \invariant Aucune méthode n'attend : toute E/S est asynchrone. \c requestPlan() rend la main
|
|
* immédiatement ; la réponse arrive par signal, au plus tôt au tour de boucle suivant.
|
|
* \invariant Le client n'écoute aucun port (invariant 10) : il se connecte, c'est tout.
|
|
* \invariant Sans \c start(), le client n'ouvre rien, n'arme rien et ne journalise rien.
|
|
*/
|
|
class OptimizerClient : public QObject
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*! \brief État de la connexion — voir la machine d'états de la classe. */
|
|
enum State {
|
|
Stopped, //!< Pas démarré, ou arrêté par \c stop().
|
|
Waiting, //!< Déconnecté, prochaine tentative au backoff.
|
|
Connecting, //!< Connexion du socket en cours.
|
|
Handshaking, //!< Connecté, \c Handshake envoyé, réponse attendue.
|
|
Ready //!< Handshake accepté : \c requestPlan() est servi.
|
|
};
|
|
Q_ENUM(State)
|
|
|
|
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
|
|
static constexpr const char *kProtocolVersion = "1.2";
|
|
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
|
|
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 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). */
|
|
static constexpr const char *kTokenFileName = "energy-optimizer.token";
|
|
|
|
/*!
|
|
* \brief Longueur minimale d'un token non vide, en caractères.
|
|
*
|
|
* Le masquage à la réception (\c OptimizerSecret) remplace TOUTE occurrence du token dans ce
|
|
* que l'optimiseur renvoie : un token court, présent par hasard dans un identifiant de charge
|
|
* ou un code, corromprait le plan reçu (\c unknown-load → repli). 16 caractères rendent la
|
|
* rencontre fortuite négligeable.
|
|
*/
|
|
static constexpr int kMinTokenChars = 16;
|
|
|
|
/*!
|
|
* \brief Les codes d'erreur que le client peut retenir (\c lastErrorCode()) ou émettre
|
|
* (\c planFailed(), \c protocolError()).
|
|
* \return Connexion, handshake, ligne écartée, requête. La suite \c etm-powersync-protocolschema
|
|
* confronte la liste au code source du client.
|
|
*/
|
|
static QStringList knownErrorCodes();
|
|
|
|
/*! \brief Longueur d'un token, comparée à \c kMinTokenChars.
|
|
* \param token Le token, en UTF-8. \return Son nombre de caractères (points de code). */
|
|
static int tokenLength(const QByteArray &token);
|
|
|
|
/*!
|
|
* \brief Construit un client ARRÊTÉ : rien n'est ouvert avant \c start().
|
|
* \param endpoint Adresse de l'optimiseur ; invalide → \c start() refuse.
|
|
* \param tokenPath Fichier du token (\c defaultTokenPath() en production) ; vide = jamais de
|
|
* token. Un fichier absent vaut « pas de token configuré ».
|
|
* \param engineVersion Version du moteur, annoncée dans \c params.engine.
|
|
* \param clock Horloge injectable ; \c nullptr = horloge réelle, possédée par le client. Une
|
|
* horloge fournie n'est pas possédée et doit survivre au client.
|
|
* \param parent Propriétaire Qt.
|
|
*/
|
|
OptimizerClient(const OptimizerEndpoint &endpoint, const QString &tokenPath,
|
|
const QString &engineVersion, OptimizerClientClock *clock = nullptr,
|
|
QObject *parent = nullptr);
|
|
~OptimizerClient() override;
|
|
|
|
/*! \brief \return \c NymeaSettings::settingsPath() + \c "/energy-optimizer.token". */
|
|
static QString defaultTokenPath();
|
|
|
|
/*!
|
|
* \brief Pose le token d'optimiseur : écriture atomique (\c QSaveFile), droits \c 0600.
|
|
*
|
|
* Les droits sont posés sur le fichier temporaire AVANT d'y écrire le secret : il n'existe
|
|
* aucun instant où le token est sur disque avec les droits de l'umask. Un fichier préexistant
|
|
* aux droits plus larges est ainsi resserré à 0600.
|
|
*
|
|
* \param path Fichier cible (\c defaultTokenPath() en production). Jamais \c energy.conf.
|
|
* \param token Secret ; vide = retrait du fichier (plus de token configuré). Refusé s'il
|
|
* compte moins de \c kMinTokenChars caractères, plus de 4 096 octets, un blanc ou un
|
|
* caractère de contrôle — et rien n'est alors modifié.
|
|
* \param[out] error Motif d'échec, en français, sans le secret.
|
|
* \return Vrai si le fichier est posé (ou retiré) avec succès.
|
|
* \note Pas de RPC dans ce lot : c'est la fonction qu'elle appellera.
|
|
*/
|
|
static bool writeToken(const QString &path, const QByteArray &token, QString *error = nullptr);
|
|
|
|
/*! \brief Démarre : première tentative immédiate. Sans effet si déjà démarré. */
|
|
void start();
|
|
|
|
/*! \brief Ferme la connexion et désarme tout. La requête en vol est abandonnée sans signal. */
|
|
void stop();
|
|
|
|
/*! \brief \return L'état courant. */
|
|
State state() const { return m_state; }
|
|
|
|
/*!
|
|
* \brief Envoie un \c GetPlan. Remplace la requête en vol s'il y en a une.
|
|
* \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);
|
|
|
|
/*! \brief \return Identifiant de la requête attendue, 0 si aucune. */
|
|
quint64 pendingRequestId() const { return m_pendingId; }
|
|
|
|
/*! \brief \return \c result.capabilities du dernier handshake accepté (vide si absent). */
|
|
QVariantMap capabilities() const { return m_capabilities; }
|
|
|
|
/*! \brief \return Vrai si le dernier handshake accepté portait \c capabilities (§3 : seul
|
|
* ce cas restreint les \c kind acceptés). */
|
|
bool hasCapabilities() const { return m_hasCapabilities; }
|
|
|
|
/*! \brief \return \c result.name du dernier handshake accepté. */
|
|
QString serverName() const { return m_serverName; }
|
|
|
|
/*! \brief \return \c result.protocolVersion du dernier handshake accepté. */
|
|
QString serverProtocolVersion() const { return m_serverProtocolVersion; }
|
|
|
|
/*! \brief \return \c result.planning du dernier handshake accepté. */
|
|
bool serverPlanning() const { return m_serverPlanning; }
|
|
|
|
/*! \brief \return Erreurs de ligne comptées depuis la construction (connexion conservée). */
|
|
int protocolErrors() const { return m_protocolErrors; }
|
|
|
|
/*! \brief \return Réponses reçues pour une requête remplacée ou expirée, ignorées. */
|
|
int staleResponses() const { return m_staleResponses; }
|
|
|
|
/*! \brief \return Code de la dernière erreur (transport, handshake, ligne ou requête). */
|
|
QString lastErrorCode() const { return m_lastErrorCode; }
|
|
|
|
/*! \brief \return Délai qui sera appliqué au prochain échec de connexion, en ms. */
|
|
qint64 nextBackoffMs() const { return m_backoffMs; }
|
|
|
|
signals:
|
|
/*! \brief L'état a changé. \param state Le nouvel état. */
|
|
void stateChanged(OptimizerClient::State state);
|
|
|
|
/*! \brief Handshake accepté : \c capabilities() & co. sont à jour. */
|
|
void ready();
|
|
|
|
/*! \brief Réponse à la requête attendue. \param id Son identifiant. \param result Le
|
|
* \c result JSON-RPC, non validé. */
|
|
void planReceived(quint64 id, const QVariantMap &result);
|
|
|
|
/*!
|
|
* \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 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);
|
|
|
|
/*! \brief Une ligne reçue a été écartée, la connexion est conservée.
|
|
* \param code \c line-too-long, \c invalid-json, \c missing-id, \c unknown-id. */
|
|
void protocolError(const QString &code);
|
|
|
|
/*! \brief Notification JSON-RPC (\c method sans \c id), ex. \c UpdateLearned (§8). Rien
|
|
* n'en est fait ici. \param method Son nom. \param params Ses paramètres. */
|
|
void notificationReceived(const QString &method, const QVariantMap ¶ms);
|
|
|
|
private:
|
|
void setState(State state);
|
|
void connectNow();
|
|
void onConnected();
|
|
void onSocketLost();
|
|
void onReadyRead();
|
|
void onWakeUp();
|
|
void handleLine(const QByteArray &line);
|
|
void handleHandshakeReply(const QJsonObject &message);
|
|
void handlePlanReply(quint64 id, const QJsonObject &message);
|
|
void failConnection(const QString &code, const QString &detail);
|
|
void dropSocket();
|
|
void scheduleReconnect();
|
|
void rearm();
|
|
void countProtocolError(const QString &code, const QString &detail);
|
|
static QByteArray serialize(const QVariantMap &message); // une ligne, sans son '\n'
|
|
bool send(const QByteArray &line);
|
|
QByteArray readToken();
|
|
|
|
OptimizerEndpoint m_endpoint;
|
|
QString m_tokenPath;
|
|
QString m_engineVersion;
|
|
OptimizerClientClock *m_clock = nullptr;
|
|
|
|
State m_state = Stopped;
|
|
QPointer<QIODevice> m_socket;
|
|
QByteArray m_buffer;
|
|
bool m_skippingLine = false; // reste d'une ligne trop longue à jeter jusqu'au '\n'
|
|
|
|
quint64 m_lastId = 0; // dernier identifiant émis
|
|
quint64 m_handshakeId = 0;
|
|
quint64 m_pendingId = 0;
|
|
bool m_tokenSent = false; // le handshake en cours porte un token (pour le diagnostic)
|
|
QString m_tokenConnexion; // le token envoyé sur CETTE connexion : ce qu'un écho recopierait
|
|
QByteArray m_tokenTentative; // relu au début de la tentative, envoyé à son handshake
|
|
bool m_echoTokenSignale = false;
|
|
|
|
qint64 m_handshakeDeadline = -1; // -1 = pas d'échéance
|
|
qint64 m_requestDeadline = -1;
|
|
qint64 m_reconnectAt = -1;
|
|
qint64 m_backoffMs = kBackoffInitialMs;
|
|
|
|
QVariantMap m_capabilities;
|
|
bool m_hasCapabilities = false;
|
|
QString m_serverName;
|
|
QString m_serverProtocolVersion;
|
|
bool m_serverPlanning = false;
|
|
|
|
int m_protocolErrors = 0;
|
|
int m_staleResponses = 0;
|
|
QString m_lastErrorCode;
|
|
QString m_failureStreakCode; // échec répété : annoncé une fois, puis compté
|
|
int m_failureStreak = 0;
|
|
QStringList m_errorsLoggedThisConnection;
|
|
};
|