fix(profiles): _run distinguishes TimeoutExpired from could-not-run (ref #893)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 08:18:03 +02:00
parent 649a544669
commit c8e23843ed
3 changed files with 46 additions and 7 deletions

View File

@ -32,6 +32,15 @@ class ActuationError(Exception):
"""Une commande d'actionnement a échoué (rc non-nul) ou n'a pas pu tourner (rc=None)."""
# Sentinel returncode for a command that RAN but did not return within the
# deadline (subprocess.TimeoutExpired), as opposed to one that could not run at
# all (OSError -> rc is None). The actuator fast-fails only on the latter; a
# timeout on lxc-start/lxc-stop is deferred to wait_state (observed state
# decides). 1000 is outside every real returncode (exit codes 0-255, signal
# codes negative), so `rc != 0` still reads it as "not success".
TIMED_OUT = 1000
def _must(run, argv: list[str]) -> None:
rc, out = run(argv)
if rc != 0:

View File

@ -21,6 +21,7 @@ import sys
from pathlib import Path
from . import apply, export
from .actuate import TIMED_OUT
from .audit import AUDIT_LOG
from .diff import ProtectedViolation, plan_changes
from .export import format_apt, format_json, format_pkglist, resolve_packages
@ -296,16 +297,24 @@ def _cmd_scan(args) -> int:
return 0
_RUN_TIMEOUT_S = 30 # was 15: --no-block native commands return at once; the
# extra headroom is for lxc-start/lxc-stop (no --no-block flag) so the container
# CLI usually finishes before we give up and defer to wait_state.
def _run(argv: list[str]) -> tuple[int | None, str]:
"""rc=None signale que la commande n'a PAS pu s'exécuter (OSError, timeout) —
à distinguer d'un rc non-nul qui est une réponse authentique de la commande.
Même contrat que observe._run_cmd : un (1, "") fabriqué ici serait
indistinguable d'une vraie réponse "non" de la commande (voir _cmd_scan,
qui a besoin de cette distinction pour ne pas écrire un manifeste faux)."""
"""rc=None = la commande n'a PAS pu s'exécuter (OSError) — jamais un faux
succès. rc=TIMED_OUT = elle a bien démarré mais n'a pas répondu dans le
délai (subprocess.TimeoutExpired) : pour lxc-start/lxc-stop (sans --no-block)
ce n'est PAS un échec, c'est wait_state qui tranche sur l'état observé.
Même contrat de lecture-seule côté observe._run_cmd (qui, lui, garde
timeout->None : une sonde qui traîne reste indéterminée)."""
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=15)
p = subprocess.run(argv, capture_output=True, text=True, timeout=_RUN_TIMEOUT_S)
return p.returncode, p.stdout
except (OSError, subprocess.SubprocessError):
except subprocess.TimeoutExpired:
return TIMED_OUT, ""
except OSError:
return None, ""

View File

@ -445,3 +445,24 @@ def test_apply_error_maps_to_rc3_not_traceback(tmp_path, monkeypatch):
raise apply_mod.ApplyError("x est protégé — un STOP est refusé")
monkeypatch.setattr(apply_mod, "apply_plan", boom)
assert cli.main(["--root", str(root), "apply", "--yes"]) == 3
def test_run_distinguishes_timeout_from_could_not_run(monkeypatch):
# cli._run must return the actuate.TIMED_OUT sentinel on a subprocess
# timeout (the command RAN, still working) and None only on OSError
# (the command could not run at all) — the actuator relies on this
# distinction to fast-fail only on genuine could-not-run.
import subprocess
import api.cli as cli
from api.actuate import TIMED_OUT
def raise_timeout(*a, **k):
raise subprocess.TimeoutExpired(cmd="x", timeout=1)
monkeypatch.setattr(cli.subprocess, "run", raise_timeout)
assert cli._run(["whatever"]) == (TIMED_OUT, "")
def raise_oserror(*a, **k):
raise OSError("no such binary")
monkeypatch.setattr(cli.subprocess, "run", raise_oserror)
assert cli._run(["whatever"]) == (None, "")