From af38810da557f109a568d26a0463143fdd97353e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 00:24:09 -0400 Subject: [PATCH] [FIX] script execute : afficher l'invite sans attendre le saut de ligne MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « readline » ne rend la main qu'au saut de ligne : une invite qui n'en porte pas restait retenue jusqu'à la ligne suivante, écrite après la réponse. Toute question « [o/N] » d'une commande du menu se voyait donc après coup, et l'on y répondait à l'aveugle. La lecture se fait par blocs, le reliquat part à l'écran aussitôt et stdout est vidé ; un décodeur incrémental protège les accents coupés entre deux lectures. Vérifié : invite visible avant la frappe, sans double affichage ; 13 cas de non-régression ; 4546 tests, seuls les 9 échecs préexistants de qemu_proxmox. --- EN --- `readline` only returns at a newline: a prompt carrying none stayed held back until the next line, written after the answer. Every "[o/N]" question asked by a menu command was therefore seen after the fact, and answered blind. Reading now happens in chunks, the trailing fragment reaches the screen at once and stdout is flushed; an incremental decoder protects accented characters split across two reads. Checked: prompt visible before typing, with no double display; 13 regression cases; 4546 tests, only the 9 pre-existing qemu_proxmox failures. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 77 +++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index b7826de..09515b8 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -8,6 +8,7 @@ # migration meurt sur un TypeError avant d'avoir rien fait. from __future__ import annotations +import codecs import datetime import logging import os @@ -183,31 +184,43 @@ class Execute: executable="/bin/bash", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, - bufsize=1, # Disable buffering for live output - universal_newlines=True, # Handle line breaks correctly + # Octets bruts, SANS tampon. « readline » attendait le saut de + # ligne pour rendre la main : une invite qui n'en porte pas — + # « Continuer ? [o/N] » — restait donc invisible jusqu'à ce que + # la réponse soit déjà tapée. La question s'affichait APRÈS la + # réponse, et l'on répondait à l'aveugle. + bufsize=0, env=my_env, ) sink = getattr(self, "log_sink", None) - while True: - line = process.stdout.readline() - if not line: - break - # La sortie du sous-processus passe par le meme filtre que - # la commande : un outil qui reaffiche ses propres arguments + # Le tube porte des octets, et une lecture peut couper un caractère + # accentué ou un emoji en deux. Le décodeur incrémental garde le + # morceau incomplet en attente au lieu de rendre un « ? ». + decoder = codecs.getincrementaldecoder("utf-8")("replace") + fd = process.stdout.fileno() + # « pending » est la ligne en cours, pas encore terminée ; « shown » + # compte ce qui en a déjà été envoyé au terminal, pour ne jamais + # afficher deux fois le même morceau d'invite quand la ligne finit + # par se terminer. + pending = "" + shown = 0 + + def retenir(ligne): + """Journaliser et retenir une ligne complète, caviardée.""" + nonlocal sink + # La sortie du sous-processus passe par le MÊME filtre que la + # commande : un outil qui réaffiche ses propres arguments # (« set -x », une trace, odoo_bin.sh) y remettrait le secret - # que la ligne 165 venait d'ecarter. - line = redact_secrets(line) - if not quiet: - print(line, end="") + # que l'affichage de la commande venait d'écarter. + clean = redact_secrets(ligne) if sink: # Chaque ligne passe DÉJÀ ici : c'est le seul endroit où # journaliser sans rien changer à ce que le terminal # montre. Une erreur d'écriture ne doit jamais faire # échouer la commande qu'on est en train de suivre. try: - sink.write(line) + sink.write(clean) except Exception: sink = None if ( @@ -216,11 +229,45 @@ class Execute: ): # Remove last \n char output_lines.append( - line.removesuffix("\r\n") + clean.removesuffix("\r\n") .removesuffix("\n") .removesuffix("\r") ) + while True: + chunk = os.read(fd, 65536) + if not chunk: + break + pending += decoder.decode(chunk) + while True: + coupe = pending.find("\n") + if coupe < 0: + break + ligne = pending[: coupe + 1] + pending = pending[coupe + 1 :] + if not quiet: + print(redact_secrets(ligne[shown:]), end="") + shown = 0 + retenir(ligne) + if not quiet: + if len(pending) > shown: + # Le reliquat sans saut de ligne EST l'invite : la + # montrer tout de suite, avant que la commande ne se + # bloque sur la lecture de la réponse. + print(redact_secrets(pending[shown:]), end="") + shown = len(pending) + # Sans vidage explicite, cette invite resterait dans le + # tampon de Python : second endroit où la question se + # perdait, la sortie n'étant vidée qu'au saut de ligne. + sys.stdout.flush() + + pending += decoder.decode(b"", True) + if pending: + if not quiet: + print(redact_secrets(pending[shown:]), end="") + sys.stdout.flush() + retenir(pending) + process.wait() exit_code = process.returncode if process.returncode != 0 and not quiet: