From 4cbbb818026d9d304a77e167f73e1d853e1f59d8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:25 +0200 Subject: [PATCH] =?UTF-8?q?optimiseur=20de=20r=C3=A9f=C3=A9rence=20:=20pro?= =?UTF-8?q?tocole=201.2=20=E2=80=94=20codes=20d'erreur=20norm=C3=A9s,=20lo?= =?UTF-8?q?ts,=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)')