[education/kstars] tools: tools: add distcc_build.py, a distcc-offload build wrapper

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit a78a1b9a855907f29fb0add826590f1f8a8b68eb by Jasem Mutlaq.
Committed on 04/08/2026 at 02:59.
Pushed by mutlaqja into branch 'master'.

tools: add distcc_build.py, a distcc-offload build wrapper

Wraps two things people otherwise get wrong by hand when offloading a
ninja/make build to a remote distcc host over SSH: DISTCC_HOSTS needs the
"@" prefix for SSH transport (silently falls back to a plain TCP connection
to distccd otherwise), and -j needs to be sized from the remote host's core
count, not the local one, or most of the remote machine's parallelism goes
unused. Also sets CCACHE_PREFIX=distcc, which is required for this repo
specifically -- KStars' own CMakeLists.txt sets RULE_LAUNCH_COMPILE=ccache
directly, which bakes a bare "ccache <compiler>" into every generated ninja
rule and silently overrides CMAKE_CXX_COMPILER_LAUNCHER regardless of how
the build was configured; CCACHE_PREFIX is ccache's own hook for chaining
to distcc on a cache miss, and works regardless of that override.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

A  +194  -0    tools/distcc_build.py

https://invent.kde.org/education/kstars/-/commit/a78a1b9a855907f29fb0add826590f1f8a8b68eb

