From 3276f4b63fa23a27d6580705b7ed7e7f45cbee9f Mon Sep 17 00:00:00 2001 From: "Tran G. (Revernomad) Khoa" Date: Wed, 8 Apr 2026 23:35:26 +0700 Subject: [PATCH] fix: silent pipeline log and server indexing progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues caused the Logs view to appear blank during a real pipeline run: 1. `from run import step_fetch, step_link` was outside the worker's try/except/finally. An import failure silently killed the thread, leaving _pipeline_done uncalled and the Run button stuck disabled forever. Now wrapped in its own try/except that posts the error to the log and resets the UI. 2. `build_server_index` makes N sequential HTTP requests (one per mod folder's meta.cpp) with no output during the scan. Added an optional `progress_fn(current, total, name)` callback; step_fetch wires it to print progress every 25 folders so the log never goes silent. 3. No immediate feedback after clicking Start — the log was blank until the worker thread started printing. Now posts a "Pipeline started" banner from the main thread before the worker launches. --- CLAUDE.md | 4 ++++ arma_modlist_tools/fetcher.py | 15 +++++++++++++-- gui/app.py | 13 ++++++++++++- gui/locales.py | 2 ++ run.py | 9 +++++++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7ec6cf..7df6cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,10 @@ Pass 2 builds `ok_disk_names` — the set of disk names that already match the s **GUI threading model:** Every network or long-running operation runs in a `threading.Thread(daemon=True)` so the Tkinter event loop is never blocked. The only safe way to update widgets from a background thread is `self.after(0, callback)` — never touch widgets directly from a worker thread. `_poll_log` drains the entire log queue in one `after(80, ...)` tick and does a single batched `CTkTextbox.insert()` call rather than one per log entry, keeping the UI smooth even when `tqdm` emits many rapid updates during downloads. The wizard's "Test Connection" button follows the same pattern: `requests.get` runs in a daemon thread; the result is posted back via `self.after(0, ...)` with widget references captured *before* the thread starts, so stale references cannot update the wrong widgets if the user navigates away mid-request. +**`run_pipeline` worker — import guard:** `from run import step_fetch, step_link` is performed inside its own `try/except` *before* stdout is redirected. If this import fails for any reason the exception is posted to the log via `self.after(0, ...)` and `_pipeline_done` is called so the UI resets cleanly. Previously an import failure would silently kill the worker thread and leave the pipeline button disabled forever. + +**`build_server_index` progress callback:** Accepts an optional `progress_fn(current, total, name)` callback. `step_fetch` in `run.py` uses this to print `Indexing N/M: @FolderName` every 25 folders so the log never goes silent during the server scan phase. The library itself never calls `print` — the caller owns the I/O. + ### GUI localization (`gui/locales.py`) All user-facing strings are centralised in `gui/locales.py`. Two languages are supported: English (`"en"`) and Vietnamese (`"vi"`). diff --git a/arma_modlist_tools/fetcher.py b/arma_modlist_tools/fetcher.py index 5f21092..492c97f 100644 --- a/arma_modlist_tools/fetcher.py +++ b/arma_modlist_tools/fetcher.py @@ -81,7 +81,11 @@ def make_session(auth: tuple[str, str]) -> requests.Session: return s -def build_server_index(base_url: str, auth: tuple[str, str]) -> dict: +def build_server_index( + base_url: str, + auth: tuple[str, str], + progress_fn: "Callable[[int, int, str], None] | None" = None, +) -> dict: """ Scan the root of the file server and build mod lookup maps. @@ -90,6 +94,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict: :param base_url: Root URL of the Caddy file server (trailing slash optional). :param auth: ``(username, password)`` tuple for HTTP Basic Auth. + :param progress_fn: Optional callback called as ``progress_fn(current, total, name)`` + after each folder is processed. Use it to report progress without + coupling the library to ``print`` or any specific I/O sink. :returns: Dict with keys: - ``by_steam_id`` — ``{steam_id: folder_url}`` @@ -100,11 +107,12 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict: root = base_url.rstrip("/") + "/" items = _list_dir(root, session) folders = [it for it in items if it.get("is_dir")] + total = len(folders) by_steam_id: dict[str, str] = {} by_name: dict[str, str] = {} - for folder in folders: + for i, folder in enumerate(folders, 1): name = folder["name"].strip("/") url = _folder_url(root, name) by_name[_normalize_name(name)] = url @@ -118,6 +126,9 @@ def build_server_index(base_url: str, auth: tuple[str, str]) -> dict: except requests.RequestException: pass # meta.cpp missing or unreachable — name-based fallback still works + if progress_fn is not None: + progress_fn(i, total, name) + return {"by_steam_id": by_steam_id, "by_name": by_name, "folders": folders} diff --git a/gui/app.py b/gui/app.py index 9a3b6e5..7486a3c 100644 --- a/gui/app.py +++ b/gui/app.py @@ -143,11 +143,22 @@ class ArmaModManagerApp(ctk.CTk): self._pipeline_running = True self._get_dashboard().set_pipeline_ui(running=True) self.navigate_to("Logs") + # Post an immediate banner so the log is never blank after clicking Start. + _sep = "=" * 50 + self.post_log(f"\n{_sep}\n {t('pipeline.starting')}\n{_sep}\n\n") def worker() -> None: # run.py calls fix_console_encoding() at import time, which needs # the real sys.stdout.buffer. Import it before we redirect stdout. - from run import step_fetch, step_link + try: + from run import step_fetch, step_link + except Exception as _import_err: + self.after(0, lambda: self.post_log( + f"\n✗ Failed to load pipeline: {_import_err}\n" + )) + self.after(0, self._pipeline_done) + return + self._redirect_output() try: from arma_modlist_tools.parser import parse_modlist_html diff --git a/gui/locales.py b/gui/locales.py index 6c873d2..d2531ae 100644 --- a/gui/locales.py +++ b/gui/locales.py @@ -26,6 +26,7 @@ _EN: dict[str, str] = { "nav.settings": "Settings", # ── Pipeline step headers (printed to log) ─────────────────────────────── + "pipeline.starting": "Pipeline started", "pipeline.step1_name": "Parse presets", "pipeline.step2_name": "Compare presets", "pipeline.step3_name": "Download mods", @@ -266,6 +267,7 @@ _VI: dict[str, str] = { "nav.settings": "Cài đặt", # ── Pipeline step headers ──────────────────────────────────────────────── + "pipeline.starting": "Pipeline đã bắt đầu", "pipeline.step1_name": "Phân tích preset", "pipeline.step2_name": "So sánh preset", "pipeline.step3_name": "Tải mod", diff --git a/run.py b/run.py index 60096f6..0825a0a 100644 --- a/run.py +++ b/run.py @@ -91,9 +91,14 @@ def step_fetch(cfg) -> None: for mod in data["mods"]: queue.append((mod, preset_name)) + def _index_progress(current: int, total: int, name: str) -> None: + if current == 1 or current % 25 == 0 or current == total: + print(f" Indexing {current}/{total}: {name}") + print(f" Building server index...") - index = build_server_index(cfg.server_url, cfg.server_auth) - print(f" Indexed {len(index['by_steam_id'])} mods\n") + index = build_server_index(cfg.server_url, cfg.server_auth, progress_fn=_index_progress) + print(f" Indexed {len(index['by_steam_id'])} mods by steam_id, " + f"{len(index['by_name'])} by name\n") session = make_session(cfg.server_auth) resolved = []