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):