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.error
import urllib.request import urllib.request
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .config import Game from .config import Game
@@ -22,7 +23,26 @@ from .paths import game_dir, metadata_dir, repo_dir
LogFn = Callable[[str], None] 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: def _noop(_: str) -> None:
@@ -36,13 +56,33 @@ def _auth_args(token: str) -> list[str]:
return ["-c", f"http.extraHeader=Authorization: token {token}"] return ["-c", f"http.extraHeader=Authorization: token {token}"]
def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str: def _net_args(net: NetConfig | None) -> list[str]:
"""Ejecuta git capturando salida; lanza RuntimeError si falla. """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 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: def emit(line: str) -> None:
log(line.replace(token, "***") if token else line) 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])) emit("$ " + " ".join(["git", *args]))
# GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido # 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). # en vez de colgarse esperando credenciales (no hay terminal en la GUI).
proc = subprocess.run( try:
cmd, proc = subprocess.run(
cwd=str(cwd) if cwd else None, cmd,
capture_output=True, cwd=str(cwd) if cwd else None,
text=True, capture_output=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, 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: if proc.stdout:
emit(proc.stdout.rstrip()) emit(proc.stdout.rstrip())
if proc.stderr: if proc.stderr:
@@ -72,7 +122,13 @@ def is_installed(root: Path, game: Game) -> bool:
return (repo_dir(root, game.id) / ".git").exists() 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. """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 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) repo = repo_dir(root, game.id)
if not (repo / ".git").exists(): if not (repo / ".git").exists():
return False 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 = ( target = (
load_meta(root, game.id).default_branch load_meta(root, game.id).default_branch
or _detect_origin_head(repo, log) 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) 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). """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. 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 = repo_dir(root, game.id)
repo.parent.mkdir(parents=True, exist_ok=True) 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(): if (repo / ".git").exists():
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…") 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" target = branch or _detect_origin_head(repo, log) or "HEAD"
_run_git(["reset", "--hard", f"origin/{target}"], repo, log) _run_git(["reset", "--hard", f"origin/{target}"], repo, log)
_run_git(["clean", "-fd"], 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}") log(f"Clonant {game.name}")
if repo.exists(): # carpeta a medias sin .git: limpiarla if repo.exists(): # carpeta a medias sin .git: limpiarla
shutil.rmtree(repo, ignore_errors=True) 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( def refresh_metadata(
@@ -137,13 +220,14 @@ def refresh_metadata(
branch: str | None = None, branch: str | None = None,
log: LogFn = _noop, log: LogFn = _noop,
token: str = "", token: str = "",
net: NetConfig = DEFAULT_NET,
) -> GameMeta: ) -> GameMeta:
"""Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea.""" """Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea."""
repo = repo_dir(root, game.id) repo = repo_dir(root, game.id)
meta = load_meta(root, game.id) meta = load_meta(root, game.id)
# Descripción + rama por defecto desde la API de Gitea (best-effort). # 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: if api is not None:
meta.description = api.get("description", meta.description) or meta.description meta.description = api.get("description", meta.description) or meta.description
meta.default_branch = api.get("default_branch", meta.default_branch) 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 "" 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: if not game.info_url:
return None return None
headers = {"Accept": "application/json"} 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}" headers["Authorization"] = f"token {token}"
try: try:
req = urllib.request.Request(game.info_url, headers=headers) 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")) return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc: 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}") log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}")
return None return None
def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None: def _fetch_default_branch(
info = _fetch_gitea_info(game, log, token) game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET
) -> str | None:
info = _fetch_gitea_info(game, log, token, net)
if info: if info:
return info.get("default_branch") return info.get("default_branch")
return None return None
+13
View File
@@ -16,6 +16,13 @@ class Settings:
hide_not_downloaded: bool = False hide_not_downloaded: bool = False
updates_pending: list[str] = field(default_factory=list) # ids con update pendiente updates_pending: list[str] = field(default_factory=list) # ids con update pendiente
gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona) gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona)
check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar
# Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s).
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
git_clone_timeout: int = 900 # techo para clone (repo grande)
http_timeout: int = 15 # techo para la API de Gitea
git_stall_limit: int = 1000 # bytes/s: por debajo se considera transferencia estancada
git_stall_time: int = 20 # segundos estancado antes de abortar
def settings_path() -> Path: def settings_path() -> Path:
@@ -34,6 +41,12 @@ def load_settings() -> Settings:
hide_not_downloaded=bool(data.get("hide_not_downloaded", False)), hide_not_downloaded=bool(data.get("hide_not_downloaded", False)),
updates_pending=list(data.get("updates_pending", [])), updates_pending=list(data.get("updates_pending", [])),
gitea_token=str(data.get("gitea_token", "")), gitea_token=str(data.get("gitea_token", "")),
check_updates_on_start=bool(data.get("check_updates_on_start", False)),
git_fetch_timeout=int(data.get("git_fetch_timeout", 60)),
git_clone_timeout=int(data.get("git_clone_timeout", 900)),
http_timeout=int(data.get("http_timeout", 15)),
git_stall_limit=int(data.get("git_stall_limit", 1000)),
git_stall_time=int(data.get("git_stall_time", 20)),
) )
+34 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QThreadPool, Qt from PySide6.QtCore import QThreadPool, Qt, QTimer
from PySide6.QtGui import QAction from PySide6.QtGui import QAction
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QInputDialog, QInputDialog,
@@ -84,6 +84,22 @@ class MainWindow(QMainWindow):
self.rows[game_id].set_update_available(True) self.rows[game_id].set_update_available(True)
self._apply_filter() self._apply_filter()
# Comprobación automática de updates al iniciar (si está activada). Se difiere
# con un timer 0 para que la ventana se muestre antes de lanzar el worker.
if self.settings.check_updates_on_start:
QTimer.singleShot(0, self._check_updates)
def _net_config(self) -> gitops.NetConfig:
"""Construye la config de red/timeouts a partir de las preferencias guardadas."""
s = self.settings
return gitops.NetConfig(
fetch_timeout=s.git_fetch_timeout,
clone_timeout=s.git_clone_timeout,
http_timeout=s.http_timeout,
stall_limit=s.git_stall_limit,
stall_time=s.git_stall_time,
)
# --------------------------------------------------------------- menú # --------------------------------------------------------------- menú
def _build_menu(self) -> None: def _build_menu(self) -> None:
@@ -98,6 +114,13 @@ class MainWindow(QMainWindow):
self.action_check.triggered.connect(self._check_updates) self.action_check.triggered.connect(self._check_updates)
menu.addAction(self.action_check) menu.addAction(self.action_check)
self.action_check_on_start = QAction(
"Comprova actualitzacions a l'inici", self, checkable=True
)
self.action_check_on_start.setChecked(self.settings.check_updates_on_start)
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
menu.addAction(self.action_check_on_start)
menu.addSeparator() menu.addSeparator()
self.action_delete = QAction("Esborra un joc", self, checkable=True) self.action_delete = QAction("Esborra un joc", self, checkable=True)
self.action_delete.toggled.connect(self._set_delete_mode) self.action_delete.toggled.connect(self._set_delete_mode)
@@ -129,6 +152,10 @@ class MainWindow(QMainWindow):
save_settings(self.settings) save_settings(self.settings)
self._apply_filter() self._apply_filter()
def _on_toggle_check_on_start(self, checked: bool) -> None:
self.settings.check_updates_on_start = checked
save_settings(self.settings)
def _apply_filter(self) -> None: def _apply_filter(self) -> None:
hide = self.action_hide.isChecked() hide = self.action_hide.isChecked()
for row in self.rows.values(): for row in self.rows.values():
@@ -139,7 +166,9 @@ class MainWindow(QMainWindow):
def _check_updates(self) -> None: def _check_updates(self) -> None:
self.action_check.setEnabled(False) self.action_check.setEnabled(False)
self._log("=== Comprovant actualitzacions ===") self._log("=== Comprovant actualitzacions ===")
worker = CheckUpdatesWorker(self.root, self.config.games, self.settings.gitea_token) worker = CheckUpdatesWorker(
self.root, self.config.games, self.settings.gitea_token, self._net_config()
)
worker.signals.log.connect(self._log) worker.signals.log.connect(self._log)
worker.signals.result.connect(self._mark_update) worker.signals.result.connect(self._mark_update)
worker.signals.finished.connect(self._check_done) worker.signals.finished.connect(self._check_done)
@@ -208,7 +237,9 @@ class MainWindow(QMainWindow):
row.set_busy(True, "Descarregant…") row.set_busy(True, "Descarregant…")
self._log(f"=== Descàrrega: {game.name} ===") self._log(f"=== Descàrrega: {game.name} ===")
worker = DownloadWorker(self.root, game, self.settings.gitea_token) worker = DownloadWorker(
self.root, game, self.settings.gitea_token, self._net_config()
)
worker.signals.log.connect(self._log) worker.signals.log.connect(self._log)
worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g)) worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g))
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg)) worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
+26 -4
View File
@@ -25,17 +25,28 @@ class _Signals(QObject):
class DownloadWorker(QRunnable): class DownloadWorker(QRunnable):
"""Clona o actualiza (forzado) y refresca la metadata de un juego.""" """Clona o actualiza (forzado) y refresca la metadata de un juego."""
def __init__(self, root: Path, game: Game, token: str = "") -> None: def __init__(
self,
root: Path,
game: Game,
token: str = "",
net: gitops.NetConfig = gitops.DEFAULT_NET,
) -> None:
super().__init__() super().__init__()
self.root = root self.root = root
self.game = game self.game = game
self.token = token self.token = token
self.net = net
self.signals = _Signals() self.signals = _Signals()
def run(self) -> None: # noqa: D401 - API de QRunnable def run(self) -> None: # noqa: D401 - API de QRunnable
try: try:
meta: GameMeta = gitops.download( meta: GameMeta = gitops.download(
self.root, self.game, log=self.signals.log.emit, token=self.token self.root,
self.game,
log=self.signals.log.emit,
token=self.token,
net=self.net,
) )
except Exception as exc: # noqa: BLE001 - reportar a la UI except Exception as exc: # noqa: BLE001 - reportar a la UI
self.signals.error.emit(str(exc)) self.signals.error.emit(str(exc))
@@ -67,11 +78,18 @@ class CheckUpdatesWorker(QRunnable):
Emite `result(game_id, has_update)` por cada juego instalado y `finished` al final. Emite `result(game_id, has_update)` por cada juego instalado y `finished` al final.
""" """
def __init__(self, root: Path, games: list[Game], token: str = "") -> None: def __init__(
self,
root: Path,
games: list[Game],
token: str = "",
net: gitops.NetConfig = gitops.DEFAULT_NET,
) -> None:
super().__init__() super().__init__()
self.root = root self.root = root
self.games = games self.games = games
self.token = token self.token = token
self.net = net
self.signals = _Signals() self.signals = _Signals()
def run(self) -> None: # noqa: D401 - API de QRunnable def run(self) -> None: # noqa: D401 - API de QRunnable
@@ -81,7 +99,11 @@ class CheckUpdatesWorker(QRunnable):
continue continue
try: try:
has_update = gitops.check_update( has_update = gitops.check_update(
self.root, game, log=self.signals.log.emit, token=self.token self.root,
game,
log=self.signals.log.emit,
token=self.token,
net=self.net,
) )
except Exception as exc: # noqa: BLE001 - no abortar el resto except Exception as exc: # noqa: BLE001 - no abortar el resto
self.signals.log.emit(f"check {game.id}: {exc}") self.signals.log.emit(f"check {game.id}: {exc}")