From e56485d014fd640da072ced1e5a0375ead05121b Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:00:57 +0200 Subject: [PATCH 1/5] =?UTF-8?q?outil(protocole)=20:=20bloc=20g=C3=A9n?= =?UTF-8?q?=C3=A9r=C3=A9=20=C2=AB=20errors=20=C2=BB,=20les=20erreurs=20que?= =?UTF-8?q?=20renvoie=20l'optimiseur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit etm-powersync-gen-protocol-doc sait rendre un bloc `errors` : une table des methods[].errors puis de x-etm-message-errors (code, méthode, message, connexion après l'erreur, quand), suivie de ce que le moteur fait d'une réponse error (x-etm-errors-engine). Aucune prose n'utilise encore le bloc : --check reste vert sur l'état commité. Co-Authored-By: Claude Opus 5.5 --- tools/etm-powersync-gen-protocol-doc | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tools/etm-powersync-gen-protocol-doc b/tools/etm-powersync-gen-protocol-doc index 59e72c3..2ed8603 100755 --- a/tools/etm-powersync-gen-protocol-doc +++ b/tools/etm-powersync-gen-protocol-doc @@ -17,7 +17,9 @@ est écrit à la main. `` : - `table:.params` — les params d'une méthode ; - `table:.result` — son résultat ; - `table:` — un schéma de `components.schemas` (un `oneOf` donne une table par branche) ; -- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres. +- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres ; +- `errors` — les erreurs que renvoie l'optimiseur : `methods[].errors`, puis `x-etm-message-errors` + (protocole 1.2), et ce que le moteur fait d'une réponse error (`x-etm-errors-engine`). Une table déroule les objets EN LIGNE (`declared.minPowerW`) ; un `$ref` renvoie à la table de son composant. Un `` inconnu, une balise sans fin, un bloc imbriqué : erreur (code 2). @@ -222,7 +224,28 @@ class Rendu: out.append(f'| `{code}` | {", ".join(f"`{k}`" for k in cles.get(code, [])) or "—"} |') return out + def erreurs(self): + messages = self.doc.get('x-etm-message-errors', {}) + out = ['| Code | Méthode | `message` | Connexion après l\'erreur | Quand |', '|---|---|---|---|---|'] + moteur = [] + for m in self.doc.get('methods', []): + for e in m.get('errors', []): + out.append(f'| `{e["code"]}` | {m["name"]} | {self.sens({"description": e.get("message", "")})} | ' + f'{e.get("x-etm-connection", "—")} | {self.sens({"description": e.get("data", "")})} |') + if m.get('x-etm-errors-engine'): + moteur.append(f'- **{m["name"]}** : {self.sens({"description": m["x-etm-errors-engine"]})}') + for e in messages.get('items', []): + out.append(f'| `{e["code"]}` | toute | {self.sens({"description": e.get("message", "")})} | ' + f'{e.get("x-etm-connection", "—")} | {self.sens({"description": e.get("data", "")})} |') + if messages.get('description'): + out += ['', self.sens(messages)] + if moteur: + out += ['', 'Ce que fait le moteur d\'une réponse error :', ''] + moteur + return out + def bloc(self, quoi): + if quoi == 'errors': + return self.erreurs() if quoi == 'rules': return self.regles() if quoi == 'reason-codes': From 952f64d24b210d82e9eaa27d2922b1bc37ff87dd Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:16 +0200 Subject: [PATCH 2/5] =?UTF-8?q?test(optimizerclient)=20:=20le=20moteur=20f?= =?UTF-8?q?ace=20aux=20r=C3=A8gles=201.2=20=E2=80=94=20fermeture,=20lots,?= =?UTF-8?q?=20longueur=20de=20ligne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preuves, côté moteur, des points Q4, Q7 et Q8 du protocole 1.2. Aucun code du moteur n'est modifié. - testARefusedHandshakeIsClosedByTheEngine (Q4) : -32001 ou -32602 au handshake, faux optimiseur qui garde son socket ouvert → c'est le moteur qui ferme (handshake-refused), nouvelle tentative au backoff seulement. - testAReceivedBatchIsNeverUnpacked (Q8, reçu) : un lot portant la réponse attendue est une ligne écartée (invalid-json), connexion conservée, réponse non lue ; la requête finit en délai dépassé. - testEachLineSentIsOneObject (Q8, émis) : lu sur les octets, chaque ligne émise est UN objet JSON, une requête remplacée donne une ligne de plus. - testAContextOverTheLineLimitIsStillSent (Q7, émis) : ÉCART constaté, non corrigé dans ce lot — requestPlan() ne borne pas ce qu'il émet ; un GetPlan de plus de 1 Mio part tel quel. QEXPECT_FAIL : le test passera en XPASS le jour où le moteur bornera l'émission. Q7 en réception était déjà éprouvé par testLineLengthLimit. Co-Authored-By: Claude Opus 5.5 --- .../optimizerclient/testoptimizerclient.cpp | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index 82ed520..8d7fd80 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -52,6 +52,37 @@ int countLog(const QString &needle) return n; } +/*! Optimiseur BRUT : ce que le client écrit, octet par octet, sans le découpage du faux + * optimiseur (qui ne rend que des objets, et ne verrait donc pas un lot). */ +struct RawOptimizer +{ + QLocalServer server; + QLocalSocket *socket = nullptr; + QByteArray received; + + QString listen(const QString &path) + { + QLocalServer::removeServer(path); + return server.listen(path) ? QStringLiteral("unix://") + path : QString(); + } + //! Lignes complètes reçues jusqu'ici, dans l'ordre (sans leur '\n'). + QList lines() + { + if (!socket && server.hasPendingConnections()) + socket = server.nextPendingConnection(); + if (socket) + received += socket->readAll(); + QList l = received.split('\n'); + l.removeLast(); // ce qui suit le dernier '\n' : vide, ou une ligne pas encore finie + return l; + } + void send(const QByteArray &line) + { + socket->write(line + '\n'); + socket->flush(); + } +}; + } // namespace /*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans @@ -112,6 +143,11 @@ private slots: void testWriteToken(); void testTokenNeverInAnyLog(); void testAShortTokenFileOpensNoConnection(); + void testARefusedHandshakeIsClosedByTheEngine_data(); + void testARefusedHandshakeIsClosedByTheEngine(); + void testAReceivedBatchIsNeverUnpacked(); + void testEachLineSentIsOneObject(); + void testAContextOverTheLineLimitIsStillSent(); private: // Démarre le client et mène le handshake jusqu'à Ready. @@ -847,5 +883,133 @@ void TestOptimizerClient::testAShortTokenFileOpensNoConnection() QCOMPARE(server.connections, 1); } +void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine_data() +{ + QTest::addColumn("code"); + QTest::newRow("token refusé, -32001") << -32001; + QTest::newRow("majeure incompatible, -32602") << -32602; +} + +void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine() +{ + // Protocole 1.2 (Q4) : après un handshake refusé, les DEUX côtés ferment. Ce faux optimiseur + // envoie l'erreur et GARDE son socket ouvert : la déconnexion qu'il constate vient du moteur. + QFETCH(int, code); + FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); + QVERIFY(server.listen()); + SimClock clock; + OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock); + + client.start(); + QJsonObject hs; + QVERIFY(server.waitMessage(&hs)); + server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")}, + {"error", QJsonObject{{"code", code}, {"message", "refusé"}}}}); + QVERIFY(waitFor([&] { return server.disconnections == 1; })); + QCOMPARE(client.state(), OptimizerClient::Waiting); + QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused")); + QCOMPARE(client.requestPlan({}), quint64(0)); + // Nouvelle tentative au backoff, pas avant : la fermeture n'est pas une reconnexion immédiate. + settle(); + QCOMPARE(server.connections, 1); + QCOMPARE(clock.remaining(), qint64(1000)); +} + +void TestOptimizerClient::testAReceivedBatchIsNeverUnpacked() +{ + // Protocole 1.2 (Q8) : lots interdits dans les deux sens. REÇU, un lot est une ligne écartée + // (invalid-json : ce n'est pas un objet), comptée, connexion CONSERVÉE ; la réponse qu'il + // contient n'est pas lue. La requête en vol attend donc toujours, et son délai la compte en échec. + 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 planSpy(&client, &OptimizerClient::planReceived); + QSignalSpy failSpy(&client, &OptimizerClient::planFailed); + QSignalSpy errorSpy(&client, &OptimizerClient::protocolError); + + const quint64 id = client.requestPlan({}); + QJsonObject req; + QVERIFY(server.waitMessage(&req)); + const QByteArray reponse = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)}, + {"result", QJsonObject{{"planId", "dans-un-lot"}}}}) + .toJson(QJsonDocument::Compact); + server.sendRaw("[" + reponse + "]\n"); + QVERIFY(waitFor([&] { return errorSpy.count() == 1; })); + QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("invalid-json")); + settle(); + QCOMPARE(planSpy.count(), 0); + QCOMPARE(client.pendingRequestId(), id); + QCOMPARE(client.state(), OptimizerClient::Ready); + + clock.advance(OptimizerClient::kRequestTimeoutMs); + QCOMPARE(failSpy.count(), 1); + QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout")); + settle(); + QCOMPARE(client.state(), OptimizerClient::Ready); + QCOMPARE(server.disconnections, 0); +} + +void TestOptimizerClient::testEachLineSentIsOneObject() +{ + // Protocole 1.2 (Q8, sens moteur → optimiseur) : un message par ligne, jamais un lot. Le client + // n'a qu'un point d'écriture, send(), qui sérialise UN objet ; vérifié ici sur les octets. + 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); + + 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{{"timestamp", "2026-10-16T10:00:00Z"}, + {"loads", QVariantList{QVariantMap{{"id", "a"}}, QVariantMap{{"id", "b"}}}}}; + QCOMPARE(client.requestPlan(context), quint64(2)); + QCOMPARE(client.requestPlan(context), quint64(3)); // remplace la précédente : une ligne de plus, pas un lot + QVERIFY(waitFor([&] { return raw.lines().size() == 3; })); + + const QList lines = raw.lines(); + QVERIFY(raw.received.endsWith('\n')); + for (int i = 0; i < lines.size(); ++i) { + QJsonParseError e; + const QJsonDocument doc = QJsonDocument::fromJson(lines.at(i), &e); + QVERIFY2(e.error == QJsonParseError::NoError && doc.isObject() && lines.at(i).startsWith('{'), + qPrintable(QStringLiteral("ligne %1 : pas un objet JSON seul").arg(i + 1))); + QCOMPARE(doc.object().value("id").toInteger(), qint64(i + 1)); + QVERIFY(lines.at(i).size() <= OptimizerClient::kMaxLineBytes); + } +} + +void TestOptimizerClient::testAContextOverTheLineLimitIsStillSent() +{ + // É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. + 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); + + 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)); + 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()))); +} + QTEST_GUILESS_MAIN(TestOptimizerClient) #include "testoptimizerclient.moc" From 4cbbb818026d9d304a77e167f73e1d853e1f59d8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:25 +0200 Subject: [PATCH 3/5] =?UTF-8?q?optimiseur=20de=20r=C3=A9f=C3=A9rence=20:?= =?UTF-8?q?=20protocole=201.2=20=E2=80=94=20codes=20d'erreur=20norm=C3=A9s?= =?UTF-8?q?,=20lots,=20lignes=20trop=20longues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il s'annonce 1.2 et s'aligne sur les règles écrites par ce lot : - Q3 : GetPlan avant un handshake réussi → -32600 (était -32002, hors norme), connexion gardée ; Handshake d'une autre majeure → -32602 puis fermeture (il ne comparait pas la majeure du moteur) ; token refusé → -32001, inchangé. - Q2 : un timestamp illisible ou sans décalage n'est plus une abstention mais -32602, error.data.problems = [{path, problem}], jamais la valeur reçue. - Q7 : une ligne de plus de 1 Mio ('\n' non compté) reçoit -32600 à id null. Correctif au passage : readline(LIGNE_MAX + 2) rendait le reste d'une ligne trop longue comme des lignes suivantes, et comptait le '\n' (une ligne d'exactement 1 Mio était refusée). La ligne est désormais lue jusqu'à sa fin par morceaux, sans être gardée. - Q8 : un lot reçoit -32600 à id null, aucun de ses messages n'est traité (il était ignoré en silence). - -32700 à id null pour une ligne non JSON (ignorée en silence avant). - Q5 et Q6 étaient déjà tenus : token accepté sans --token-file, UpdateLearned en notification ignorée, avec id → -32601. README : tableau des erreurs. Co-Authored-By: Claude Opus 5.5 --- tools/etm-powersync-ref-optimizer/README.md | 20 +++- .../etm-powersync-ref-optimizer | 102 ++++++++++++++---- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/tools/etm-powersync-ref-optimizer/README.md b/tools/etm-powersync-ref-optimizer/README.md index 23bf33e..3b98536 100644 --- a/tools/etm-powersync-ref-optimizer/README.md +++ b/tools/etm-powersync-ref-optimizer/README.md @@ -1,7 +1,7 @@ # `etm-powersync-ref-optimizer` — optimiseur de référence Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md` -1.1 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe +1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de comparaison d'un banc, exemple pour qui écrit son propre optimiseur. @@ -29,6 +29,24 @@ Il écrit `ÉCOUTE ` sur la sortie standard quand il écoute (le port et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement. +## Erreurs (protocole 1.2) + +Codes JSON-RPC 2.0, plus `-32001` (protocole §7) : + +| Reçu | Réponse | Connexion | +|---|---|---| +| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée | +| `Handshake` d'une autre majeure | `-32602` | fermée | +| `GetPlan` avant un handshake réussi | `-32600` | gardée | +| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée | +| ligne non JSON | `-32700`, `id: null` | gardée | +| lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée | +| ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée | +| requête d'une autre méthode, dont `UpdateLearned` avec `id` | `-32601` | gardée | + +Sans `--token-file`, un token reçu au handshake est accepté sans être lu. Une notification (sans +`id`) est ignorée. + ## Pointer le moteur dessus ```sh diff --git a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer index ba71a36..a3af737 100755 --- a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer +++ b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync -"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.1, annexe E). +"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E). etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port] [--token-file FICHIER] [--mode MODE | --mode-file FICHIER] @@ -52,7 +52,20 @@ même optimiseur, et une connexion morte n'en bloque pas une neuve. Le token se lit dans un FICHIER (--token-file), relu à chaque handshake, jamais en argument (il se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans lui, ou avec un -autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. +autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un +token reçu est accepté sans être lu. + +Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) : + Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment) + GetPlan avant un handshake réussi -32600, connexion gardée + contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la + valeur reçue — une erreur, pas une abstention + ligne non JSON -32700, id null + lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité + ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire + requête d'une méthode non servie -32601 (dont UpdateLearned, qui ne va que vers le moteur) ; + une notification (sans id) est ignorée +Hors handshake refusé, la connexion est toujours gardée. Au handshake, il s'annonce `powersync-optimizer` (exemple de référence GPL) ; `etm-optimizer` est l'optimiseur ETM, propriétaire, hors de ce dépôt. @@ -75,13 +88,19 @@ import time from datetime import datetime, timedelta ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock' -VERSION = '1.1' +VERSION = '1.2' +MAJEURE = VERSION.split('.')[0] NOM = 'etm-powersync-ref-optimizer' # Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer` # est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt. NOM_ANNONCE = 'powersync-optimizer' -LIGNE_MAX = 1024 * 1024 # E-T2-1 : une ligne de plus de 1 Mio est écartée -CODE_TOKEN_REFUSE = -32001 # E-T2-6 +LIGNE_MAX = 1024 * 1024 # E-T2-1, §2 : 1 Mio par ligne, '\n' non compté, dans les deux sens +CODE_TOKEN_REFUSE = -32001 # E-T2-6, §3 +CODE_ANALYSE = -32700 # JSON-RPC 2.0 ; codes 1.2 : OPTIMIZER_PROTOCOL.md §7 +CODE_REQUETE_INVALIDE = -32600 +CODE_METHODE_INCONNUE = -32601 +CODE_PARAMS_INVALIDES = -32602 +TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type'] @@ -308,12 +327,18 @@ def iso(t): return t.isoformat(timespec='seconds') +def problemes_contexte(ctx): + """Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais + la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut.""" + if instant(ctx.get('timestamp')) is None: + return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}] + return [] + + def plan(ctx, mode, horizon_s, token): - """La réponse à un GetPlan (`result`), ou None pour « ne pas répondre » (drop).""" + """La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte().""" t0 = time.monotonic() maintenant = instant(ctx.get('timestamp')) - if maintenant is None: - return {'abstain': True, 'reason': 'timestamp illisible ou sans décalage'} if mode == 'abstain': return {'abstain': True, 'reason': 'mode abstain (test)'} actions = waterfall(ctx, inverse=(mode == 'deviate')) @@ -412,28 +437,60 @@ class Connexion(socketserver.StreamRequestHandler): self.wfile.write(ligne) self.wfile.flush() + def erreur(self, ident, code, message, data=None): + e = {'code': code, 'message': message} + if data is not None: + e['data'] = data + self.envoyer({'jsonrpc': '2.0', 'id': ident, 'error': e}) + + def lire_ligne(self): + """Une ligne sans son '\n' ; None en fin de flux ; TROP_LONGUE pour une ligne de plus de + LIGNE_MAX octets, lue jusqu'à sa fin par morceaux, sans jamais être gardée entière.""" + morceau = self.rfile.readline(LIGNE_MAX + 1) + if not morceau: + return None + if morceau.endswith(b'\n'): + return morceau[:-1] + if len(morceau) <= LIGNE_MAX: + return morceau # dernière ligne, sans '\n', avant la fin du flux + while True: + morceau = self.rfile.readline(LIGNE_MAX + 1) + if not morceau or morceau.endswith(b'\n'): + return TROP_LONGUE + def handle(self): cfg = self.server.cfg pret = False token = None while True: try: - ligne = self.rfile.readline(LIGNE_MAX + 2) + ligne = self.lire_ligne() except (OSError, ValueError): return - if not ligne: + if ligne is None: return - if len(ligne) > LIGNE_MAX: + if ligne is TROP_LONGUE: journal('ligne trop longue écartée') + self.erreur(None, CODE_REQUETE_INVALIDE, 'ligne trop longue') + continue + if not ligne.strip(): continue try: message = json.loads(ligne) except ValueError: journal('ligne non JSON écartée') + self.erreur(None, CODE_ANALYSE, "erreur d'analyse") continue capturer(cfg, message) - if not isinstance(message, dict) or 'method' not in message: + if isinstance(message, list): + journal('lot JSON-RPC écarté : un message par ligne') + self.erreur(None, CODE_REQUETE_INVALIDE, 'lot interdit') continue + if not isinstance(message, dict): + self.erreur(None, CODE_REQUETE_INVALIDE, 'requête invalide') + continue + if 'method' not in message: + continue # une réponse : le moteur n'en envoie pas, rien à répondre ident, methode = message.get('id'), message.get('method') params = message.get('params') if isinstance(message.get('params'), dict) else {} if ident is None: @@ -444,8 +501,12 @@ class Connexion(socketserver.StreamRequestHandler): if attendu is not None and params.get('token') != attendu: # Le token reçu n'est jamais recopié ni journalisé. journal('handshake refusé : token absent ou différent') - self.envoyer({'jsonrpc': '2.0', 'id': ident, - 'error': {'code': CODE_TOKEN_REFUSE, 'message': 'token refusé'}}) + self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé') + return + version = params.get('protocolVersion') + if not isinstance(version, str) or version.split('.')[0] != MAJEURE: + journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})') + self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible') return token = attendu pret = True @@ -461,8 +522,12 @@ class Connexion(socketserver.StreamRequestHandler): journal(f'handshake accepté ({str(params.get("engine", "?"))[:80]}), mode {mode}') elif methode == 'GetPlan': if not pret: - self.envoyer({'jsonrpc': '2.0', 'id': ident, - 'error': {'code': -32002, 'message': 'handshake attendu'}}) + self.erreur(ident, CODE_REQUETE_INVALIDE, 'handshake requis') + continue + problemes = problemes_contexte(params) + if problemes: + journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}') + self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes}) continue if mode == 'drop': journal('mode drop : connexion fermée sans réponse') @@ -472,8 +537,7 @@ class Connexion(socketserver.StreamRequestHandler): self.envoyer({'jsonrpc': '2.0', 'id': ident, 'result': plan(params, mode, cfg.horizon_s, token)}) else: - self.envoyer({'jsonrpc': '2.0', 'id': ident, - 'error': {'code': -32601, 'message': 'méthode inconnue'}}) + self.erreur(ident, CODE_METHODE_INCONNUE, 'méthode inconnue') class ServeurUnix(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): @@ -518,7 +582,7 @@ def ecouter(endpoint): def main(argv=None): - p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.1, annexe E).') + p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).') p.add_argument('--listen', default=ENDPOINT_DEFAUT, help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})') p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)') From 7bb2b4a19416d8768cd225fb81a5561fa4404350 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:35 +0200 Subject: [PATCH 4/5] =?UTF-8?q?test(conformance)=20:=20sonde=20des=20r?= =?UTF-8?q?=C3=A8gles=201.2=20que=20le=20moteur=20ne=20provoque=20pas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre fonctions, une connexion de SONDE sur l'optimiseur (sans le moteur), pour ce que le client du moteur n'envoie jamais : - testGetPlanBeforeTheHandshakeIsRefused (Q3) : -32600 à son id, puis le handshake sur la même connexion est accepté (connexion gardée) ; - testARefusedHandshakeIsClosedByTheOptimizer (Q3, Q4) : autre majeure → -32602 puis l'optimiseur ferme ; avec un token attendu, token faux → -32001, fermé pareil ; - testABatchIsRefused (Q8) : un lot → une erreur -32600 à id null, son contenu non traité, connexion gardée ; - testALineOverTheLimitIsRefused (Q7) : maxLineBytes + 1 → -32600 à id null, une ligne d'exactement maxLineBytes est lue. Le traitement d'un message se constate par une réponse normée sans contexte : UpdateLearned avec id → -32601 (Q6). Réponses lues brutes : un lot renvoyé se verrait. README : tableau, boucle de lancement, ce qui n'est pas vérifié. Co-Authored-By: Claude Opus 5.5 --- tests/auto/conformance/README.md | 21 +- tests/auto/conformance/testconformance.cpp | 243 ++++++++++++++++++++- 2 files changed, 251 insertions(+), 13 deletions(-) diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md index 0ccaa61..d135e75 100644 --- a/tests/auto/conformance/README.md +++ b/tests/auto/conformance/README.md @@ -11,9 +11,18 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio | `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) | | `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` | | `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 | +| `testGetPlanBeforeTheHandshakeIsRefused` | protocole 1.2, §3 : `GetPlan` avant un handshake réussi → erreur `-32600` à son `id`, connexion **gardée** (le handshake qui suit est accepté) | +| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil | +| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée | +| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue | Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. +Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce +que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles +utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` → +`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité. + Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W, @@ -30,7 +39,8 @@ cd /tests/auto/conformance export NYMEA_PLUGINS_PATH=/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}' for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ - testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop; do + testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ + testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; do systemd-run --user --scope -q -p MemoryMax=2G \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ --bind --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn" @@ -65,7 +75,7 @@ toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` : - `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ; -- les trois autres fonctions sont inchangées. +- les autres fonctions sont inchangées. À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode @@ -81,6 +91,9 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne ## Ce qu'elle ne vérifie pas La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence -seulement), les bornes VE (aucune borne au banc), `UpdateLearned` (ignoré par le moteur 1.1), le -comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` +seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa +forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ; +le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` +invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut +de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp index 217f04f..5b0f09f 100644 --- a/tests/auto/conformance/testconformance.cpp +++ b/tests/auto/conformance/testconformance.cpp @@ -16,7 +16,11 @@ // sémantiques comprises — jugée par le validateur publié ; // 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ; // 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur, -// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur. +// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur ; +// 6 à 9 (protocole 1.2). Ce que le moteur n'envoie JAMAIS, envoyé par une connexion de SONDE +// (sans le moteur) : GetPlan avant le handshake (-32600, connexion gardée), handshake d'une +// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot +// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée). // // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque @@ -52,6 +56,7 @@ #include #include +#include #include using namespace nymeaserver; @@ -319,6 +324,69 @@ private: QElapsedTimer m_chrono; }; +/*! + * Connexion de SONDE (protocole 1.2) : des lignes écrites à la main, les réponses lues une à une, + * sans le moteur — pour ce que son client n'envoie jamais. Une réponse est gardée BRUTE aussi : un + * lot renvoyé (`[…]`) n'est pas un objet, et QJsonDocument::object() le rendrait vide. + */ +class Sonde +{ +public: + explicit Sonde(const QString &endpoint) : m_s(socketVers(endpoint, &m_proprietaire)) {} + + bool ouverte() { return m_s && waitFor([this] { return connecte(m_s); }, 5000); } + + void envoyer(const QByteArray &ligne) + { + m_s->write(ligne + '\n'); + if (auto *l = qobject_cast(m_s)) + l->flush(); + else if (auto *t = qobject_cast(m_s)) + t->flush(); + } + + //! La ligne suivante (sans son '\n') ; faux si rien n'arrive en \p ms, ou si la connexion se ferme avant. + bool recevoir(QByteArray *ligne, int ms = 5000) + { + waitFor([this] { lire(); return m_tampon.contains('\n') || !connecte(m_s); }, ms); + lire(); + const int nl = m_tampon.indexOf('\n'); + if (nl < 0) + return false; + *ligne = m_tampon.left(nl); + m_tampon.remove(0, nl + 1); + return true; + } + + //! Vrai si l'AUTRE côté ferme en \p ms : la sonde ne ferme jamais d'elle-même. + bool fermeeParLautre(int ms) { return waitFor([this] { lire(); return !connecte(m_s); }, ms); } + +private: + void lire() { m_tampon += m_s->readAll(); } + + QObject m_proprietaire; + QIODevice *m_s; + QByteArray m_tampon; +}; + +//! Une réponse error de la sonde : `{"jsonrpc":"2.0","id":,"error":{"code":,…}}`, objet seul. +QString verifierErreur(const QByteArray &ligne, const QJsonValue &id, int code) +{ + QJsonParseError e; + const QJsonDocument doc = QJsonDocument::fromJson(ligne, &e); + const QString vu = QString::fromUtf8(ligne.left(300)); + if (e.error != QJsonParseError::NoError || !doc.isObject()) + return QStringLiteral("réponse qui n'est pas UN objet JSON : ") + vu; + const QJsonObject m = doc.object(); + if (!m.contains("error") || m.value("error").toObject().value("code").toInt() != code) + return QStringLiteral("erreur %1 attendue : ").arg(code) + vu; + const QJsonValue recu = m.value("id"); + const bool bonId = id.isNull() ? m.contains("id") && recu.isNull() : recu.isDouble() && recu.toDouble() == id.toDouble(); + if (!bonId) + return QStringLiteral("id %1 attendu : ").arg(id.isNull() ? QStringLiteral("null") : QString::number(id.toInt())) + vu; + return QString(); +} + QString cle(const QVariantMap &v) { return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact)); @@ -336,9 +404,23 @@ private slots: void testTheSessionMatchesTheContract(); void testEveryPlanIsAccepted(); void testTheSafetyLoopStaysOnTop(); + void testGetPlanBeforeTheHandshakeIsRefused(); + void testARefusedHandshakeIsClosedByTheOptimizer(); + void testABatchIsRefused(); + void testALineOverTheLimitIsRefused(); private: void monter(); + //! Où l'optimiseur écoute ($ETM_POWERSYNC_OPTIMIZER_ENDPOINT, sinon un socket du test). + QString endpoint() const; + //! Le token que l'optimiseur attend ($ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE), vide sinon. + QString lireToken(); + //! Points 6 à 9 : l'optimiseur seul, sans moteur ; \p sonde ouverte dessus. + void sonder(std::unique_ptr &sonde); + //! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide. + QByteArray handshake(int id, const QString &version, const QString &token) const; + //! Handshake accepté sur \p sonde, ou échec du test. + void ouvrirSession(Sonde &sonde, int id); //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). void brancher(bool parLeRelais); void cycle(const QDateTime &t); @@ -437,20 +519,32 @@ void TestOptimizerConformance::monter() m_arb->setOptimizerTraceDirectory(d); QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError")); // Token : celui que l'optimiseur attend, s'il en attend un. - const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); - if (!fichier.isEmpty()) { - QFile f(fichier); - QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); - m_token = QString::fromUtf8(f.readAll().trimmed()); + m_token = lireToken(); + if (QTest::currentTestFailed()) + return; + if (!m_token.isEmpty()) QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError")); - } m_surplusW = 0; } +QString TestOptimizerConformance::endpoint() const +{ + return variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path())); +} + +QString TestOptimizerConformance::lireToken() +{ + const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); + if (fichier.isEmpty()) + return QString(); + QFile f(fichier); + [&] { QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); }(); + return QString::fromUtf8(f.readAll().trimmed()); +} + void TestOptimizerConformance::brancher(bool parLeRelais) { - const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", - QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path())); + const QString endpoint = this->endpoint(); QString erreur; QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur)); QString cible = endpoint; @@ -710,5 +804,136 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop() } } +// --- 6 à 9 : protocole 1.2, par une connexion de sonde ---------------------------------------------- + +void TestOptimizerConformance::sonder(std::unique_ptr &sonde) +{ + QVERIFY(m_dir.isValid()); + m_token = lireToken(); + if (QTest::currentTestFailed()) + return; + QString erreur; + QVERIFY2(m_optimiseur.demarrer(endpoint(), &erreur), qUtf8Printable(erreur)); + sonde.reset(new Sonde(endpoint())); + QVERIFY2(sonde->ouverte(), qUtf8Printable("la sonde ne se connecte pas à " + endpoint())); +} + +QByteArray TestOptimizerConformance::handshake(int id, const QString &version, const QString &token) const +{ + QJsonObject params{{"protocolVersion", version}, {"engine", "etm-powersync-energy-plugin-etm/conformance"}}; + if (!token.isEmpty()) + params.insert("token", token); + return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "Handshake"}, {"params", params}}) + .toJson(QJsonDocument::Compact); +} + +void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) +{ + sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token)); + QByteArray ligne; + QVERIFY2(sonde.recevoir(&ligne), "handshake de sonde sans réponse"); + const QJsonObject m = QJsonDocument::fromJson(ligne).object(); + QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), + qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300)))); +} + +void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused() +{ + // §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le + // handshake qui suit, sur la même connexion, est accepté. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + sonde->envoyer(R"({"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}})"); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), "GetPlan avant le handshake : aucune réponse (ou connexion fermée)"); + const QString e = verifierErreur(ligne, 1, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + ouvrirSession(*sonde, 2); +} + +void TestOptimizerConformance::testARefusedHandshakeIsClosedByTheOptimizer() +{ + // §3 (1.2) : un handshake refusé est fermé des deux côtés. Ici, la moitié de l'OPTIMISEUR : il + // ferme après avoir envoyé l'erreur (la sonde, elle, ne ferme pas). Majeure incompatible → + // -32602 ; si l'optimiseur attend un token, un token faux → -32001, fermé pareil. La moitié du + // moteur est éprouvée par la suite `optimizerclient` (testARefusedHandshakeIsClosedByTheEngine). + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0"; + sonde->envoyer(handshake(1, autreMajeure, m_token)); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), qUtf8Printable("handshake en " + autreMajeure + " : aucune réponse avant la fermeture")); + QString e = verifierErreur(ligne, 1, -32602); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + QVERIFY2(sonde->fermeeParLautre(5000), "handshake refusé (-32602) : l'optimiseur n'a pas fermé la connexion en 5 s"); + + if (m_token.isEmpty()) { + qInfo().noquote() << "[Conformité] aucun token attendu : refus -32001 non éprouvé"; + return; + } + Sonde faux(endpoint()); + QVERIFY(faux.ouverte()); + faux.envoyer(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token + QStringLiteral("-faux"))); + QVERIFY2(faux.recevoir(&ligne), "token faux : aucune réponse avant la fermeture"); + e = verifierErreur(ligne, 1, -32001); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé (-32001) : l'optimiseur n'a pas fermé la connexion en 5 s"); +} + +void TestOptimizerConformance::testABatchIsRefused() +{ + // §2 (1.2) : lots interdits. Un lot reçu → UNE réponse -32600 à id null, aucun de ses messages + // traité, connexion gardée. Le lot porte une requête dont la réponse est normée (UpdateLearned + // avec id → -32601, §8) : la traiter se verrait. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + ouvrirSession(*sonde, 1); + if (QTest::currentTestFailed()) + return; + sonde->envoyer(R"([{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}])"); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), "lot : aucune réponse (ou connexion fermée)"); + QString e = verifierErreur(ligne, QJsonValue::Null, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + // Connexion gardée, et la ligne suivante répond à la requête suivante — pas au contenu du lot. + sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})"); + QVERIFY2(sonde->recevoir(&ligne), "après le lot : aucune réponse (ou connexion fermée)"); + e = verifierErreur(ligne, 3, -32601); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); +} + +void TestOptimizerConformance::testALineOverTheLimitIsRefused() +{ + // §2 (1.2) : maxLineBytes dans les deux sens, '\n' non compté. Un octet de trop → -32600 à id + // null, la ligne n'est pas traitée, connexion gardée ; une ligne d'exactement maxLineBytes est + // lue normalement. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + ouvrirSession(*sonde, 1); + if (QTest::currentTestFailed()) + return; + auto ligneDe = [](int id, int taille) { + QByteArray l = QStringLiteral(R"({"jsonrpc":"2.0","id":%1,"method":"UpdateLearned","params":{}})").arg(id).toUtf8(); + return l + QByteArray(taille - l.size(), ' '); + }; + sonde->envoyer(ligneDe(2, OptimizerClient::kMaxLineBytes + 1)); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne trop longue : aucune réponse (ou connexion fermée)"); + QString e = verifierErreur(ligne, QJsonValue::Null, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + sonde->envoyer(ligneDe(3, OptimizerClient::kMaxLineBytes)); + QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne d'exactement maxLineBytes : aucune réponse (ou connexion fermée)"); + e = verifierErreur(ligne, 3, -32601); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); +} + QTEST_MAIN(TestOptimizerConformance) #include "testconformance.moc" From 8f2470987482585cb8761667d2ace157292f84a5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:46 +0200 Subject: [PATCH 5/5] =?UTF-8?q?protocole=20optimiseur=201.2=20:=20les=20r?= =?UTF-8?q?=C3=A8gles=20Q2=20=C3=A0=20Q9,=20et=20le=20moteur=20annonce=201?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/OPTIMIZER_PROTOCOL.md passe en v1.2 (mineure) et écrit les décisions du 2026-10-21 à leur place : - §2 : maxLineBytes (1 Mio, '\n' non compté) dans les deux sens ; lots interdits dans les deux sens ; /run/powersync appartient au paquet de l'optimiseur, le moteur ne le crée ni ne le suppose. Écart connu noté : le moteur ne borne pas encore ce qu'il émet. - §3 : la majeure se compare des deux côtés (-32602 puis fermeture) ; -32001 normé ; un token non exigé est accepté ; un handshake refusé est fermé des deux côtés ; GetPlan avant handshake → -32600, connexion gardée. - §7 : contexte GetPlan invalide → -32602 avec chemins et natures, jamais les valeurs, compté comme un échec ; codes JSON-RPC 2.0 standard sinon ; table des erreurs GÉNÉRÉE depuis le contrat. - §8 : UpdateLearned à sens unique, -32601 avec id ; forme non normée en 1.2. - §9, §0 (changelog, compatibilité 1.1), annexes A, E, G. Contrat OpenRPC : methods[].errors complétés (x-etm-connection), x-etm-errors-engine, x-etm-message-errors (nouveau), x-etm-transport (maxLineBytesScope, batches, runtimeDirectory), règle handshake-refused. La version (info.version, ProtocolVersion) régénérée depuis le code par --enums-from. Moteur : OptimizerProtocol::ProtocolVersion et OptimizerClient::kProtocolVersion passent à "1.2" ; testHandshakeAndPlan vérifie aussi que les deux constantes sont égales. Aucun autre comportement ne change. Tests suivis : version attendue dans testoptimizerclient et testsocketscheduler, handshake de la session de schema-check-test, et un test du bloc « errors » dans gen-protocol-doc-test. Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 167 +++++++++++++++--- ...-powersync-optimizer-protocol.openrpc.json | 84 +++++++-- energyplugin/etm/scheduler/optimizerclient.h | 2 +- .../etm/scheduler/optimizerprotocol.h | 2 +- .../optimizerclient/testoptimizerclient.cpp | 8 +- .../socketscheduler/testsocketscheduler.cpp | 2 +- .../etm-powersync-gen-protocol-doc-test.py | 16 ++ .../tools/etm-powersync-schema-check-test.py | 2 +- 8 files changed, 241 insertions(+), 42 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 25c20c8..8e7f376 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1,4 +1,4 @@ -# OPTIMIZER_PROTOCOL — v1.1 +# OPTIMIZER_PROTOCOL — v1.2 Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce). @@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages : 2. Spécification du protocole socket pour un optimiseur en process séparé. 3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. -Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). +Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). > ### La forme est un contrat MACHINE > @@ -42,6 +42,40 @@ Statut : **v1.1** — figé à la fin de la beta. Versionné en semver (`protoco ## 0. Changelog +### 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. + +**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. + +- §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 + valeurs. Le moteur la compte comme un échec (règle des échecs consécutifs inchangée). +- §3, §7 **Codes d'erreur de l'optimiseur normés** : majeure incompatible au handshake → `-32602` + puis fermeture ; `GetPlan` avant un handshake réussi → `-32600`, connexion gardée ; token faux + ou absent → `-32001` (inchangé). Toute autre erreur : un code JSON-RPC 2.0 standard. +- §3 **Après un handshake refusé, les deux côtés ferment** : l'optimiseur après avoir envoyé + l'erreur, le moteur à sa réception. +- §3 **Token envoyé alors que l'optimiseur n'en exige pas : accepté**, sans erreur. +- §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. +- §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. +- Contrat machine : `methods[].errors` complétés, `x-etm-message-errors` (nouveau), + `x-etm-transport` (`maxLineBytesScope`, `batches`, `runtimeDirectory`) ; tableau des erreurs + du §7 généré depuis lui. + ### 1.1 (2026-10) Additif sur le fil : toutes les additions sont des **champs optionnels nouveaux** ; un optimiseur 1.0 reste conforme (champs inconnus ignorés, §9). @@ -110,9 +144,28 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr - Endpoint configurable : - `unix:///run/powersync/optimizer.sock` — défaut. Optimiseur local. Droits : socket possédé par `root:powersync`, mode `0660`. + **Le répertoire `/run/powersync` appartient au paquet de l'optimiseur** (1.2) : + `RuntimeDirectory=powersync` de son unité systemd, groupe `powersync`. Le moteur ne le crée + ni ne le suppose : sans optimiseur, il n'en a pas besoin — et il ne se connecte à rien tant + qu'aucun endpoint n'est configuré (§7, `optimizerExpected=false`). - `tcp://:` — optimiseur sur le LAN (NAS, serveur). Pour les implémentations communautaires (ex. pont Akkudoktor-EOS, annexe B). - Protocole applicatif : **JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8.** +- **Pas de lot, dans les deux sens** (1.2). Chaque ligne porte un seul objet JSON-RPC ; un lot + (`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun + message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée + (`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue + dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). +- **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux + sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur + `-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 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`. @@ -123,7 +176,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ - "protocolVersion":"1.1", + "protocolVersion":"1.2", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} @@ -133,7 +186,7 @@ Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ - "protocolVersion":"1.1", + "protocolVersion":"1.2", "name":"etm-optimizer", "planning":true, "capabilities":{ @@ -146,14 +199,16 @@ Réponse attendue : }} ``` -- `protocolVersion` : même **majeure** requise. Majeure différente → le plugin refuse, - log explicite, repli rule-based. +- `protocolVersion` : même **majeure** requise, **des deux côtés**. Majeure différente dans la + réponse → le plugin refuse, log explicite, repli rule-based. Majeure différente dans la requête + → l'optimiseur refuse (1.2) : erreur `-32602`, puis fermeture. Une autre mineure est acceptée + des deux côtés. - `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il ne répond qu'au présent (plan à un créneau). Informationnel. -- `token` : si configuré côté plugin et absent/incorrect côté serveur → réponse `error` - (code `-32001` recommandé ; le moteur traite **toute** réponse `error` au handshake comme un - refus, `handshake-refused`) puis connexion fermée. Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au - moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 +- `token` : si configuré côté optimiseur et absent ou différent dans la requête → réponse `error` + **`-32001`** (normé en 1.2 ; « recommandé » en 1.1) puis connexion fermée. **Un token envoyé + alors que l'optimiseur n'en exige pas est accepté**, sans erreur (1.2). Recommandé pour tout + endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8 admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur renvoie, et un token court corromprait un identifiant qui le contiendrait. @@ -165,6 +220,14 @@ Réponse attendue : `result` non objet, `protocolVersion` absent ou vide, `capabilities` présent mais non objet (`invalid-handshake`) ; majeure différente (`major-mismatch`). - `name` : optionnel, informatif, affiché en mode installateur ; jamais contrôlé. +- **Refus et fermeture** (1.2). Un handshake refusé — token (`-32001`) ou majeure (`-32602`) — + est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa + réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit + son code (`handshake-refused`), et retente au backoff (§2). +- **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la + connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il + n'appelle `GetPlan` qu'une fois le handshake accepté. +- Les codes d'erreur de l'optimiseur sont rassemblés au §7, « Erreurs renvoyées par l'optimiseur ». @@ -173,7 +236,7 @@ Réponse attendue : | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `protocolVersion` | `ProtocolVersion` | oui | `"1.1"` | Version du moteur ; seule la majeure est comparée. | +| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). | | `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. | | `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. | @@ -1152,6 +1215,42 @@ requête remplacée par une plus récente n'est pas un échec. Avant le premier `optimizerAlive` vaut `false`. Un plan invalide remplace le cache (l'ancien plan valide n'est plus appliqué) et un socket fermé l'abandonne (E-T3-9). +**Erreurs renvoyées par l'optimiseur** (1.2). Un optimiseur répond une erreur JSON-RPC, jamais +une abstention, à ce qu'il ne peut pas traiter. En particulier : + +- **Contexte `GetPlan` invalide → `-32602`**, pas `abstain`. `error.data` liste les problèmes : + pour chacun, le **chemin** du champ fautif et la **nature** du problème (obligatoire absent, + type faux, valeur hors domaine…) — **jamais la valeur reçue**. Un contexte invalide est un défaut + du moteur ; une abstention le cacherait. Le moteur compte cette erreur comme un **échec** de la + requête (`rpc-error`), selon la règle des deux échecs consécutifs ci-dessus, inchangée. +- Les codes sont ceux de JSON-RPC 2.0, plus `-32001` (token refusé), seul code propre au + protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond + (`-32603` erreur interne, par exemple). + + + + +| Code | Méthode | `message` | Connexion après l'erreur | Quand | +|---|---|---|---|---| +| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | +| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). | +| `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. | +| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | +| `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. | +| `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. | +| `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. | +| `-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 fait le moteur d'une réponse error : + +- **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff. +- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). + + + **Ce que le validateur refuse en plus de la forme.** Le JSON Schema du contrat dit la forme ; le moteur juge ensuite chaque réponse sur le contexte de SA requête, dans cet ordre, et le premier contrôle qui échoue donne son code (`fallbackCode` vers l'app, `lastErrorCode` pour un refus) : @@ -1179,7 +1278,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt | `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. | | `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. | | `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). | -| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff. | +| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | | `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. | | `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. | @@ -1194,8 +1293,14 @@ qu'elle affiche de ces trois champs. - L'optimiseur peut renvoyer ses estimations (`learned` + `confidence`) dans une notification `UpdateLearned` (sans id, fire-and-forget) ; le plugin les persiste - et les retransmet dans les contextes suivants. **État 1.1 du moteur** : reçue, journalisée + et les retransmet dans les contextes suivants. **État du moteur** : reçue, journalisée une fois, ignorée — la persistance est un lot ultérieur (`INTERFACE.md`). +- **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par + un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`), + erreur `-32601` (méthode inconnue). +- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le + contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui + apprend. - Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge « profil en apprentissage ». - Les données d'apprentissage restent **locales**. Rien ne quitte le site. @@ -1207,7 +1312,7 @@ qu'elle affiche de ces trois champs. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `loads` | [object] | non | | Non normé en 1.1 : intention §8 (learned + confidence par charge). | +| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). @@ -1217,7 +1322,12 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## 9. Versionnement -- `protocolVersion` en semver. Même majeure = compatible. +- `protocolVersion` en semver. Même majeure = compatible. **Les deux côtés comparent la + majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et + l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée. +- Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre + côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses + à aligner avant de s'annoncer de cette mineure, pas comme une rupture. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. @@ -1226,14 +1336,26 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## Annexe A — Session minimale ``` -→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.1","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm70"}} -← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","name":"mon-optimiseur","planning":false}} +→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}} → {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} ← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ``` +Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce +qu'éprouve la suite de conformité, par une connexion de sonde : + +``` +→ {"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}} (avant le handshake) +← {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"handshake requis"}} connexion gardée +→ [{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}] (un lot) +← {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"lot interdit"}} connexion gardée +→ {"jsonrpc":"2.0","id":3,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…"}} +← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture +``` + Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, -renvoyer un plan à un créneau (ou `abstain`). +renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7. ## Annexe B — Exemple : brancher Akkudoktor-EOS @@ -1301,7 +1423,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden ## Annexe E — Optimiseur de référence `tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) -conforme 1.1, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un +conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du `SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`. @@ -1338,7 +1460,9 @@ Ils deviendront normatifs avec le lot qui les implémente. - **Le contrat** : `docs/etm-powersync-optimizer-protocol.openrpc.json` (OpenRPC 1.3). Méthodes `Handshake`, `GetPlan`, `UpdateLearned` ; forme des messages en JSON Schema (`components.schemas`) ; ce qu'OpenRPC ne sait pas dire en extensions `x-etm-…` : - `x-etm-transport` (unix/tcp, NDJSON, rôles, délais), `x-etm-direction` (sens d'une méthode), + `x-etm-transport` (unix/tcp, NDJSON, rôles, délais, longueur de ligne, lots, répertoire du + socket), `x-etm-direction` (sens d'une méthode), `methods[].errors` et `x-etm-message-errors` + (erreurs de l'optimiseur, 1.2), `x-etm-errors-engine` (ce que le moteur fait d'une erreur), `x-etm-fallback-machine` (§7), `x-etm-semantic-rules` (ce que le validateur refuse en plus, avec `fallbackCode`), `x-etm-tolerances` (ce que le moteur accepte au-delà), `x-etm-reason-params`, `x-etm-generated` (listes fermées régénérées depuis le code). Forme côté moteur (requêtes) @@ -1349,5 +1473,6 @@ Ils deviendront normatifs avec le lot qui les implémente. une session NDJSON (les deux sens) ou des traces (annexe D) ; `--semantic` applique les règles. - **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que - lance `ETM_POWERSYNC_OPTIMIZER_CMD`. + lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde, + les erreurs du §7 que le moteur ne provoque pas (annexe A). - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`). diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index 801705b..b387e36 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -2,18 +2,18 @@ "openrpc": "1.3.2", "info": { "title": "etm-powersync — protocole moteur ↔ optimiseur", - "version": "1.1", + "version": "1.2", "license": { "name": "GPL-3.0-or-later", "url": "https://www.gnu.org/licenses/gpl-3.0.html" }, - "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." + "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." }, "servers": [ { "name": "unix", "url": "unix:///run/powersync/optimizer.sock", - "summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660." + "summary": "Défaut : optimiseur local, socket possédé par root:powersync, mode 0660. Le répertoire /run/powersync appartient au paquet de l'optimiseur (x-etm-transport.runtimeDirectory)." }, { "name": "tcp", @@ -33,7 +33,7 @@ { "name": "Handshake", "summary": "Ouverture de session, à chaque (re)connexion (§3).", - "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé.", + "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", "paramStructure": "by-name", "params": [ { @@ -42,7 +42,7 @@ "schema": { "$ref": "#/components/schemas/ProtocolVersion" }, - "description": "Version du moteur ; seule la majeure est comparée." + "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)." }, { "name": "engine", @@ -71,11 +71,19 @@ { "code": -32001, "message": "token refusé", - "data": "Recommandé (optimiseur de référence). Toute réponse error est traitée pareil : handshake-refused, connexion fermée." + "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", + "x-etm-connection": "fermée" + }, + { + "code": -32602, + "message": "majeure incompatible", + "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).", + "x-etm-connection": "fermée" } ], "x-etm-direction": "engine→optimizer", - "x-etm-timeout-s": 5 + "x-etm-timeout-s": 5, + "x-etm-errors-engine": "Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff." }, { "name": "GetPlan", @@ -176,19 +184,33 @@ } }, "errors": [ + { + "code": -32600, + "message": "handshake requis", + "data": "GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt.", + "x-etm-connection": "conservée" + }, + { + "code": -32602, + "message": "contexte invalide", + "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "x-etm-connection": "conservée" + }, { "code": -32603, "message": "erreur interne", - "data": "Toute réponse error compte comme un échec de la requête (rpc-error, §7) ; le code n'est pas lu." + "data": "Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur.", + "x-etm-connection": "conservée" } ], "x-etm-direction": "engine→optimizer", - "x-etm-timeout-s": 10 + "x-etm-timeout-s": 10, + "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)." }, { "name": "UpdateLearned", "summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.", - "description": "Envoyée par l'OPTIMISEUR (sens inverse des deux autres), sans id, sans réponse. État 1.1 du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme non normée en 1.1.", + "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE en 1.2 : elle sera fixée avec la première stratégie qui apprend.", "paramStructure": "by-name", "params": [ { @@ -201,7 +223,7 @@ "additionalProperties": true } }, - "description": "Non normé en 1.1 : intention §8 (learned + confidence par charge)." + "description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)." } ], "x-etm-direction": "optimizer→engine", @@ -212,7 +234,7 @@ "schemas": { "ProtocolVersion": { "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).", - "const": "1.1" + "const": "1.2" }, "Trigger": { "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.", @@ -1799,6 +1821,8 @@ "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.", + "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, "backoffS": { @@ -1808,8 +1832,38 @@ }, "requestsInFlight": 1, "ids": "entiers croissants sur toute la vie du client, à travers les reconnexions", - "lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE", - "tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse" + "lineErrors": "ligne non JSON, sans id, id jamais émis, ligne > maxLineBytes : comptée, connexion CONSERVÉE (côté moteur ; côté optimiseur : x-etm-message-errors)", + "tokenMasking": "toute occurrence du token dans un message reçu est remplacée par « [token masqué] » dès l'analyse", + "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.", + "items": [ + { + "code": -32700, + "message": "erreur d'analyse", + "data": "Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0.", + "x-etm-connection": "conservée" + }, + { + "code": -32600, + "message": "lot interdit", + "data": "Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité.", + "x-etm-connection": "conservée" + }, + { + "code": -32600, + "message": "ligne trop longue", + "data": "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.", + "x-etm-connection": "conservée" + }, + { + "code": -32601, + "message": "méthode inconnue", + "data": "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.", + "x-etm-connection": "conservée" + } + ] }, "x-etm-fallback-machine": { "description": "Machine de repli §7 (OptimizerStatus) : heartbeat implicite — une réponse valide à GetPlan = optimiseur vivant. Les échecs se comptent PAR REQUÊTE.", @@ -2050,7 +2104,7 @@ "countsAsFailure": false, "needs": [], "schemaAlsoCatches": false, - "description": "réponse error (token absent ou différent : -32001 recommandé) → connexion fermée, nouvelle tentative au backoff." + "description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." }, { "name": "invalid-handshake", diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 3c81ab0..5c1c890 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -144,7 +144,7 @@ public: Q_ENUM(State) /*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */ - static constexpr const char *kProtocolVersion = "1.1"; + 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. diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h index 4c43dc2..2ddfff1 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.h +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -28,7 +28,7 @@ namespace OptimizerProtocol { //! Version du protocole parlée par ce sérialiseur (§3, §9). -constexpr const char *ProtocolVersion = "1.1"; +constexpr const char *ProtocolVersion = "1.2"; //! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json). constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json"; diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index 8d7fd80..b869b6a 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -6,6 +6,7 @@ // des sockets, jamais à laisser s'écouler un délai du client. #include "etm/scheduler/optimizerclient.h" +#include "etm/scheduler/optimizerprotocol.h" #include "../common/etm-powersync-fakeoptimizer.h" #include "../common/etm-powersync-contrat.h" @@ -285,7 +286,10 @@ void TestOptimizerClient::testHandshakeAndPlan() QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("id").toInteger(), 1); const QJsonObject params = hs.value("params").toObject(); - QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.1")); + // Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert + // (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur. + QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2")); + QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion)); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QVERIFY(!params.contains("token")); // Seule l'échéance du handshake est armée, à 5 s depuis le début de la tentative. @@ -348,7 +352,7 @@ void TestOptimizerClient::testWrongMajorIsRefused() QCOMPARE(readySpy.count(), 0); QCOMPARE(client.requestPlan({}), quint64(0)); QVERIFY(waitFor([&] { return server.disconnections == 1; })); - QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.1 — majeures différentes, refusé"), 1); + QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1); // Nouvelle tentative au backoff (§3), pas avant. clock.advance(999); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index d9a08f6..e44bb24 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -2166,7 +2166,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() cycle(t0.addSecs(60 * i), -3000); const QVariantMap r = rpc("GetOptimizerProtocol"); - QCOMPARE(r.value("protocolVersion").toString(), QString("1.1")); + QCOMPARE(r.value("protocolVersion").toString(), QString("1.2")); const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); QVERIFY(!embarque.isEmpty()); diff --git a/tests/tools/etm-powersync-gen-protocol-doc-test.py b/tests/tools/etm-powersync-gen-protocol-doc-test.py index 28b0b4b..b1814ee 100644 --- a/tests/tools/etm-powersync-gen-protocol-doc-test.py +++ b/tests/tools/etm-powersync-gen-protocol-doc-test.py @@ -106,6 +106,22 @@ class TestGenProtocolDoc(unittest.TestCase): self.assertEqual(code, 2, sortie) self.assertIn('orpheline', sortie) + def test_le_tableau_des_erreurs_suit_le_contrat(self): + # Protocole 1.2 : methods[].errors puis x-etm-message-errors, et ce que fait le moteur. + p = self.lire(self.prose) + self.assertIn('| `-32602` | Handshake | majeure incompatible | fermée |', p) + self.assertIn('| `-32600` | GetPlan | handshake requis | conservée |', p) + self.assertIn('| `-32600` | toute | lot interdit | conservée |', p) + self.assertIn('- **GetPlan** : Toute réponse error à GetPlan', p) + doc = json.loads(self.lire(self.schema)) + doc['x-etm-message-errors']['items'].append({'code': -32099, 'message': 'erreur ajoutée', + 'data': 'contre-épreuve', 'x-etm-connection': 'conservée'}) + self.ecrire(self.schema, json.dumps(doc, ensure_ascii=False, indent=2) + '\n') + code, sortie = lancer(*self.args('--check')) + self.assertEqual(code, 1, sortie) + self.assertEqual(lancer(*self.args())[0], 0) + self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose)) + def test_des_balises_cassees_sont_une_erreur(self): p = self.lire(self.prose) i = p.index('') diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 7e661b4..7e2994b 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -100,7 +100,7 @@ def session(mode, dossier): with open(REQUETE, encoding='utf-8') as f: params = json.load(f) s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', - 'params': {'protocolVersion': '1.1', 'engine': 'etm-powersync-energy-plugin-etm/test', + 'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test', 'token': TOKEN}}).encode() + b'\n') hs = lire_ligne(s, tampon) if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'):