diff --git a/tools/distcc_build.py b/tools/distcc_build.py
new file mode 100755
index 0000000000..3d8c00e45d
--- /dev/null
+++ b/tools/distcc_build.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+Build KStars (or any ninja/make project) offloading compilation to a remote
+distcc host over SSH.
+
+This wraps two things people otherwise get wrong by hand:
+
+  1. DISTCC_HOSTS must be set to "@<host>/<slots>" (the leading "@" means
+     "dispatch via ssh"). Omitting the "@" silently falls back to a plain
+     TCP connection to distccd on port 3632, which usually isn't listening.
+  2. The parallelism distcc actually achieves is capped by whatever -j value
+     you pass to ninja/make -- distcc itself does not create parallelism, it
+     only lets each already-parallel job land on a different machine. Ninja's
+     default -j is derived from the *local* CPU count (local-cores + 2), so
+     running `ninja` with no -j on a 4-core laptop offloading to a 16+ core
+     remote box still only ever has ~6 compiles in flight -- the remote
+     machine's extra cores just sit idle. This script fixes that by sizing
+     -j from the remote host's actual `nproc`, not the local one.
+
+Assumes distcc (and, for the ssh-pump mode used here, an sshd + distccd
+reachable via `ssh <host> distccd --inetd`) is already installed on both the
+local machine and the remote host -- this script does not install anything,
+it only wires up the environment and job count correctly for a single build.
+
+Usage:
+    tools/distcc_build.py --host workshop
+    tools/distcc_build.py --host 192.168.1.50 --build-dir ../build kstars/kstars
+    tools/distcc_build.py --host workshop --jobs 24 --slots 20 --dry-run
+"""
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_BUILD_DIR = REPO_ROOT / "build"
+
+
+def parse_args():
+    p = argparse.ArgumentParser(
+        description="Build with compilation offloaded to a remote distcc host over SSH.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog=__doc__.split("Usage:")[1] and "Usage:" + __doc__.split("Usage:")[1],
+    )
+    p.add_argument("--host", "-H", required=True,
+                   help="Remote hostname or IP with distcc/sshd already running.")
+    p.add_argument("--build-dir", "-C", type=Path, default=DEFAULT_BUILD_DIR,
+                   help=f"Build directory containing build.ninja or Makefile (default: {DEFAULT_BUILD_DIR}).")
+    p.add_argument("--slots", type=int, default=None,
+                   help="Max concurrent distcc jobs on the remote host (DISTCC_HOSTS '@host/N'). "
+                        "Default: the remote host's own `nproc`.")
+    p.add_argument("--jobs", "-j", type=int, default=None,
+                   help="Parallelism passed to ninja/make -j. Default: same as --slots -- this is "
+                        "the number that actually controls how many compiles run at once; distcc "
+                        "only decides *where* each one runs.")
+    p.add_argument("--local-jobs", type=int, default=0,
+                   help="Also allow this many jobs to compile on the local machine concurrently "
+                        "with the remote ones (adds a bare 'localhost/N' entry to DISTCC_HOSTS). "
+                        "Default 0: fully offload, no local compiles.")
+    p.add_argument("--ssh-user", default=None,
+                   help="SSH user for the remote host, if not the same as the local user "
+                        "(passed as '@user@host/slots' in DISTCC_HOSTS).")
+    p.add_argument("--skip-preflight", action="store_true",
+                   help="Skip the SSH reachability/distccd check before building.")
+    p.add_argument("--verbose", action="store_true",
+                   help="Set DISTCC_VERBOSE=1 so distcc logs where each job actually ran -- "
+                        "useful for confirming jobs are landing on the remote host at all.")
+    p.add_argument("--dry-run", action="store_true",
+                   help="Print the environment and command that would run, without building.")
+    p.add_argument("targets", nargs="*",
+                   help="Specific ninja/make targets to build (default: build everything).")
+    return p.parse_args()
+
+
+def ssh_target(args):
+    return f"{args.ssh_user}@{args.host}" if args.ssh_user else args.host
+
+
+def remote_nproc(args):
+    """SSH to the host and return its core count, or None if unreachable."""
+    try:
+        result = subprocess.run(
+            ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", ssh_target(args), "nproc"],
+            capture_output=True, text=True, timeout=15,
+        )
+    except (subprocess.SubprocessError, OSError) as e:
+        print(f"[distcc_build] ERROR: could not SSH to {args.host}: {e}", file=sys.stderr)
+        return None
+    if result.returncode != 0:
+        print(f"[distcc_build] ERROR: `ssh {ssh_target(args)} nproc` failed:\n{result.stderr}",
+              file=sys.stderr)
+        return None
+    try:
+        return int(result.stdout.strip())
+    except ValueError:
+        print(f"[distcc_build] ERROR: unexpected `nproc` output from {args.host}: {result.stdout!r}",
+              file=sys.stderr)
+        return None
+
+
+def preflight(args):
+    """Confirm the remote host is reachable and has distccd installed."""
+    print(f"[distcc_build] Checking {args.host} over SSH...")
+    try:
+        result = subprocess.run(
+            ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", ssh_target(args),
+             "command -v distccd"],
+            capture_output=True, text=True, timeout=15,
+        )
+    except (subprocess.SubprocessError, OSError) as e:
+        sys.exit(f"[distcc_build] ERROR: could not SSH to {args.host}: {e}")
+    if result.returncode != 0 or not result.stdout.strip():
+        sys.exit(
+            f"[distcc_build] ERROR: distccd not found on {args.host} (or SSH key auth isn't set "
+            f"up). This script assumes distcc is already installed on both machines -- install "
+            f"it there first, or pass --skip-preflight if you know better."
+        )
+    print(f"[distcc_build] OK: distccd found at {result.stdout.strip()}")
+
+
+def detect_build_tool(build_dir: Path):
+    if (build_dir / "build.ninja").is_file():
+        if not shutil.which("ninja"):
+            sys.exit("[distcc_build] ERROR: build.ninja present but `ninja` is not on PATH.")
+        return "ninja"
+    if (build_dir / "Makefile").is_file():
+        return "make"
+    sys.exit(f"[distcc_build] ERROR: no build.ninja or Makefile in {build_dir}. "
+             f"Configure the build first (cmake -B {build_dir} ...).")
+
+
+def main():
+    args = parse_args()
+
+    if not args.build_dir.is_dir():
+        sys.exit(f"[distcc_build] ERROR: build dir {args.build_dir} does not exist.")
+
+    slots = args.slots
+    if slots is None:
+        if args.skip_preflight:
+            sys.exit("[distcc_build] ERROR: --slots is required when --skip-preflight is set "
+                     "(otherwise there's no way to size it without asking the remote host).")
+        n = remote_nproc(args)
+        if n is None:
+            sys.exit(f"[distcc_build] ERROR: could not determine {args.host}'s core count. "
+                     f"Pass --slots explicitly to skip this check.")
+        slots = n
+        print(f"[distcc_build] {args.host} reports {slots} cores -- using that as the distcc slot count.")
+
+    jobs = args.jobs if args.jobs is not None else slots
+
+    if not args.skip_preflight:
+        preflight(args)
+
+    host_spec = f"{ssh_target(args)}/{slots}"
+    distcc_hosts = f"@{host_spec}"
+    if args.local_jobs > 0:
+        distcc_hosts = f"localhost/{args.local_jobs} {distcc_hosts}"
+
+    build_tool = detect_build_tool(args.build_dir)
+    cmd = [build_tool, f"-j{jobs}"] + args.targets
+
+    env = os.environ.copy()
+    env["DISTCC_HOSTS"] = distcc_hosts
+    # KStars' own CMakeLists.txt sets RULE_LAUNCH_COMPILE=ccache directly (a global
+    # CMake property), which bakes a bare "ccache <compiler>" into every generated
+    # ninja rule and completely overrides whatever CMAKE_CXX_COMPILER_LAUNCHER was
+    # configured with -- distcc never appears in the compile command, so DISTCC_HOSTS
+    # alone has nothing to act on and every compile silently runs locally. CCACHE_PREFIX
+    # is ccache's own hook for exactly this: on a cache miss it runs
+    # "$CCACHE_PREFIX <real-compiler> ..." instead of the compiler directly, which works
+    # regardless of how the launcher was wired into the ninja rule. Verified 2026-08-04:
+    # without this, `ssh <host> 'pgrep -ac distccd'` stayed at 0 for the whole build while
+    # cc1plus pegged the local CPU instead.
+    env["CCACHE_PREFIX"] = "distcc"
+    if args.verbose:
+        env["DISTCC_VERBOSE"] = "1"
+
+    print(f"[distcc_build] DISTCC_HOSTS={distcc_hosts}")
+    print(f"[distcc_build] CCACHE_PREFIX={env['CCACHE_PREFIX']}")
+    print(f"[distcc_build] Running: {' '.join(cmd)}  (cwd={args.build_dir})")
+
+    if args.dry_run:
+        print("[distcc_build] --dry-run: not actually building.")
+        return 0
+
+    result = subprocess.run(cmd, cwd=args.build_dir, env=env)
+    return result.returncode
+
+
+if __name__ == "__main__":
+    sys.exit(main())
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.