Comprovació d'updates a l'inici opcional + timeouts de xarxa configurables

- Opció marcable «Comprova actualitzacions a l'inici» al menú; persisteix a
  settings.json i llança la comprovació diferida en obrir la finestra.
- Tolerància a repos offline/inalcanzables: low-speed abort + techo dur de
  temps a les operacions git de xarxa (fetch/clone), evitant cuelgues.
- Timeouts exposats a settings.json (git_fetch_timeout, git_clone_timeout,
  http_timeout, git_stall_limit, git_stall_time) via NetConfig, propagats
  UI -> workers -> gitops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 08:52:19 +02:00
parent 90b7bb5fb1
commit c51b7b74ed
4 changed files with 185 additions and 31 deletions
+112 -24
View File
@@ -14,6 +14,7 @@ import subprocess
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from .config import Game
@@ -22,7 +23,26 @@ from .paths import game_dir, metadata_dir, repo_dir
LogFn = Callable[[str], None]
_HTTP_TIMEOUT = 15
# --- Tolerancia a repos offline / inalcanzables ----------------------------
# Dos mecanismos complementarios para que git no se cuelgue:
# 1) low-speed abort: si una transferencia baja de `stall_limit` bytes/s durante
# `stall_time` segundos, git la aborta (transferencias que se estancan a media
# descarga).
# 2) techo duro: timeout en subprocess.run que mata git pase lo que pase (cubre
# cuelgues de conexión TCP/DNS cuando el host está offline, donde aún no fluyen
# bytes y el low-speed no llega a dispararse).
# Los valores son configurables desde settings.json (ver settings.Settings).
@dataclass(frozen=True)
class NetConfig:
fetch_timeout: float = 60 # techo para fetch / comprobar update (operación ligera)
clone_timeout: float = 900 # techo para clone (puede traer un repo grande)
http_timeout: float = 15 # techo para la API de Gitea (urllib)
stall_limit: int = 1000 # bytes/s por debajo de los cuales se considera estancado
stall_time: int = 20 # segundos por debajo del límite -> abortar
DEFAULT_NET = NetConfig()
def _noop(_: str) -> None:
@@ -36,13 +56,33 @@ def _auth_args(token: str) -> list[str]:
return ["-c", f"http.extraHeader=Authorization: token {token}"]
def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla.
def _net_args(net: NetConfig | None) -> list[str]:
"""Args -c para abortar transferencias estancadas (solo afectan al transporte http)."""
if net is None:
return []
return [
"-c",
f"http.lowSpeedLimit={net.stall_limit}",
"-c",
f"http.lowSpeedTime={net.stall_time}",
]
def _run_git(
args: list[str],
cwd: Path | None,
log: LogFn,
token: str = "",
timeout: float | None = None,
net: NetConfig | None = None,
) -> str:
"""Ejecuta git capturando salida; lanza RuntimeError si falla o agota `timeout`.
Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch
de repos privados) y la redacta en el log para no filtrarla.
de repos privados) y la redacta en el log para no filtrarla. Si se pasa `net`
añade el low-speed abort; `timeout` impone un techo duro (mata git si se cuelga).
"""
cmd = ["git", *_auth_args(token), *args]
cmd = ["git", *_auth_args(token), *_net_args(net), *args]
def emit(line: str) -> None:
log(line.replace(token, "***") if token else line)
@@ -51,13 +91,23 @@ def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") ->
emit("$ " + " ".join(["git", *args]))
# GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido
# en vez de colgarse esperando credenciales (no hay terminal en la GUI).
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
try:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
timeout=timeout,
)
except subprocess.TimeoutExpired:
emit(
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s): "
"el repositori no respon o no és accessible."
)
raise RuntimeError(
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s)"
) from None
if proc.stdout:
emit(proc.stdout.rstrip())
if proc.stderr:
@@ -72,7 +122,13 @@ def is_installed(root: Path, game: Game) -> bool:
return (repo_dir(root, game.id) / ".git").exists()
def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> bool:
def check_update(
root: Path,
game: Game,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> bool:
"""Hace fetch y devuelve True si el clone local está por detrás del remoto.
No modifica el árbol de trabajo: solo cuenta los commits de origin/<rama> que no
@@ -81,7 +137,14 @@ def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") ->
repo = repo_dir(root, game.id)
if not (repo / ".git").exists():
return False
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
_run_git(
["fetch", "origin", "--prune"],
repo,
log,
token=token,
timeout=net.fetch_timeout,
net=net,
)
target = (
load_meta(root, game.id).default_branch
or _detect_origin_head(repo, log)
@@ -106,7 +169,13 @@ def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
shutil.rmtree(target, ignore_errors=True)
def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> GameMeta:
def download(
root: Path,
game: Game,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> GameMeta:
"""Clona (si no existe) o trae el remoto forzado (descartando cambios locales).
Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve.
@@ -114,11 +183,18 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam
repo = repo_dir(root, game.id)
repo.parent.mkdir(parents=True, exist_ok=True)
branch = _fetch_default_branch(game, log, token)
branch = _fetch_default_branch(game, log, token, net)
if (repo / ".git").exists():
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
_run_git(
["fetch", "origin", "--prune"],
repo,
log,
token=token,
timeout=net.fetch_timeout,
net=net,
)
target = branch or _detect_origin_head(repo, log) or "HEAD"
_run_git(["reset", "--hard", f"origin/{target}"], repo, log)
_run_git(["clean", "-fd"], repo, log)
@@ -126,9 +202,16 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam
log(f"Clonant {game.name}")
if repo.exists(): # carpeta a medias sin .git: limpiarla
shutil.rmtree(repo, ignore_errors=True)
_run_git(["clone", game.clone_url, str(repo)], None, log, token=token)
_run_git(
["clone", game.clone_url, str(repo)],
None,
log,
token=token,
timeout=net.clone_timeout,
net=net,
)
return refresh_metadata(root, game, branch, log, token)
return refresh_metadata(root, game, branch, log, token, net)
def refresh_metadata(
@@ -137,13 +220,14 @@ def refresh_metadata(
branch: str | None = None,
log: LogFn = _noop,
token: str = "",
net: NetConfig = DEFAULT_NET,
) -> GameMeta:
"""Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea."""
repo = repo_dir(root, game.id)
meta = load_meta(root, game.id)
# Descripción + rama por defecto desde la API de Gitea (best-effort).
api = _fetch_gitea_info(game, log, token)
api = _fetch_gitea_info(game, log, token, net)
if api is not None:
meta.description = api.get("description", meta.description) or meta.description
meta.default_branch = api.get("default_branch", meta.default_branch)
@@ -194,7 +278,9 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str:
return out.splitlines()[0] if out else ""
def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
def _fetch_gitea_info(
game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET
) -> dict | None:
if not game.info_url:
return None
headers = {"Accept": "application/json"}
@@ -202,15 +288,17 @@ def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
headers["Authorization"] = f"token {token}"
try:
req = urllib.request.Request(game.info_url, headers=headers)
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
with urllib.request.urlopen(req, timeout=net.http_timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc:
log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}")
return None
def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None:
info = _fetch_gitea_info(game, log, token)
def _fetch_default_branch(
game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET
) -> str | None:
info = _fetch_gitea_info(game, log, token, net)
if info:
return info.get("default_branch")
return None