From 74b8cac928e909e97600395b22efdb20e445e3a0 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Sun, 10 May 2026 11:55:57 -0500 Subject: [PATCH] chore: Add verbose mode to build-sd-fonts.py (#1923) ## Summary One of the recent font conversion script changes obliterated performance. Add a verbose mode to build-sd-fonts.py to help diagnose problems. Changed the CI process to use verbose mode. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_ --- .github/workflows/release-fonts.yml | 2 +- lib/EpdFont/scripts/build-sd-fonts.py | 89 ++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-fonts.yml b/.github/workflows/release-fonts.yml index 0fcf9874..86bf5fb2 100644 --- a/.github/workflows/release-fonts.yml +++ b/.github/workflows/release-fonts.yml @@ -40,7 +40,7 @@ jobs: echo "metadata=$(python3 -c 'from cpfont_version import FONTS_MANIFEST_VERSION; print(FONTS_MANIFEST_VERSION)')" >> "$GITHUB_OUTPUT" - name: Build SD card fonts - run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean + run: python3 lib/EpdFont/scripts/build-sd-fonts.py --clean --verbose -j 1 - name: Flatten output for release assets run: | diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index 9f29c00d..8f981fbe 100755 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -17,6 +17,12 @@ Usage: # Generate only specific families python3 build-sd-fonts.py --only Literata,IBMPlexMono + + # Stream child process output for debugging + python3 build-sd-fonts.py --verbose + + # Override the per-family timeout (default: 600s) + python3 build-sd-fonts.py --timeout 1200 """ import argparse @@ -25,6 +31,8 @@ import shutil import subprocess import sys import tempfile +import threading +import time import urllib.request from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path @@ -141,7 +149,16 @@ def resolve_font_path(style_spec: dict, family_name: str, style_name: str) -> Pa return resolved -def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]: +def _stream_pipe(pipe, prefix: str, dest: list[str]): + """Read lines from a pipe, print with prefix, and accumulate into dest.""" + for line in pipe: + dest.append(line) + print(f" [{prefix}] {line}", end="", flush=True) + + +def build_family( + family: dict, output_base: Path, verbose: bool = False, timeout: int = 600 +) -> tuple[str, bool, str]: """Build a single font family. Returns (name, success, message).""" name = family["name"] output_dir = output_base / name @@ -185,18 +202,52 @@ def build_family(family: dict, output_base: Path) -> tuple[str, bool, str]: cmd.append("--force-autohint") # Run fontconvert_sdcard.py + start = time.monotonic() try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=600, - ) - if result.returncode != 0: - return name, False, result.stderr.strip() or f"Exit code {result.returncode}" - return name, True, "" - except subprocess.TimeoutExpired: - return name, False, "Timed out after 600s" + if verbose: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + stdout_lines: list[str] = [] + stderr_lines: list[str] = [] + t_out = threading.Thread( + target=_stream_pipe, args=(proc.stdout, name, stdout_lines) + ) + t_err = threading.Thread( + target=_stream_pipe, args=(proc.stderr, f"{name}/err", stderr_lines) + ) + t_out.start() + t_err.start() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + elapsed = time.monotonic() - start + return name, False, f"Timed out after {elapsed:.0f}s" + finally: + t_out.join() + t_err.join() + + if proc.returncode != 0: + err = "".join(stderr_lines).strip() + return name, False, err or f"Exit code {proc.returncode}" + return name, True, "" + else: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + if result.returncode != 0: + return name, False, result.stderr.strip() or f"Exit code {result.returncode}" + return name, True, "" + except subprocess.TimeoutExpired as e: + elapsed = time.monotonic() - start + tail = "" + captured = getattr(e, "stderr", None) or getattr(e, "stdout", None) + if captured: + lines = captured.strip().splitlines() + tail = "\n Last output:\n" + "\n".join(f" | {l}" for l in lines[-20:]) + return name, False, f"Timed out after {elapsed:.0f}s{tail}" except Exception as e: return name, False, str(e) @@ -252,6 +303,14 @@ def main(): help="Max parallel jobs (default: number of families)" ) parser.add_argument("--clean", action="store_true", help="Clean output directory before building") + parser.add_argument( + "--verbose", "-v", action="store_true", + help="Stream child process output in real time (useful for debugging timeouts)" + ) + parser.add_argument( + "--timeout", type=int, default=600, + help="Per-family timeout in seconds (default: 600)" + ) args = parser.parse_args() if args.manifest and not args.base_url: @@ -303,12 +362,14 @@ def main(): # Build phase (parallel) max_workers = args.jobs or len(families) - print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs) ===\n") + verbose = args.verbose + timeout = args.timeout + print(f"\n=== Building {len(families)} families ({max_workers} parallel jobs, timeout {timeout}s) ===\n") failed = [] with ProcessPoolExecutor(max_workers=max_workers) as executor: futures = { - executor.submit(build_family, family, output_base): family["name"] + executor.submit(build_family, family, output_base, verbose, timeout): family["name"] for family in families } for future in as_completed(futures):