diff --git a/.claude/skills/mama-style-review/SKILL.md b/.claude/skills/mama-style-review/SKILL.md index 941af00..812c9bd 100644 --- a/.claude/skills/mama-style-review/SKILL.md +++ b/.claude/skills/mama-style-review/SKILL.md @@ -170,6 +170,17 @@ grep -rn 'Color\.YELLOW' mama/ | grep -v 'utils/system.py' - A new ~3-line helper duplicating something in util.py is a finding. ### Code shape +- **Lean and mean: cache repeated/invariant calculations.** Never compute the + same value twice. Flag a `sum()` / probe / `.encode()` / `stat` / glob + evaluated twice in one expression (classically in a comprehension's filter + AND the value it builds), or recomputed every loop iteration when it is + loop-invariant - hoist it out and compute once. Process-constant results + (terminal encoding, cpu count, a compiled regex) get memoized a single time, + not re-derived per call. The fix is usually both faster AND fewer lines. + ```bash + # smell: same call in the filter and the body of one comprehension + grep -rn 'for .* if .*\b\(sum\|len\|glob\|encode\|stat\)\b' mama/ tests/ + ``` - Long functions are a smell. If you find yourself adding a third large responsibility to a function (e.g. another inline artifactory probe inside `_load`), extract a helper. diff --git a/.gitignore b/.gitignore index ea2a216..b995e42 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,13 @@ mama.cmake packages/ bin/ + +# Local CMake config-overhead benchmark (clones + build dirs) +bench/repos/ +bench/_out/ + +# pytest tmp_path artifacts (repo-local, see tests/conftest.py) +.pytest_tmp/ + +# Claude Code agent worktrees (transient) +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index b776517..1dbe05c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,13 @@ keep biting future-Claude. Update as the codebase teaches new lessons. - **Yellow output goes through `warning(text)`** (from `mama.utils.system`), not `console(text, color=Color.YELLOW)`. The helper exists so warnings have a single chokepoint and a consistent shape. +- **Lean and mean: cache repeated/invariant calculations.** Never compute the + same value twice. A `sum()` / probe / `.encode()` / `stat` evaluated twice in + one expression (e.g. in a filter AND the value it builds), or recomputed per + loop iteration when it's loop-invariant, is a finding - hoist it out and + compute once. Results that are constant for the whole process (terminal + encoding, cpu count, a compiled regex) get memoized a single time, not + re-derived per call. Less work and less code, same result. ### Examples diff --git a/README.md b/README.md index 3d7b3ef..b7cc7d3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,46 @@ # Mama Build Tool -Mama - A modular C++ build tool even your mama can use - -The main goal of this project is to provide extremely convenient in-source builds -for cross platform projects. Building is as simple as `mama build windows` - no ceremony~! - -CMake projects with trivial configurations and no dependencies can be handled -automatically by Mama. This makes header-only libraries or stand-alone C libraries -extremely easy to link. - -Adding projects with already configured `mamafile.py` is trivial and allows you to manage -large scale projects in a modular way. Dependencies are added and configured through mamafiles. - -Each mama build target exports CMake `${ProjectName}_INCLUDES` and `${ProjectName}_LIBS`. All exports -are gathered in correct linker order inside `MAMA_INCLUDES` and `MAMA_LIBS`. This ensures the least -amount of friction for developers - everything just works. - -There is no central package repository, all packages are pulled and updated from public or -private git repositories. Package versioning is done through git tags or branches. - -Custom build systems are also supported. For additional documentation explore: [build_target.py](mama/build_target.py) +Mama - A modular C++ build tool so simple even your mama can use it + +Mama turns a tree of C++ libraries - your own and third-party - into a single +`mama build`. It clones the sources, configures them, builds them in the right order +across every platform and compiler you target, and links the results into your project. +No central package repository, no Docker containers, no hand-written toolchain glue - +just git repos, a minimal set of system libs, and the compilers you already have. + +Building is as simple as `mama build windows` - no ceremony~! Trivial CMake projects and +header-only or stand-alone C libraries are picked up automatically; larger projects add a +small `mamafile.py` to declare their dependencies and build steps. + +![mama build demo](docs/demo.gif) + +## Why Mama + +- **One command, the whole DAG.** Mama resolves C++ dependencies into a build graph and drives + it end to end - clone, configure, compile, in dependency order. `mama build ` does just + that target's subtree, nothing more. +- **Everything parallel.** Clones, configures and compiles all run at once under one scheduler - a + leaf builds while a deeper dep still clones. Longest poles launch first; the core budget is + RAM-capped so a wide build can't OOM the box. +- **Multi-platform, multi-compiler - no Docker.** Toolchains used as designed: `linux`, `linux-clang`, + `windows`, `android` and other cross-builds land side by side, never clobbering. Three platforms at once? Run + three builds, `mama build ` each. Sanitizers (asan/lsan/tsan/ubsan) and coverage are plain flags. +- **Any build system.** CMake, GNU Make, MSBuild, or a custom `build()` step - all through the + same scheduler and live display. +- **Faster CMake configures.** Compiler detection (~5s of a ~6.5s cold configure) runs once per + toolchain and is reused across every fresh build dir, cutting it down to almost nothing. +- **In-source, project-scoped, reproducible.** Dependencies pull from git (pinned by + tag/branch/commit) or local folders; heavy libs like OpenCV and FFmpeg build easily from source, so a + fresh checkout builds identically - only a minimal set of system libs is assumed. +- **Resilient & cached.** Fail-fast Ctrl+C, self-healing build dirs after an interrupted + configure, and `mama upload` to a private artifactory server so matching commits are fetched + instead of rebuilt. +- **Rich build stats.** `mama build buildstats` prints per-package timing bars plus a + frontend/backend/link breakdown (MSVC vcperf, Clang `-ftime-trace`) - the slowest TUs, costliest + headers, heaviest codegen. +- **Batteries included.** Correct-order linking via `MAMA_INCLUDES`/`MAMA_LIBS`, `clang-tidy` and + coverage as flags, and `mama init` to adopt an existing CMake project in one step. + +For additional documentation explore: [build_target.py](mama/build_target.py) ## Who is this FOR? @@ -69,7 +91,7 @@ target_link_libraries(YourProject PRIVATE ${MAMA_LIBS}) ## Command examples ``` mama init Initialize a new project. Tries to create mamafile.py and CMakeLists.txt - mama build Update and build main project only. This only clones, but does not update! + mama build Build main project only. Clones missing deps, but does not git pull. mama build x86 opencv Cross compile build target opencv to x86 architecture mama build android Cross compile to arm64 android NDK (default API level 29) mama build android-31 Cross compile to arm64 with Android API level 31 @@ -83,7 +105,7 @@ target_link_libraries(YourProject PRIVATE ${MAMA_LIBS}) mama rebuild dep1 deps_only Cleans and rebuilds only dep1's dependencies, skipping dep1 itself. mama build dep1 deps_only Build only dep1's dependencies, skipping dep1 itself. mama configure deps_only Re-runs CMake configure on all dependencies, but not the main project. - mama build dep1 Update and build dep1 only. + mama build dep1 Build dep1 only. Clones if missing, but does not git pull. mama update dep1 Update and build the specified target. mama serve android Update, build and deploy for Android. mama deploy Runs PAPA deploy stage. @@ -118,6 +140,7 @@ Call `mama help` for more usage information. clang-tidy Enable clang-tidy static analysis during build. silent Greatly reduces output verbosity. verbose Greatly increases output verbosity. + buildstats After the build, print per-package timing bars and a deep compiler breakdown. parallel Load dependencies in parallel. deps_only Only execute build/rebuild/clean on dependencies, skip the main target. When combined with a target name, applies to that target's dependencies only. @@ -137,7 +160,7 @@ conversion, and an existing clone's `origin` remote is re-pointed so `fetch`/`pu the chosen protocol. ``` - mama build https-override git@github.com:KrattWorks/repo.git -> https://github.com/KrattWorks/repo.git + mama build https-override git@github.com:example/repo.git -> https://github.com/example/repo.git mama build ssh-override https://github.com/RedFox20/ReCpp.git -> git@github.com:RedFox20/ReCpp.git ``` @@ -159,6 +182,45 @@ the chosen protocol. coverage-report[=src_root] Generate coverage report using gcovr. ``` +### Build statistics: `buildstats` + +`mama build buildstats` prints a timing report after the build finishes. + +``` +mama build buildstats # timing report for the whole dependency chain +mama rebuild all buildstats # full rebuild, so every package shows real compile time +mama build buildstats opencv # scope the deep breakdown to a single target +``` + +**Stage 1 - per-package bars.** One normalized horizontal bar per package, slowest first, +segmented into load (git/artifactory), configure (CMake) and build (compile+link), with the +package's total wall time on the right. Bar length scales against the slowest package, so the +long poles are obvious at a glance. Packages faster than 0.33s are omitted as noise. On UTF-8 +terminals the segments use block shades, on legacy Windows code pages they fall back to ASCII. + +``` + Build times ░ load ▒ configure ▓ build + opencv ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 2m 12s + ReCpp ░▒▒▓▓▓▓▓▓ 31.4s +``` + +**Stage 2 - deep compiler breakdown.** Where the compile time actually went: frontend (parse) +vs backend (codegen) vs link, the achieved build parallelism, the slowest translation units, +the costliest headers (total parse time and include count) and the costliest codegen symbols. +This stage is compiler-specific: + +- **MSVC** - the build is wrapped in a [vcperf](https://learn.microsoft.com/en-us/cpp/build-insights/) + `/timetrace` session and the trace is written to `packages///mama_timetrace.json`. + vcperf must be on `PATH`, in the MSVC toolset, or pointed at by the `VCPERF` env var; a one-time + elevated `vcperf /grantusercontrol` is the prerequisite for capturing without admin rights. + If vcperf isn't found, Stage 1 still prints and Stage 2 is skipped with a warning. +- **Clang** - the build is instrumented with `-ftime-trace` and the per-TU JSONs written during + this run are collected from the build dirs. Use `mama build clang buildstats` on Linux. +- **GCC** - no per-file trace exists, so only Stage 1 prints. + +Both stages only see what was actually compiled. An up-to-date incremental build reports +`no compiler activity captured` - use `rebuild` when you want to profile a full build. + ### Clang-Tidy static analysis Mama supports running [clang-tidy](https://clang.llvm.org/extra/clang-tidy/) static analysis during the build. When enabled, CMake sets `CMAKE_C_CLANG_TIDY` and `CMAKE_CXX_CLANG_TIDY` so that clang-tidy runs on every compiled source file. @@ -203,12 +265,33 @@ def settings(self): ## Mamafile Reference +### Requiring a minimum mamabuild version + +When a mamafile relies on newer mamabuild features, declare the minimum version. The build aborts +during target load - before any configure/build work - with an upgrade hint if mama is too old: + +```py +def settings(self): + self.requires_version('0.13.01') +``` + +``` +Target myapp requires mamabuild >= 0.13.01, but this is 0.12.5. Upgrade with: pip install --upgrade mama +``` + +Versions compare segment-wise as numbers, so `0.13.01` correctly outranks `0.9.5` and a shorter +version zero-pads (`0.13` < `0.13.01`). + +> Note: `requires_version` itself exists only from 0.13.01 onward. On an older mama the mamafile +> fails with `AttributeError: ... has no attribute 'requires_version'` - which is still the signal +> to upgrade. + ### Adding dependencies ```py # Git dependency with full options: self.add_git('ReCpp', 'https://github.com/RedFox20/ReCpp.git', - branch='master', # track a branch (default: repo default branch) + git_branch='master', # track a branch (default: repo default branch) git_tag='v1.2.3', # pin to a specific git tag git_commit='abc123', # OR pin to a specific commit hash (alias of git_tag argument) mamafile='recpp.py', # explicit mamafile path (default: auto-detect {src}/mamafile.py) @@ -451,7 +534,7 @@ class AlphaGL(mama.BuildTarget): # the credentials can be configured by env vars for CI, call `mama help` self.set_artifactory_ftp('artifacts.myftp.com', auth='store') # add packages - self.add_git('ReCpp', 'https://github.com/RedFox20/ReCpp.git', branch='master') + self.add_git('ReCpp', 'https://github.com/RedFox20/ReCpp.git', git_branch='master') self.add_git('libpng', 'https://github.com/LuaDist/libpng.git') self.add_git('libjpeg', 'https://github.com/LuaDist/libjpeg.git') self.add_git('glfw', 'https://github.com/glfw/glfw.git') @@ -520,40 +603,26 @@ This is also the default behavior of `default_package_includes()` when only a `s ## Example output from Mama Build ``` -$ mama build -========= Mama Build Tool ========== - - Target FaceOne BUILD [root target] - - Target dlib OK - - Target CppGuid OK - - Target opencv OK - - Target ReCpp OK - - Target NanoMesh OK - - Package ReCpp - build/ReCpp/ReCpp - [L] build/ReCpp/windows/RelWithDebInfo/ReCpp.lib - - Package opencv - build/opencv/windows/include - [L] build/opencv/windows/lib/Release/opencv_xphoto342.lib - [L] build/opencv/windows/lib/Release/opencv_features2d342.lib - [L] build/opencv/windows/lib/Release/opencv_imgcodecs342.lib - [L] build/opencv/windows/lib/Release/opencv_imgproc342.lib - [L] build/opencv/windows/lib/Release/opencv_core342.lib - [L] build/opencv/windows/3rdparty/lib/Release/libjpeg-turbo.lib - [L] build/opencv/windows/3rdparty/lib/Release/libpng.lib - [L] build/opencv/windows/3rdparty/lib/Release/zlib.lib - - Package dlib - build/dlib/windows/include - [L] build/dlib/windows/lib/dlib19.15.99_relwithdebinfo_64bit_msvc1914.lib - - Package NanoMesh - build/NanoMesh/NanoMesh - [L] build/NanoMesh/windows/RelWithDebInfo/NanoMesh.lib - - Package CppGuid - build/CppGuid/CppGuid/include - [L] build/CppGuid/windows/RelWithDebInfo/CppGuid.lib - - Package FaceOne - include - [L] bin/FaceOne.dll - [L] bin/FaceOne.lib +$ mama clang build +Mama 0.13.01 building with clang 18.1 libstdc++ ++ build J4 reflect_cpp git 0.1s cfg 0.02s bld 0.01s ++ build J0 sdl_gamecontrollerdb git 0.1s cfg 0.02s bld 0.07s ++ build J1 nlohmannjson git 0.1s cfg 0.02s bld 0.02s ++ build J12 px4gpsdrivers git 0.4s cfg 0.0s bld 0.0s ++ build J4 xz_embedded git 0.09s cfg 0.02s bld 0.3s ++ build J5 shapelib git 0.08s cfg 0.07s bld 0.4s ++ build J15 zlib git 0.08s cfg 0.08s bld 0.3s ++ build J2 libevents git 0.08s cfg 0.1s bld 3.2s ++ build J31 SDL git 0.2s cfg 0.01s bld 5.7s ++ build J18 qcoro git 0.09s cfg 0.01s bld 10.3s ++ build J31 protobuf git 0.3s cfg 0.8s bld 35.3s ++ build J31 rtpvideo git 0.1s cfg 0.0s bld 0.0s ++ build J27 serviceman git 0.1s cfg 0.01s bld 0.01s ++ build J31 geographiclib git 0.07s cfg 0.09s bld 2.0s ++ build J31 sentry_native git 0.10s cfg 4.1s bld 0.8s ++ build J31 datalink git 0.4s cfg 0.0s bld 12.1s ++ build J31 qgroundcontrol loc 0.02s cfg 5.6s bld 27.2s +Built 14 target(s) in 1m 32s ``` ### Uploading packages ### ```python @@ -563,32 +632,13 @@ $ mama build ``` ``` $ mama upload googletest -========= Mama Build Tool ========== - - Package googletest - myworkspace/googletest/linux/include - [L] myworkspace/googletest/linux/lib/libgmock.a - [L] myworkspace/googletest/linux/lib/libgtest.a - - PAPA Deploy /home/XXX/myworkspace/googletest/linux/deploy/googletest + - PAPA Deploy /home/mamabuild/example/packages/googletest/linux/deploy/googletest I (googletest) include - L (googletest) libgmock.a - L (googletest) libgtest.a + L (googletest) lib/libgmock.a + L (googletest) lib/libgtest.a PAPA Deployed: 1 includes, 2 libs, 0 syslibs, 0 assets - - PAPA Upload googletest-linux-x64-release-ebb36f3 770.6KB - |==================================================>| 100 % -``` -And then rebuilding with an artifactory package available -``` -$ mama rebuild googletest -========= Mama Build Tool ========== - - Target googletest CLEAN linux - - Target googletest BUILD [cleaned target] - Artifactory fetch ftp.myartifactory.com/googletest-linux-x64-release-ebb36f3 770.6KB - |<==================================================| 100 % - Artifactory unzip googletest-linux-x64-release-ebb36f3 - - Package googletest - myworkspace/googletest/linux/include - [L] myworkspace/googletest/linux/libgmock.a - [L] myworkspace/googletest/linux/libgtest.a + - PAPA Upload googletest-ubuntu-24-gcc14.3-x64-release-ae51a95 2.9MB + - googletest |==================================================>| 100 % ``` diff --git a/bench/bench_config_overhead.py b/bench/bench_config_overhead.py new file mode 100644 index 0000000..859fb90 --- /dev/null +++ b/bench/bench_config_overhead.py @@ -0,0 +1,107 @@ +"""Measure CMake CONFIGURE overhead (no compilation) across real repos, to judge config-speed +ideas on data. For each repo in repos.txt it reports three numbers with the VS generator: + + cold - fresh build dir (full compiler detection) + seeded - mama's ABI seed injected (skips the ABI try_compile) + warm - configure twice, 2nd run (detection cached) = the pre-warm ceiling + +The cold->warm gap is the headroom a parallel detection pre-warm could capture. This is a manual +benchmark (clones + runs real cmake); it is NOT part of the unit suite. + + python bench/bench_config_overhead.py # all repos in repos.txt + python bench/bench_config_overhead.py ReCpp # just one +""" +import os, re, sys, time, shutil, subprocess, tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) +from mama import cmake_compiler_cache as cc # noqa: E402 + +REPOS_DIR = os.path.join(HERE, 'repos') +OUT_DIR = os.path.join(HERE, '_out') # build dirs + seeds, repo-local + gitignored +_DETECT = re.compile(r'identification is|compiler ABI info|compile features') + + +def sh(args, cwd=None): + return subprocess.run(args, cwd=cwd, capture_output=True, text=True) + + +def vs_generator(): + out = sh(['cmake', '--help']).stdout + vs = [l.split('=')[0].strip().lstrip('* ') for l in out.splitlines() if 'Visual Studio' in l and '=' in l] + return vs[0] if vs else '' + + +def cmake_ver(): + out = sh(['cmake', '--version']).stdout + m = re.search(r'(\d+\.\d+\.\d+)', out) + return m.group(1) if m else 'unknown' + + +def configure(src, build, gen, extra=()): + args = ['cmake'] + if gen: args += ['-G', gen, '-A', 'x64'] + args += [*extra, '-S', src, '-B', build] + t = time.monotonic() + cp = sh(args) + return time.monotonic() - t, (_DETECT.findall(cp.stdout).__len__()), cp.returncode == 0 + + +def fresh(prefix): + os.makedirs(OUT_DIR, exist_ok=True) + return tempfile.mkdtemp(prefix=prefix, dir=OUT_DIR) # repo-local, not system temp + + +def bench_repo(name, src, gen, ver): + cf = lambda b: os.path.join(b, 'CMakeFiles', ver) + # cold + b1 = fresh(f'{name}_cold_'); cold, dcold, ok1 = configure(src, b1, gen) + # warm (2nd configure of same dir) + configure(src, b1, gen); warm, dwarm, _ = configure(src, b1, gen) + # seeded: publish from the cold build, inject the warm state into a fresh dir + seed = fresh(f'{name}_seed_'); cc.publish(seed, cf(b1)) + b2 = fresh(f'{name}_seeded_'); cc.inject(seed, b2, cf(b2), src) + seeded, dseed, ok2 = configure(src, b2, gen) + for d in (b1, b2, seed): shutil.rmtree(d, ignore_errors=True) + return cold, seeded, warm, dcold, dseed, dwarm, ok1 and ok2 + + +def clone_missing(only): + os.makedirs(REPOS_DIR, exist_ok=True) + repos = [] + for line in open(os.path.join(HERE, 'repos.txt'), encoding='utf-8'): + line = line.strip() + if not line or line.startswith('#'): continue + parts = line.split() + name, url = parts[0], parts[1] + if only and name not in only: continue + branch = parts[2] if len(parts) > 2 else None + subdir = parts[3] if len(parts) > 3 else '' + dest = os.path.join(REPOS_DIR, name) + if not os.path.isdir(dest): + args = ['git', 'clone', '--depth=1', '--recurse-submodules'] + if branch: args += ['--branch', branch] + print(f'cloning {name} ...') + if sh(args + [url, dest]).returncode != 0: + print(f' skip {name} (clone failed - private/auth?)'); continue + repos.append((name, os.path.join(dest, subdir) if subdir else dest)) + return repos + + +def main(): + only = set(sys.argv[1:]) + gen, ver = vs_generator(), cmake_ver() + print(f'generator: {gen or "(default)"} cmake: {ver}\n') + print(f'{"repo":<12}{"cold":>7}{"seeded":>8}{"warm":>7} {"cold->seeded":>13}{"cold->warm":>12} detect c/s/w') + for name, src in clone_missing(only): + if not os.path.exists(os.path.join(src, 'CMakeLists.txt')): + print(f'{name:<12} no CMakeLists.txt at {src}'); continue + cold, seeded, warm, dc, ds, dw, ok = bench_repo(name, src, gen, ver) + pct = lambda a, b: f'{(1 - b / a) * 100:4.0f}%' if a else ' -' + print(f'{name:<12}{cold:6.1f}s{seeded:7.1f}s{warm:6.1f}s {pct(cold, seeded):>13}{pct(cold, warm):>12}' + f' {dc}/{ds}/{dw}{"" if ok else " (configure FAILED)"}') + + +if __name__ == '__main__': + main() diff --git a/bench/repos.txt b/bench/repos.txt new file mode 100644 index 0000000..296f201 --- /dev/null +++ b/bench/repos.txt @@ -0,0 +1,6 @@ +# CMake config-overhead benchmark manifest: one "name url" per line (shallow-cloned into repos/). +# Lines starting with # are ignored. A 3rd field (optional) is the relative subdir holding the +# top CMakeLists.txt. These are public repos, so no auth is needed; private repos would clone via git SSH. +ReCpp https://github.com/RedFox20/ReCpp.git +udp_quality https://github.com/RedFox20/udp_quality.git +googletest https://github.com/RedFox20/googletest.git diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..9006fda --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +*.cast \ No newline at end of file diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..ebd8c0d Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo_cast.py b/docs/demo_cast.py new file mode 100755 index 0000000..66f6d79 --- /dev/null +++ b/docs/demo_cast.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Obfuscate/refine and merge asciinema v2 .cast files for a clean demo GIF. + +Two subcommands (driven by record_demo.sh): + + demo_cast.py obfuscate IN -o OUT [...] rewrite one cast: obfuscate text, keep + timing + cursor codes, and optionally width-truncate, drop sections, + fast-forward stuck stretches, hold on a marker, force size, prepend a prompt. + demo_cast.py merge A B C -o OUT [--gap] concatenate casts into one timeline. + +Obfuscation is a SINGLE case-insensitive pass: every rule key is matched +ignoring case and replaced with its value (the matched text's case is discarded). +Only printable text in output ("o") events and the header's string values are +touched - the cursor movements that animate the live dashboard are intact. +Rules come from --rules (default $MAMA_DEMO_RULES or ~/.mama_demo_rules.json). +""" +import argparse, getpass, json, os, re, socket, sys + +# Escape sequences (CSI / OSC / two-char) - copied verbatim, never counted as width. +_ESC = re.compile(r'\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])') + + +def truncate_cols(text, n, col): + """Drop printable characters past visible column `n` on each line, keeping ALL + control codes (colours, cursor moves, the trailing ESC[K). `col` is the starting + column carried across events; returns (text, end_col).""" + out, i, L = [], 0, len(text) + while i < L: + m = _ESC.match(text, i) + if m: + out.append(m.group(0)); i = m.end(); continue + c = text[i]; i += 1 + if c == '\n' or c == '\r': + out.append(c); col = 0 + elif col < n: + out.append(c); col += 1 + return ''.join(out), col + + +def signature(text, width=80): + """A frame fingerprint that ignores volatile numbers (timers, step counts, cpu%) + and the wide ninja detail past `width`, so two 'same target still building' + frames compare equal.""" + lines = [] + for line in _ESC.sub('', text).replace('\r', '\n').split('\n'): + line = re.sub(r'\d+', '#', line[:width]) + line = re.sub(r'\s+', ' ', line).strip() + if line: lines.append(line) + return '\n'.join(lines) + + +# ── rules → one case-insensitive substitution map ──────────────────────────── + +def load_rules(rules_path, cwd): + """Return ONE {lowercase-key: replacement} map. Matching is case-insensitive and + the matched text's case is discarded, so a single pass covers identity, words + and the kratt catch-all (longest key wins at each position). + + FALLBACK: with no rules file there is nothing to key identity off, so we do the + one scrub that always matters here - kratt -> drone.""" + if not (rules_path and os.path.exists(rules_path)): + return {'kratt': 'drone'} + + cfg = json.load(open(rules_path)) + user = cfg.get('user', 'mamabuild'); host = cfg.get('host', 'ubuntu24') + project = cfg.get('project_display', 'qgroundcontrol') + home = cfg.get('home') or f'/home/{user}' + + realHome = os.path.expanduser('~'); realProj = os.path.abspath(cwd) + m = {realProj.lower(): os.path.join(home, project)} + if realProj.startswith(realHome): + rel = realProj[len(realHome):].lstrip('/') + if rel: m['~/' + rel.lower()] = '~/' + project + m[realHome.lower()] = home + m[getpass.getuser().lower()] = user + m[socket.gethostname().lower()] = host + m.setdefault(os.path.basename(realProj).lower(), project) + for k, v in cfg.get('replace', {}).items(): m[k.lower()] = v + for k, v in cfg.get('strip_ci', {'kratt': 'qgc'}).items(): m[k.lower()] = v + return {k: v for k, v in m.items() if k and k != v.lower()} + + +def wp_apply(text, pattern, repl_fn): + """Width-preserving substitution: after each replacement, keep the line width + constant by consuming (or adding) padding spaces that follow - but only inside a + run of >=2 spaces, so real column padding is adjusted while single-space + separators, paths and prose are left as-is.""" + if not pattern: + return text + out, i = [], 0 + for m in pattern.finditer(text): + out.append(text[i:m.start()]) + old = m.group(0); new = repl_fn(old) + out.append(new) + j = m.end(); delta = len(new) - len(old) + run = len(text[j:]) - len(text[j:].lstrip(' ')) + if run >= 2: + if delta > 0: j += min(delta, run) + elif delta < 0: out.append(' ' * (-delta)) + i = j + out.append(text[i:]) + return ''.join(out) + + +def make_substitute(rules_path, cwd): + mapping = load_rules(rules_path, cwd) + keys = sorted(mapping, key=len, reverse=True) # longest match wins + pat = re.compile('|'.join(re.escape(k) for k in keys), re.IGNORECASE) if keys else None + substitute = lambda s: wp_apply(s, pat, lambda old: mapping[old.lower()]) + return substitute, keys, max((len(k) for k in keys), default=0) + + +def safe_split(buf, keys, maxkey): + """(emit, carry): carry = longest suffix of buf that could START a key, held so a + token straddling two events isn't missed.""" + hold = 0 + for h in range(1, min(len(buf), maxkey) + 1): + if any(k.startswith(buf[-h:].lower()) for k in keys): hold = h + return (buf[:len(buf) - hold], buf[len(buf) - hold:]) if hold else (buf, '') + + +def obf_obj(o, sub): + if isinstance(o, str): return sub(o) + if isinstance(o, dict): return {k: obf_obj(v, sub) for k, v in o.items()} + if isinstance(o, list): return [obf_obj(v, sub) for v in o] + return o + + +def process_drop(text, dropping, start_re, end_re): + """Cut section(s) from `text` at line boundaries. When `start_re` matches, drop + from that line on; if `end_re` is given, resume at the line it matches (section + strip), else stop for good (tail truncation). Returns (kept_text, dropping, stop).""" + res, pos, stop = [], 0, False + line_start = lambda s, i: (s.rfind('\n', 0, i) + 1) + while pos <= len(text): + if not dropping: + m = start_re.search(text, pos) + if not m: + res.append(text[pos:]); break + res.append(text[pos:line_start(text, m.start())]) + if end_re is None: + stop = True; break + dropping = True; pos = m.end() + else: + m = end_re.search(text, pos) + if not m: + break + dropping = False; pos = line_start(text, m.start()) + return ''.join(res), dropping, stop + + +# ── subcommand: obfuscate ──────────────────────────────────────────────────── + +def cmd_obfuscate(args): + substitute, keys, maxkey = make_substitute(args.rules, os.getcwd()) + drop_from_re = re.compile(args.drop_from) if args.drop_from else None + drop_to_re = re.compile(args.drop_to) if args.drop_to else None + holds = [(float(s.split(':', 1)[0]), re.compile(s.split(':', 1)[1])) for s in args.hold_at] + fired = set() + + lines = [ln for ln in open(args.input, encoding='utf-8', errors='replace').read().split('\n') if ln.strip()] + header = obf_obj(json.loads(lines[0]), substitute) + if args.cols: header['width'] = args.cols + if args.rows: header['height'] = args.rows + out = [json.dumps(header, ensure_ascii=True)] + + col, cur_t = 0, 0.0 + if args.prepend: + clear = '\x1b[H\x1b[2J\x1b[3J' if args.clear else '' # home + clear screen + scrollback + ptext = clear + substitute(args.prepend) + '\r\n' + if args.max_cols: ptext, col = truncate_cols(ptext, args.max_cols, col) + out.append(json.dumps([0.0, 'o', ptext], ensure_ascii=True)) + cur_t = 0.4 # let the prompt sit before output starts + + dropping, carry = False, '' + prev_real, prev_sig, last_raw = None, None, 0.0 + for ln in lines[1:]: + ev = json.loads(ln) + if len(ev) < 3 or ev[1] != 'o': + out.append(json.dumps([cur_t if args.fast_forward else ev[0]] + ev[1:], ensure_ascii=True)) + continue + last_raw = ev[0] + emit, carry = safe_split(carry + ev[2], keys, maxkey) + text = substitute(emit) + stop = False + if drop_from_re: + text, dropping, stop = process_drop(text, dropping, drop_from_re, drop_to_re) + if args.max_cols and text: + text, col = truncate_cols(text, args.max_cols, col) + + if args.fast_forward: + first = prev_real is None + real_gap = 0.0 if first else (ev[0] - prev_real) + prev_real = ev[0] + sig = signature(text) + if first: + delta = 0.4 + elif not sig or (prev_sig is not None and sig == prev_sig): + delta = args.ff_hold # stuck: only numbers changed + else: + delta = min(real_gap, args.idle_cap) + if sig: prev_sig = sig + cur_t = round(cur_t + delta, 4) + tstamp = cur_t + else: + tstamp = ev[0] + + if text: + out.append(json.dumps([tstamp, 'o', text], ensure_ascii=True)) + if args.fast_forward and text: # pause after a frame that hits a --hold-at marker + for idx, (secs, rx) in enumerate(holds): + if idx not in fired and rx.search(text): + fired.add(idx); cur_t = round(cur_t + secs, 4) + if stop: + carry = ''; break + + if carry and not dropping: + text = substitute(carry) + if args.max_cols: text, col = truncate_cols(text, args.max_cols, col) + if text: + out.append(json.dumps([cur_t if args.fast_forward else last_raw, 'o', text], ensure_ascii=True)) + + final_t = cur_t if args.fast_forward else last_raw + if args.end_hold: # extend the timeline so the last frame lingers + final_t = round(final_t + args.end_hold, 4) + out.append(json.dumps([final_t, 'o', '\x1b[0m'], ensure_ascii=True)) + + open(args.output, 'w', encoding='utf-8').write('\n'.join(out) + '\n') + sys.stderr.write(f'[demo_cast] {args.output}: {len(out) - 1} events, ~{final_t:.1f}s\n') + + +# ── subcommand: merge ──────────────────────────────────────────────────────── + +def _read_cast(path): + lines = [ln for ln in open(path, encoding='utf-8', errors='replace').read().split('\n') if ln.strip()] + return json.loads(lines[0]), [json.loads(ln) for ln in lines[1:]] + + +def cmd_merge(args): + """Concatenate casts into one timeline: shift each input by the running offset + with a gap between segments; the header takes the max width/height.""" + base, _ = _read_cast(args.inputs[0]) + merged = dict(base) + events, offset = [], 0.0 + for path in args.inputs: + header, evs = _read_cast(path) + merged['width'] = max(merged.get('width', 0), header.get('width', 0)) + merged['height'] = max(merged.get('height', 0), header.get('height', 0)) + seg_end = 0.0 + for ev in evs: + seg_end = max(seg_end, ev[0]) + events.append([round(ev[0] + offset, 3)] + ev[1:]) + offset += seg_end + args.gap + merged.pop('timestamp', None) + out = [json.dumps(merged, ensure_ascii=True)] + [json.dumps(e, ensure_ascii=True) for e in events] + open(args.output, 'w', encoding='utf-8').write('\n'.join(out) + '\n') + sys.stderr.write(f'[demo_cast] merge {len(args.inputs)} -> {args.output} ' + f'({merged["width"]}x{merged["height"]}, {round(offset - args.gap, 1)}s)\n') + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + + o = sub.add_parser('obfuscate', help='obfuscate + refine one cast') + o.add_argument('input') + o.add_argument('-o', '--output', required=True) + o.add_argument('--rules', default=os.environ.get('MAMA_DEMO_RULES', + os.path.join(os.path.expanduser('~'), '.mama_demo_rules.json'))) + o.add_argument('--prepend', help='fake prompt+command line drawn before the recording') + o.add_argument('--clear', action='store_true', help='clear the screen before the prompt') + o.add_argument('--end-hold', type=float, default=0.0, help='hold the final frame this many seconds') + o.add_argument('--hold-at', action='append', metavar='SECS:REGEX', default=[], + help='pause SECS on the first frame whose text matches REGEX (repeatable; needs --fast-forward)') + o.add_argument('--drop-from', help='drop from the line matching this regex; to end unless --drop-to is set') + o.add_argument('--drop-to', help='resume at the line matching this regex (strip a middle section)') + o.add_argument('--max-cols', type=int, help='truncate visible text past this column') + o.add_argument('--cols', type=int, help='force header width (columns)') + o.add_argument('--rows', type=int, help='force header height (rows)') + o.add_argument('--fast-forward', action='store_true', help='collapse frames where only numbers change') + o.add_argument('--ff-hold', type=float, default=0.05, help='seconds given to a collapsed (unchanged) frame') + o.add_argument('--idle-cap', type=float, default=0.5, help='cap the real gap of a changed frame to this') + o.set_defaults(func=cmd_obfuscate) + + m = sub.add_parser('merge', help='concatenate casts into one timeline') + m.add_argument('inputs', nargs='+') + m.add_argument('-o', '--output', required=True) + m.add_argument('--gap', type=float, default=1.2, help='seconds of pause between segments') + m.set_defaults(func=cmd_merge) + + args = ap.parse_args() + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/docs/record_demo.sh b/docs/record_demo.sh new file mode 100755 index 0000000..8bea62d --- /dev/null +++ b/docs/record_demo.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Record mama's live build dashboard for each demo command, obfuscate + refine it +# (timing and cursor codes preserved), merge into one timeline, and render a single +# animated GIF. Self-contained: the two files (record_demo.sh, demo_cast.py) can +# be moved to ~/Mama/docs/ as-is. +# +# ── INSTALL (one-time) ─────────────────────────────────────────────────────── +# sudo apt install -y asciinema +# curl -Lo ~/.local/bin/agg \ +# https://github.com/asciinema/agg/releases/latest/download/agg-x86_64-unknown-linux-gnu +# chmod +x ~/.local/bin/agg +# +# ── USE ────────────────────────────────────────────────────────────────────── +# cd # mama commands run here +# ~/Mama/docs/record_demo.sh # record + render -> demo.gif +# RENDER_ONLY=1 ...record_demo.sh # re-render from existing casts (no rebuild) +# +# Obfuscation rules live OUTSIDE this dir so it can be published without leaking +# real names: $MAMA_DEMO_RULES, default ~/.mama_demo_rules.json. +# +# Tunables (env): SPEED FPS FONT GAP MAXCOLS FF_HOLD IDLE THEME COLS_ENV ROWS_ENV +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="${PROJECT_DIR:-$PWD}" +RULES="${MAMA_DEMO_RULES:-$HOME/.mama_demo_rules.json}" +OUT="${OUT:-$HERE}"; CASTS="$OUT/casts"; GIF="$OUT/demo.gif"; MERGED="$OUT/demo_merged.cast" +mkdir -p "$CASTS" + +SPEED="${SPEED:-1}" # extra uniform agg speed-up (ff already compresses) +FPS="${FPS:-15}" # agg frame cap +FONT="${FONT:-14}" +THEME="${THEME:-asciinema}" +GAP="${GAP:-1.0}" # pause between segments +MAXCOLS="${MAXCOLS:-120}" # truncate wide ninja detail + bound GIF width +HEIGHT="${HEIGHT:-28}" # GIF rows (max live region is ~14, so this is safe) +FF_HOLD="${FF_HOLD:-0.012}" +IDLE="${IDLE:-0.35}" +COLS_ENV="${COLS_ENV:-120}"; ROWS_ENV="${ROWS_ENV:-$HEIGHT}" # terminal size for NEW recordings +RENDER_ONLY="${RENDER_ONLY:-0}" + +STEPS=( + "prep:mama clean all" + "rec:mama build" + "prep:mama android clean all" + "rec:mama android build" + "prep:mama clang clean all" + "rec:mama clang build buildstats rtpvideo" +) + +# preflight --------------------------------------------------------------------- +miss=0 +for t in asciinema agg; do command -v "$t" >/dev/null || { echo "!! '$t' not installed"; miss=1; }; done +if [ "$miss" = 1 ]; then cat <<'EOF' +Install: + sudo apt install -y asciinema + curl -Lo ~/.local/bin/agg https://github.com/asciinema/agg/releases/latest/download/agg-x86_64-unknown-linux-gnu + chmod +x ~/.local/bin/agg +EOF + exit 1; fi +[ -f "$RULES" ] || echo "!! rules not found: $RULES - fallback obfuscation: kratt->drone only (identity NOT scrubbed)" + +# coloured fake prompt from the identity in the rules file (defaults if no file) - +read -r U H P < <(python3 - "$RULES" <<'PY' +import json,os,sys +r = json.load(open(sys.argv[1])) if os.path.exists(sys.argv[1]) else {} +print(r.get('user','mamabuild'), r.get('host','ubuntu24'), r.get('project_display','qgroundcontrol')) +PY +) +G=$'\033[32m'; B=$'\033[34m'; R=$'\033[0m' +PROMPT="${G}${U}@${H}${R}:${B}~/${P}${R}$ " + +cd "$PROJECT_DIR" +export MAMA_DEMO_RULES="$RULES" +OBFCASTS=() +for step in "${STEPS[@]}"; do + kind="${step%%:*}"; cmd="${step#*:}" + name="mama_$(echo "${cmd#mama }" | tr ' ' '_')" + raw="$CASTS/$name.cast"; obf="$CASTS/$name.obf.cast" + + if [ "$kind" = prep ]; then + [ "$RENDER_ONLY" = 1 ] && continue + echo ">>> prep (not recorded): $cmd" + eval "$cmd" >/dev/null 2>&1 || echo " (prep exited $? - continuing)" + continue + fi + + if [ "$RENDER_ONLY" != 1 ]; then + echo ">>> record: $cmd" + COLUMNS="$COLS_ENV" LINES="$ROWS_ENV" asciinema rec --overwrite --quiet -c "$cmd" "$raw" + fi + [ -f "$raw" ] || { echo " !! no cast $raw (record first, or unset RENDER_ONLY)"; continue; } + + # keep Build Insights after the warnings block; everything else drops to end. + # buildstats also gets a 3s hold so the insights are readable. + case "$cmd" in + *buildstats*) DROP=(--drop-from 'Compiler diagnostics' --drop-to 'Build Insights') + HOLD=(--hold-at "${HOLD_BUILT:-1}:Built [0-9]+ target" --end-hold 3) ;; + *) DROP=(--drop-from 'Compiler diagnostics'); HOLD=() ;; + esac + echo ">>> refine: $cmd -> $obf" + python3 "$HERE/demo_cast.py" obfuscate "$raw" -o "$obf" --prepend "${PROMPT}${cmd}" --clear \ + --max-cols "$MAXCOLS" --cols "$MAXCOLS" --rows "$HEIGHT" \ + --fast-forward --ff-hold "$FF_HOLD" --idle-cap "$IDLE" "${DROP[@]}" "${HOLD[@]}" + OBFCASTS+=("$obf") +done + +[ "${#OBFCASTS[@]}" -gt 0 ] || { echo "!! nothing to render"; exit 1; } +echo ">>> merge ${#OBFCASTS[@]} segments" +python3 "$HERE/demo_cast.py" merge "${OBFCASTS[@]}" -o "$MERGED" --gap "$GAP" + +echo ">>> render -> $GIF (speed ${SPEED}x, ${FPS}fps, font ${FONT})" +agg --speed "$SPEED" --fps-cap "$FPS" --font-size "$FONT" --theme "$THEME" "$MERGED" "$GIF" + +echo ">>> leftover-secret scan:" +python3 - "$CASTS" "$RULES" <<'PY' +import json, os, re, sys, glob +keys = list(json.load(open(sys.argv[2])).get('strip_ci', {})) or ['kratt'] +pat = re.compile('|'.join(re.escape(k) for k in keys), re.IGNORECASE) +bad = False +for f in sorted(glob.glob(os.path.join(sys.argv[1], '*.obf.cast'))): + n = len(pat.findall(open(f, encoding='utf-8', errors='replace').read())) + if n: bad = True; print(f" !! {f}: {n} leftover hit(s)") +print(" clean." if not bad else " ^ add the word(s) to the rules file and re-run.") +PY +echo ">>> done: $GIF ($(du -h "$GIF" | cut -f1))" diff --git a/mama/_version.py b/mama/_version.py index aa6032f..4d77dec 100644 --- a/mama/_version.py +++ b/mama/_version.py @@ -1,2 +1,2 @@ # this is parsed by pyproject.toml and defines current mamabuild version -__version__ = "0.12.10" +__version__ = "0.13.01" diff --git a/mama/artifactory.py b/mama/artifactory.py index ea61626..e606788 100644 --- a/mama/artifactory.py +++ b/mama/artifactory.py @@ -234,6 +234,16 @@ def artifactory_upload_ftp(target:BuildTarget, file_path:str) -> bool: return False +def _warn_on_compiler_mismatch(target:BuildTarget, papa:PapaFileInfo): + """Foreign-compiler package = libc++ archives in a libstdc++ build, dies on undefined std::__1:: symbols. + Compiler-scoped build dirs make this unreachable, so warn (don't fail) - a pre-C-record package has no stamp.""" + if not papa.compiler: return # pre-C-record package: unknown, allow + try: current = target.config.compiler_version() + except Exception: return + if papa.compiler != current: + warning(f' - Target {target.name: <16} package was built with {papa.compiler}, this build uses {current}') + + def artifactory_load_target(target:BuildTarget, deploy_path, num_files_copied) -> Tuple[bool, list]: """ Reconfigures `target` from {deployment_path}/papa.txt. @@ -254,6 +264,7 @@ def artifactory_load_target(target:BuildTarget, deploy_path, num_files_copied) - if papa.project_name != target.name: error(f' {target.name} Artifactory Load failed because {papa_list} ProjectName={papa.project_name} mismatches!') return (False, None) + _warn_on_compiler_mismatch(target, papa) target.dep.from_artifactory = True target.exported_includes = papa.includes # include folders to export from this target diff --git a/mama/build_config.py b/mama/build_config.py index 5978ac1..eab9c52 100644 --- a/mama/build_config.py +++ b/mama/build_config.py @@ -70,6 +70,14 @@ def summary_line(self) -> str: # This configuration is then passed down to dependencies # class BuildConfig: + @staticmethod + def _default_build_jobs() -> int: + """Default parallel build jobs. Linux leaves ONE core free so a perfectly-parallel build can't + saturate the desktop into an OOM/freeze; Windows/macOS use all cores. An explicit `jobs=N` + on the command line overrides this.""" + cpu = psutil.cpu_count() or 4 + return max(1, cpu - 1) if System.linux else cpu + def __init__(self, args): # commands self.list = False @@ -90,6 +98,8 @@ def __init__(self, args): self.reclone = False self.dirty = False # marks a target for rebuild on next build even if it's up to date self.deps_only = False # only execute build/rebuild/clean on dependencies, not the main target + self.sched_debug = False # TEMP: print each target's build-weight calc, then exit without building + self.buildstats = False # after the build, print a per-package load/configure/build time breakdown self.unshallow = False # by default, git clones are shallow, this allows unshallowing self.git_url_override = None # 'https' or 'ssh': rewrite add_git() urls at build time self.run_cmake_configure = False # if True, forces running CMake configure step even if target doesn't need rebuild @@ -132,6 +142,8 @@ def __init__(self, args): # If compiler specificed from command line # using `mama build gcc` or `mama build clang` self.compiler_cmd = False + self.compiler_conflict_warned = False # the "target prefers X but compiler locked to Y" note fires once, not per dep + self.clang_stdlib = 'libc++' # linux clang C++ stdlib; see use_gcc_stdlib_for_clang() self.fortran = '' # build optimization self.release = True @@ -139,7 +151,7 @@ def __init__(self, args): # valid architectures: x86, x64, arm, arm64 self.arch = None self.distro = None # distro information (name, major, minor) - self.jobs = psutil.cpu_count() + self.jobs = BuildConfig._default_build_jobs() self.target = None self.flags = None self.open = None @@ -169,16 +181,27 @@ def __init__(self, args): self.parallel_load = False ## Whether to load dependencies in parallel? self.serial_load = False ## If True, override the auto-parallel-on-update behaviour self.parallel_max = 20 ## Cap concurrent git fetches (avoids hammering the SSH master) + self.git_timeout = 30 ## Kill a git clone/fetch with no progress for this many seconds + self.no_compiler_cache = False ## Disable cross-build-dir reuse of cmake compiler detection self.global_workspace = False if System.windows: self.workspaces_root = util.normalized_path(os.getenv('HOMEPATH')) else: self.workspaces_root = os.getenv('HOME') self._network_available = None # None=untested, True/False=result + self._announced = set() # announce_once() keys already printed + self._announce_lock = threading.Lock() + self._cmake_ver_num = None # cached `cmake --version`, also the CMakeFiles/ dir name + self._seed_coord = None # compiler-seed Coordinator, built on first configure + self._buildstats_start = None # buildstats wall start; set only on a non-MSVC insights run + self._timetrace_json = None # vcperf trace path; set only on an MSVC insights run self.unused_args = [] self.loaded_dependencies : dict[str, BuildDependency] = {} + self.dep_registry_lock = threading.Lock() # guards loaded_dependencies under parallel_load self.parse_args(args) self.check_platform() + if self.buildstats and self.clang: + self.run_cmake_configure = True # Linux/Clang: the -ftime-trace compile flag must be (re)applied def parse_args(self, args: List[str]): @@ -208,6 +231,8 @@ def parse_args(self, args: List[str]): elif arg == 'unshallow': self.unshallow = True elif arg == 'https-override': self.git_url_override = 'https' elif arg == 'ssh-override': self.git_url_override = 'ssh' + elif arg == 'sched_debug': self.sched_debug = True # TEMP: print build-weight calc, no build + elif arg == 'buildstats': self.buildstats = True # print per-package timing breakdown after build elif arg == 'configure': self.run_cmake_configure = True self.build = True # configure implies a build @@ -216,9 +241,13 @@ def parse_args(self, args: List[str]): elif arg == 'verbose': self.verbose = True elif arg == 'parallel': self.parallel_load = True elif arg == 'serial': self.serial_load = True + elif arg == 'nocache' or arg == 'no-compiler-cache': self.no_compiler_cache = True elif arg.startswith('parallel_max='): try: self.parallel_max = max(1, int(arg.split('=', 1)[1])) except (ValueError, IndexError): pass + elif arg.startswith('git_timeout='): + try: self.git_timeout = max(5, int(arg.split('=', 1)[1])) + except (ValueError, IndexError): pass elif arg == 'all': self.target = 'all' elif arg == 'test': self.test = ' ' # no test arguments elif arg == 'start': self.start = ' ' # no start arguments @@ -494,7 +523,13 @@ def build_dir_default(self): return 'build' # per-sanitizer build dir suffix so flavors don't share a dir and force a reconfigure SANITIZER_SUFFIX = { 'address':'-asan', 'leak':'-lsan', 'thread':'-tsan', 'undefined':'-ubsan' } + def compiler_build_dir_suffix(self): + """'-clang' on linux clang builds, else ''. Shared dir = one compiler clobbers the other, then g++ + links libc++ archives. gcc keeps bare 'linux' (no churn); elsewhere the toolset/SDK fixes the compiler.""" + return '-clang' if (self.linux and self.clang) else '' + def build_dir_with_suffix(self, build_dir): + build_dir += self.compiler_build_dir_suffix() # coarsest axis first: linux-clang-coverage-tsan if self.coverage: build_dir += '-coverage' if self.sanitize: build_dir += ''.join(self.SANITIZER_SUFFIX.get(s, '-'+s) for s in self.sanitize.split(',')) return build_dir @@ -551,6 +586,35 @@ def set_artifactory_ftp(self, ftp_url: str, auth='store'): self.artifactory_auth = auth + def announce_once(self, key: str, text: str): + """Print `text` only the first time `key` comes up. Option builders run per fingerprint + computation, not per configure, so a plain console() repeats the same line with no new news.""" + if not self.print: return + with self._announce_lock: + if key in self._announced: return + self._announced.add(key) + console(text) + + + def clean_only(self) -> bool: + """`clean` with no build: nothing to fetch, configure or package afterwards - just delete.""" + return self.clean and not self.build + + + def lock_compiler(self): + """Freeze the compiler after the ROOT mamafile's settings(). build_dir depends on it, so a dep + flipping it mid-load would scatter the tree across linux/ and linux-clang/. Later prefer_*() just notes.""" + self.compiler_cmd = True + + + def _warn_compiler_conflict(self, target_name, requested, locked): + """The 'target prefers X but the compiler is already locked to Y' note - emitted ONCE per run + (every dep re-requests its preference, so this used to flood the output one line per target).""" + if self.print and not self.compiler_conflict_warned: + self.compiler_conflict_warned = True + console(f'Target {target_name} requested {requested} but compiler already set to {locked}.') + + def prefer_clang(self, target_name): if not self.linux or self.raspi or self.clang: return if not self.compiler_cmd: @@ -560,8 +624,7 @@ def prefer_clang(self, target_name): if self.print: console(f'Target {target_name} requests Clang. Using Clang since no explicit compiler flag passed.') else: - if self.print: - console(f'Target {target_name} requested Clang but compiler already set to GCC.') + self._warn_compiler_conflict(target_name, 'Clang', 'GCC') def prefer_gcc(self, target_name): @@ -573,8 +636,13 @@ def prefer_gcc(self, target_name): if self.print: console(f'Target {target_name} requests GCC. Using GCC since no explicit compiler flag passed.') else: - if self.print: - console(f'Target {target_name} requested GCC but compiler already set to Clang.') + self._warn_compiler_conflict(target_name, 'GCC', 'Clang') + + + def use_gcc_stdlib_for_clang(self): + """Use libstdc++ instead of libc++ for linux clang, to link GNU-built prebuilts like Qt. + Call from the root mamafile settings() so it applies to every target.""" + self.clang_stdlib = 'libstdc++' ## @@ -1068,17 +1136,32 @@ def get_msvc_tools_path(self): raise EnvironmentError('MSVC tools not available on this platform!') tools_root = f"{self.get_visualstudio_path()}\\VC\\Tools\\MSVC" - tools = os.listdir(tools_root) - if not tools: + tools_path = self._latest_msvc_toolset(tools_root) + if not tools_path: raise EnvironmentError('Could not detect MSVC Tools') - - tools_path = os.path.join(tools_root, tools[0]) - #tools_path = forward_slashes(tools_path) self._msvctools_path = tools_path if self.verbose: console(f'Detected MSVC Tools: {tools_path}') return tools_path + @staticmethod + def _latest_msvc_toolset(tools_root: str) -> str: + """Newest MSVC toolset (highest version) that still has the x64 cl.exe. An upgrade can leave the old + version's dir behind without binaries, and os.listdir order is unspecified - so sort by version + (numerically, not lexically: 14.51 > 14.9) and skip toolsets whose cl.exe is gone. '' if none: + every get_msvc_* path is bin/Hostx64/x64, so a toolset without it only defers the failure.""" + try: + dirs = [d for d in os.listdir(tools_root) if os.path.isdir(os.path.join(tools_root, d))] + except OSError: + return '' + if not dirs: return '' + dirs.sort(key=lambda n: tuple(int(p) if p.isdigit() else 0 for p in n.split('.')), reverse=True) + for d in dirs: + if os.path.isfile(os.path.join(tools_root, d, 'bin', 'Hostx64', 'x64', 'cl.exe')): + return os.path.join(tools_root, d) + return '' # a dir without cl.exe can't build; '' lets the caller say so up front + + def get_msvc_bin64(self): return f'{self.get_msvc_tools_path()}/bin/Hostx64/x64/' diff --git a/mama/build_dependency.py b/mama/build_dependency.py index f8cde19..790063f 100644 --- a/mama/build_dependency.py +++ b/mama/build_dependency.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import List, TYPE_CHECKING -import os, sys, shutil, time +import os, sys, shutil, time, threading from .types.dep_source import DepSource from .types.git import Git @@ -34,6 +34,9 @@ def __init__(self, parent:BuildDependency, config:BuildConfig, self.already_loaded = False self.already_executed = False self.currently_loading = False + self.load_action = 'check' # what load() did, for the display: check|clone|pulling|local|artifactory + self.phase_times = {} # 'load'|'configure'|'build' -> wall seconds, for the `buildstats` breakdown + self._load_lock = threading.Lock() # serialises concurrent load() of THIS dep (parallel_load) self.from_artifactory = False # if true, this Dependency was loaded from Artifactory self.did_check_artifactory = False # if true, artifactory was already checked and can be skipped self._is_shim_cache = None # tri-state cache for is_artifactory_shim() @@ -105,27 +108,28 @@ def update_existing_dependency(self, dep_source: DepSource): def add_child(self, dep_source: DepSource) -> BuildDependency: """ - Adds a new child dependency to this BuildDependency + Adds a new child dependency to this BuildDependency. Thread-safe: under parallel_load two + parents can add the same-named child concurrently; the registry lock makes the dedup + + creation atomic so a shared (diamond) dep resolves to one instance. """ - dep = None - if dep_source.name in self.config.loaded_dependencies: - dep = self.config.loaded_dependencies[dep_source.name] - if dep: - # reuse & update existing dep - dep.update_existing_dependency(dep_source) - else: - # add new - dep = BuildDependency(self, self.config, self.workspace, dep_source) - self.config.loaded_dependencies[dep_source.name] = dep - if self.config.verbose: - console(f' - Target {self.name: <16} ADD {dep}', color=Color.BLUE) + with self.config.dep_registry_lock: + dep = self.config.loaded_dependencies.get(dep_source.name) + if dep: + # reuse & update existing dep + dep.update_existing_dependency(dep_source) + else: + # add new + dep = BuildDependency(self, self.config, self.workspace, dep_source) + self.config.loaded_dependencies[dep_source.name] = dep + if self.config.verbose: + console(f' - Target {self.name: <16} ADD {dep}', color=Color.BLUE) - if dep in self.children: - raise RuntimeError(f"BuildTarget {self.name} add dependency '{dep.name}'"\ - " failed because it has already been added") + if dep in self.children: + raise RuntimeError(f"BuildTarget {self.name} add dependency '{dep.name}'"\ + " failed because it has already been added") - self.children.append(dep) - return dep + self.children.append(dep) + return dep def get_children(self) -> List[BuildDependency]: @@ -185,6 +189,15 @@ def save_exports_as_dependencies(self, exports): write_text_to(self.exported_libs_file(), '\n'.join(exports)) + def has_usable_artifacts(self) -> bool: + """Is there anything on disk a dependent could link/include against? build_products carries the + last build's recorded exports, so a custom build() target with no CMakeCache still reads as built.""" + if self.from_artifactory or self.nothing_to_build or self.is_artifactory_shim(): return True + if self.target is None: return self.has_build_files() # load failed/never ran: judge by the build dir + if self.find_first_missing_build_product(): return False + return bool(self.target.build_products) or self.has_build_files() + + def find_first_missing_build_product(self): for depfile in self.target.build_products: if not os.path.exists(depfile): @@ -305,19 +318,13 @@ def create_build_dir_if_needed(self): ## @return True if dependency has changed def load(self): - if self.currently_loading: - #console(f'WAIT {self.name}') - while self.currently_loading: - time.sleep(0.1) - return self.should_rebuild - #console(f'LOAD {self.name}') - changed = False - try: - self.currently_loading = True - changed = self._load() - finally: - self.currently_loading = False - return changed + # Per-dep lock: under parallel_load a shared (diamond) dep can get two concurrent load() + # calls; serialise loads of THIS dep so exactly one thread clones it (the old + # `currently_loading` busy-wait had a TOCTOU race). Different deps still clone concurrently. + with self._load_lock: + if self.already_loaded: + return self.should_rebuild + return self._load() def _load_target(self) -> BuildTarget: @@ -337,22 +344,23 @@ def _git_checkout_if_needed(self) -> bool: def _force_source_clone(self) -> bool: - """`mama unshallow ` must materialize a real clone, even from a - cached shim - a shim has no source on disk to unshallow.""" - return self.config.unshallow and self.is_current_target() + """A `rebuild` (build from source) or `mama unshallow` of THIS target must materialize a real + clone, even from a cached shim: drop the shim so the git path clones source instead of reusing + the prebuilt package. A plain `clean` does NOT force a clone - it reloads the package post-clean.""" + return (self.config.rebuild or self.config.unshallow) and self.is_current_target() def _try_artifactory_shim(self) -> bool: """Pre-clone artifactory load for non-root git deps. Either honours a cached shim or probes artifactory via ls-remote. Returns True when the dep was satisfied without a clone.""" - # unshallow target: drop the shim marker so the git path clones source. + # rebuild/unshallow target: drop the shim marker so the git path clones source. if self._force_source_clone(): if self.is_artifactory_shim(): if self.config.print: - console(f' - Target {self.name: <16} UNSHALLOW shim -> source clone', color=Color.BLUE) + console(f' - Target {self.name: <16} REBUILD shim -> source clone', color=Color.BLUE) self.remove_shim_marker() - # Unshallow means "use this target's source": suppress the post-clone probe so it + # Build-from-source means "use this target's source": suppress the post-clone probe so it # doesn't re-load the prebuilt pkg over the clone (true for an already-cloned target too). self.did_check_artifactory = True return False @@ -396,6 +404,17 @@ def _try_artifactory_load(self, target) -> bool: return False + def _reload_artifactory_after_clean(self, target) -> bool: + """Re-fetch the artifactory package a plain `clean` just wiped from the build dir. The cached + zip lives in dep_dir (clean only rmtree's build_dir), so this re-extracts offline. Returns True + on success; on failure the caller falls through to the regular post-clone probe.""" + self.create_build_dir_if_needed() + fetched, dependencies = artifactory_fetch_and_reconfigure(target) + if fetched and dependencies: + for dep_source in dependencies: self.add_child(dep_source) + return bool(fetched) + + def _load(self): conf = self.config if conf.verbose: @@ -414,12 +433,18 @@ def _load(self): self.create_build_dir_if_needed() if self.dep_source.is_git: loaded_from_pkg = self._try_artifactory_shim() - if not loaded_from_pkg: + # A clean deletes build dirs; it never needs source. Without this a dep whose shim marker a + # PREVIOUS clean removed gets cloned from scratch - minutes of git for a dir we then delete. + if not loaded_from_pkg and not conf.clean_only(): git_changed = self._git_checkout_if_needed() ## pull Git before loading target Mamafile target = self._load_target() ## load target for Git and Src if conf.clean and is_target: self.clean() ## requires a parsed mamafile target + # A plain `clean` rmtree'd the build dir, including a shim-loaded artifactory package's libs; + # re-extract it so dependents can still link. (`rebuild` dropped the shim above -> from source.) + if loaded_from_pkg: + loaded_from_pkg = self._reload_artifactory_after_clean(target) if not self.is_root and not loaded_from_pkg: # Post-clone probe catches target.version-pinned deps that the pre-clone shim couldn't predict. @@ -430,6 +455,9 @@ def _load(self): if conf.verbose: console(f' - Target {self.name: <16} load settings and dependencies') target.settings() ## customization point for project settings + if self.is_root: + conf.lock_compiler() # root settings() is the last prefer_clang/gcc; lock before any dep loads + self._update_dep_name_and_dirs(self.name) # build_dir was computed pre-flip, re-resolve it target.dependencies() ## customization point for additional dependencies if not loaded_from_pkg and self.is_root: @@ -443,12 +471,21 @@ def _load(self): git:Git = self.dep_source git.save_status(self) + self.load_action = self._display_load_action(loaded_from_pkg) # refine the breakdown letter (G/L/A) self.already_loaded = True self.should_rebuild = build if conf.list: self._print_list(conf, target) return build + def _display_load_action(self, loaded_from_pkg: bool) -> str: + """The load label for the display breakdown letter: artifactory (A) / local (L), else the + git action (check/clone/pulling -> G) already recorded during checkout.""" + if loaded_from_pkg: return 'artifactory' + if self.dep_source.is_src: return 'local' + return self.load_action + + def can_fetch_artifactory(self, print: bool, which: str): if self.is_root or self.did_check_artifactory: return False @@ -457,8 +494,11 @@ def can_fetch_artifactory(self, print: bool, which: str): disable_art = self.config.disable_artifactory is_target = self.is_current_target() - def noart(r): - if print and (self.config.print or force_art): + def noart(r, expected=False): + # `expected`: OUR decision to skip (a clean/rebuild ignores artifactory by design), so it's + # verbose-only - a second line per dep next to its CLEAN/BUILD line is pure noise. + show = self.config.verbose if expected else (self.config.print or force_art) + if print and show: warning(f' - Target {self.name: <16} NO ARTIFACTORY PKG [{which} {r}]') self.did_check_artifactory = True return False @@ -467,9 +507,9 @@ def noart(r): return noart('noart override') elif is_target and not force_art: # don't load during rebuild -- defer to source based builds in that case - if self.config.rebuild: return noart('target rebuild') + if self.config.rebuild: return noart('target rebuild', expected=True) # don't load anything during cleaning -- because it will get cleaned anyways - if self.config.clean: return noart('target clean') + if self.config.clean: return noart('target clean', expected=True) elif print and (self.config.verbose or force_art): warning(f' - Target {self.name: <16} CHECK ARTIFACTORY PKG [{which}]') @@ -498,8 +538,8 @@ def _print_list(self, conf, target): def _should_build(self, conf:BuildConfig, target:BuildTarget, is_target, git_changed, loaded_from_pkg): def build(r): if conf.print: - args = f'{target.args}' if target.args else '' - warning(f' - Target {target.name: <16} BUILD [{r}] {args}') + args = f' {target.args}' if target.args else '' + warning(f' - Target {target.name: <16} BUILD [{r}]{args}') return True # Artifactory shim: no source on disk, nothing to build from. The shim was @@ -509,7 +549,7 @@ def build(r): return False if conf.target and not is_target: # if we called: "target=SpecificProject" - return False # skip build if target doesn't match + return False # skip; mark_unbuilt_target_deps() revives the ones X actually needs, post-load ## build also entails packaging if conf.clean and is_target: return build('cleaned target') @@ -598,7 +638,7 @@ def create_build_target(self): return # load the default mama.BuildTarget class - mamaBuildTarget = getattr(sys.modules['mama.build_target'], 'BuildTarget') + from .build_target import BuildTarget as mamaBuildTarget # deferred: circular at import time mamaFilePath = self.mamafile_path() if mamaFilePath and self.config.verbose: exists = os.path.exists(mamaFilePath) diff --git a/mama/build_insights.py b/mama/build_insights.py new file mode 100644 index 0000000..5bd190f --- /dev/null +++ b/mama/build_insights.py @@ -0,0 +1,365 @@ +"""C++ Build Insights (Stage 2 of `buildstats`): wrap an MSVC build in a vcperf /timetrace session, then +parse the Chrome-trace JSON to show where compile time goes - frontend parse vs backend codegen, slowest +TUs, costliest headers. vcperf is a pure observer of whatever the build command compiled, so `build +buildstats` profiles the iterative build and `rebuild buildstats` the full one. Non-MSVC builds skip this.""" +import os, json, tempfile +import mama.util as util +from .util import get_time_str, normalized_path +from .utils.system import System, console, warning, Color, get_colored_text +from .utils.sub_process import SubProcess + +_SESSION = 'mama_buildstats' # one global ETW session name around the whole parallel build +_TIMEOUT = 600 # vcperf /stop relogs the whole trace; give it room on large builds +_VS_VCPERF_SUBPATH = 'Common7/IDE/CommonExtensions/Platform/CppBuildInsights/vcperf.exe' # bundled standalone +# /noadmin: capture without elevation (one-time `vcperf /grantusercontrol` from an elevated prompt is the +# prerequisite); /nocpusampling: skip the admin-only kernel sampling trace - timetrace needs only MSVC events. +_START = ('/start', '/noadmin', '/nocpusampling') +# Structural activity names (passes/threads/codegen wrappers) that carry no path/symbol of interest. +_STRUCTURAL = ('thread', 'pass', 'frontend', 'backend', 'code generation', 'codegeneration', + 'whole program', 'wholeprogram', 'optref', 'opticf', 'optlbr', 'ltcg', 'invocation') + + +def find_vcperf(config) -> str: + """Locate vcperf.exe: VCPERF env, then PATH, then the MSVC toolset bin and the bundled CppBuildInsights + folder (which ships vcperf's DLLs alongside). Returns '' if not found.""" + env = os.getenv('VCPERF') + if env and os.path.isfile(env): return env + found = util.find_executable_from_system('vcperf') + if found: return found + candidates = [] + try: candidates.append(f'{config.get_msvc_bin64()}vcperf.exe') + except Exception: pass + try: candidates.append(os.path.join(config.get_visualstudio_path(), _VS_VCPERF_SUBPATH)) + except Exception: pass + return next((c for c in candidates if c and os.path.isfile(c)), '') + + +def timetrace_path(build_dir: str) -> str: + """Where vcperf writes the trace: the root project's platform build dir, so other tools can find it at + a stable per-project location (packages///mama_timetrace.json), not a temp file.""" + return normalized_path(os.path.join(build_dir, 'mama_timetrace.json')) + + +def _start_reason(output: str) -> str: + """One concise, actionable line from vcperf's verbose /start failure blurb.""" + low = output.lower() + if 'grantusercontrol' in low: + return 'vcperf needs one-time setup - run `vcperf /grantusercontrol` from an elevated prompt' + if 'preventing vcperf' in low or 'failed to start' in low: + return 'another ETW trace is already running (try `vcperf /stop mama_buildstats` or elevated `xperf -stop`)' + return 'vcperf could not start a trace' + + +class VcPerfSession: + """Context manager: `/start /noadmin /nocpusampling` on enter, `/stop /timetrace ` on exit (always, + so an ETW session is never left open even if the build raised). Success is judged by whether the JSON was + written, not vcperf's exit code (which is unreliable - it errors on an event-less session yet still writes + a valid empty trace). A failed start degrades to a no-op with a one-line hint.""" + def __init__(self, vcperf: str, json_out: str, level='/level3'): + self.vcperf = vcperf; self.json_out = json_out; self.level = level; self.ok = False + + def _run(self, args) -> tuple: + out = [] + try: + st = SubProcess.run(args, io_func=lambda p, ln: out.append(ln), timeout=_TIMEOUT) + except Exception as e: + return 1, str(e) + return st, '\n'.join(out) + + def _clear_stale(self): + """Best-effort discard of a session left open by a crashed run, so /start can succeed. /stopnoanalyze + needs an output path; we write a throwaway ETL and delete it.""" + etl = normalized_path(os.path.join(tempfile.gettempdir(), 'mama_stale.etl')) + self._run([self.vcperf, '/stopnoanalyze', _SESSION, etl]) + try: os.remove(etl) + except OSError: pass + + def _start(self) -> tuple: + return self._run([self.vcperf, *_START, self.level, _SESSION]) + + def _grant_user_control(self) -> bool: + """One-time elevated `vcperf /grantusercontrol` (one UAC prompt) so /noadmin needs no elevation on + any later run. Windows-only; returns True if the elevated command completed.""" + if not System.windows: return False + console('buildstats: granting vcperf user-mode trace control (one-time; accept the elevation prompt)') + cmd = ['powershell', '-NoProfile', '-Command', + f"Start-Process -FilePath '{self.vcperf}' -ArgumentList '/grantusercontrol' -Verb RunAs -Wait"] + try: return SubProcess.run(cmd, io_func=lambda p, ln: None, timeout=120) == 0 + except Exception: return False + + def __enter__(self): + st, out = self._start() + if st != 0 and 'grantusercontrol' in out.lower() and self._grant_user_control(): + st, out = self._start() # /noadmin now permitted - retry + if st != 0: # otherwise maybe a stale session from a crashed run - clear it and retry once + self._clear_stale() + st, out = self._start() + self.ok = st == 0 + if not self.ok: warning(f'buildstats: {_start_reason(out)}; skipping Build Insights') + return self + + def __exit__(self, *exc): + if self.ok: + os.makedirs(os.path.dirname(self.json_out) or '.', exist_ok=True) # vcperf won't create the dir + try: os.remove(self.json_out) # so a leftover trace can't masquerade as this run's + except OSError: pass + self._run([self.vcperf, '/stop', _SESSION, '/timetrace', self.json_out]) + self.ok = os.path.exists(self.json_out) # trust the file, not vcperf's exit code + if not self.ok: warning('buildstats: vcperf produced no trace, skipping Build Insights') + return False # never suppress a build exception + + +# --- timetrace JSON parsing --------------------------------------------------------------------------- +# Format (vcperf TimeTraceGenerator): {"traceEvents":[...]} where ts/dur are microseconds. Childless +# entries are complete events (ph "X", carry dur); entries with children are begin/end pairs (ph "B"/"E"). +# Names: "CL Invocation N" / "Link Invocation N" (top-level, with File Input/Output args); a FrontEndFile +# carries its path as the name; a Function/template carries its symbol. So a path-separator in the name +# distinguishes a parsed file from a codegen symbol - no dependence on internal pass names. + +class _Node: + __slots__ = ('name', 'args', 'start', 'dur', 'children') + def __init__(self, name, args, start): + self.name = name; self.args = args; self.start = start; self.dur = 0.0; self.children = [] + + +def _build_tree(events) -> list: + roots, stack = [], [] + for ev in events: + ph = ev.get('ph') + if ph == 'E': + if stack: n = stack.pop(); n.dur = ev.get('ts', 0) - n.start + continue + n = _Node(ev.get('name', ''), ev.get('args'), ev.get('ts', 0)) + if ph == 'X': n.dur = ev.get('dur', 0) + (stack[-1].children if stack else roots).append(n) + if ph == 'B': stack.append(n) + return roots + + +def _is_path(name: str) -> bool: + return '\\' in name or '/' in name # MSVC symbol names carry no path separators + +def _base(name: str) -> str: + return name.replace('\\', '/').rsplit('/', 1)[-1] + +def _structural(name: str) -> bool: + low = name.lower() + return any(s in low for s in _STRUCTURAL) + +def _arg_values(args, key: str) -> list: + return [v for k, v in args.items() if k.startswith(key)] if args else [] + + +_FRONTEND_PASS, _BACKEND_PASS = 'FrontEndPass', 'BackEndPass' + +def _find_passes(node, fe: list, be: list): + """Collect the per-TU FrontEndPass/BackEndPass activities under a CL invocation (descending through + any wrapper nodes). With /MP one invocation runs MANY of each in parallel; summing their durations + gives aggregate CPU - which is what makes frontend/backend comparable (the invocation's own dur is + wall time, so aggregate parse could exceed it and clamp backend to zero - the bug this fixes).""" + for ch in node.children: + if ch.name == _FRONTEND_PASS: fe.append(ch) + elif ch.name == _BACKEND_PASS: be.append(ch) + else: _find_passes(ch, fe, be) + + +def _collect(node, depth: int, files: dict, symbols: dict, in_backend: bool) -> str: + """DFS of one compiler pass: aggregate each INCLUDED header (a FrontEndFile at depth>0) into `files` + and, under a backend pass, each codegen leaf into `symbols`. Returns the outermost source path (the + TU's own .cpp at depth 0) for labeling - it's not a header, so it never feeds `files`.""" + src = None + for ch in node.children: + if _is_path(ch.name): + if depth > 0: + agg = files.setdefault(_base(ch.name), [0.0, 0]); agg[0] += ch.dur; agg[1] += 1 + elif src is None: + src = ch.name + _collect(ch, depth + 1, files, symbols, in_backend) + else: + if in_backend and not ch.children and ch.dur > 0 and not _structural(ch.name): + symbols[ch.name] = symbols.get(ch.name, 0.0) + ch.dur + r = _collect(ch, depth, files, symbols, in_backend) + if src is None: src = r + return src + + +def _norm(p: str) -> str: + return p.replace('\\', '/').lower() + + +class TraceStats: + """Aggregated build-insights numbers, all durations in seconds. Lists are sorted slowest-first.""" + def __init__(self, compile_s, link_s, frontend_s, backend_s, wall_s, n_tu, tus, files, symbols): + self.compile_s = compile_s; self.link_s = link_s + self.frontend_s = frontend_s; self.backend_s = backend_s + self.wall_s = wall_s; self.n_tu = n_tu + self.tus = tus # [(label, seconds)] + self.files = files # [(basename, seconds, include_count)] + self.symbols = symbols # [(symbol, seconds)] + + @property + def empty(self) -> bool: + return self.n_tu == 0 and self.link_s <= 0 + + +def parse_timetrace(data: dict, scope_paths=None) -> TraceStats: + """Aggregate a loaded timetrace dict. `scope_paths` (dir prefixes) limits to one package's TUs by + File Input/Output path; None aggregates the whole build (root stats).""" + prefixes = [_norm(p) for p in scope_paths] if scope_paths else None + def in_scope(paths): + if not prefixes: return True + return any(any(_norm(p).startswith(pre) for pre in prefixes) for p in paths) + + frontend_us = backend_us = link_us = 0.0 + n_tu = 0 + tus, files, symbols = [], {}, {} + starts, stops = [], [] + for node in _build_tree(data.get('traceEvents', [])): + ins, outs = _arg_values(node.args, 'File Input'), _arg_values(node.args, 'File Output') + if not in_scope(ins + outs): continue + if node.name.startswith('CL Invocation'): + starts.append(node.start); stops.append(node.start + node.dur) + fe_passes, be_passes = [], [] + _find_passes(node, fe_passes, be_passes) + for fp in fe_passes: # one per TU (the .cpp's frontend), even under /MP + frontend_us += fp.dur; n_tu += 1 + src = _collect(fp, 0, files, symbols, in_backend=False) + tus.append((_base(src) if src else 'TU', fp.dur)) + for bp in be_passes: + backend_us += bp.dur + _collect(bp, 0, files, symbols, in_backend=True) + elif node.name.startswith('Link Invocation'): + link_us += node.dur + starts.append(node.start); stops.append(node.start + node.dur) + wall_us = (max(stops) - min(starts)) if starts else 0.0 + return _build_stats(frontend_us, backend_us, link_us, wall_us, n_tu, tus, files, symbols) + + +def _build_stats(frontend_us, backend_us, link_us, wall_us, n_tu, tus, files, symbols) -> TraceStats: + """Convert accumulated microseconds to a sorted TraceStats (compile = frontend + backend, aggregate).""" + us = 1e-6 + rank = lambda seq: sorted(seq, key=lambda t: t[1], reverse=True) + return TraceStats((frontend_us + backend_us) * us, link_us * us, frontend_us * us, backend_us * us, + wall_us * us, n_tu, rank((l, d * us) for l, d in tus), + rank((b, v[0] * us, v[1]) for b, v in files.items()), rank((s, d * us) for s, d in symbols.items())) + + +# --- Clang -ftime-trace (Linux/Clang deep dive) ------------------------------------------------------- +# One flat-event JSON per TU (events ph:"X", each with args.detail). Frontend/Backend are the phase +# totals; Source events carry a parsed file path; Instantiate*/OptFunction carry an ALREADY-demangled +# symbol. Linking isn't traced (link stays 0), and per-TU timelines are independent, so wall comes from +# the build clock, not the JSONs. +_CLANG_CODEGEN = ('InstantiateClass', 'InstantiateFunction', 'OptFunction') +_SRC_EXTS = ('.c', '.cc', '.cpp', '.cxx', '.c++', '.m', '.mm') + +def _is_header(path: str) -> bool: + return not _base(path).lower().endswith(_SRC_EXTS) # the TU's own .cpp isn't a header; STL headers have no ext + + +def parse_clang_traces(paths, wall_s=0.0) -> TraceStats: + """Aggregate Clang -ftime-trace JSONs (one per TU) into the same TraceStats vcperf produces, so the + Linux deep report renders identically. `wall_s` is the measured build wall time (the JSONs can't give it).""" + frontend_us = backend_us = 0.0 + n_tu = 0 + tus, files, symbols = [], {}, {} + for path in paths: + try: + with open(path, encoding='utf-8') as f: data = json.load(f) + events = data.get('traceEvents', []) if isinstance(data, dict) else [] # skip compile_commands.json etc + except (OSError, ValueError): + continue + fe = be = 0.0 + for ev in events: + if ev.get('ph') != 'X': continue + name, dur = ev.get('name', ''), ev.get('dur', 0) + detail = (ev.get('args') or {}).get('detail', '') + if name == 'Frontend': fe += dur + elif name == 'Backend': be += dur + elif name == 'Source' and detail and _is_header(detail): + agg = files.setdefault(_base(detail), [0.0, 0]); agg[0] += dur; agg[1] += 1 + elif name in _CLANG_CODEGEN and detail: + symbols[detail] = symbols.get(detail, 0.0) + dur + if fe or be: + n_tu += 1; frontend_us += fe; backend_us += be + tus.append((_base(path)[:-5] if path.endswith('.json') else _base(path), fe + be)) + return _build_stats(frontend_us, backend_us, 0.0, wall_s / 1e-6, n_tu, tus, files, symbols) + + +def collect_clang_traces(build_dir: str, since: float = 0.0) -> list: + """The `*.json` time-traces clang wrote into `build_dir`, modified at/after `since` (wall seconds) so + only THIS build's TUs are analyzed. Content-filtering (skip compile_commands.json etc) is left to the + parser. Sorted newest-first is irrelevant; the parser aggregates all.""" + import glob + out = [] + for p in glob.glob(os.path.join(build_dir, '**', '*.json'), recursive=True): + try: + if os.path.getmtime(p) >= since: out.append(p) + except OSError: + pass + return out + + +# --- rendering ---------------------------------------------------------------------------------------- +_TOP = 8 # rows per ranked section +_SYM_WIDTH = 100 # truncate a demangled symbol so a codegen row stays one line +_UNDNAME_NAME_ONLY = 0x1000 # dbghelp flag: drop return type / calling convention / params -> scope::name<...> +_undname = None # memoized (ctypes, UnDecorateSymbolName) or False; resolved once (the API is process-constant) + + +def _demangle(name: str) -> str: + """An MSVC-mangled symbol (`?...`) -> readable `scope::name<...>` via dbghelp's UnDecorateSymbolName + (NAME_ONLY drops the return type/params). No-op for a non-mangled name or off Windows.""" + global _undname + if not name.startswith('?') or not System.windows: + return name + if _undname is None: + try: + import ctypes + fn = ctypes.WinDLL('dbghelp.dll').UnDecorateSymbolName + fn.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint, ctypes.c_uint]; fn.restype = ctypes.c_uint + _undname = (ctypes, fn) + except Exception: + _undname = False + if not _undname: return name + ctypes, fn = _undname + buf = ctypes.create_string_buffer(8192) + n = fn(name.encode('utf-8', 'replace'), buf, len(buf), _UNDNAME_NAME_ONLY) + return buf.value.decode('utf-8', 'replace') if n else name + + +def _short(s: str, width=_SYM_WIDTH) -> str: + return s if len(s) <= width else s[:width - 3] + '...' + + +def _seg(label, color, s, whole=None) -> str: + pct = f' ({100*s/whole:4.1f}%)' if whole else '' # share-of-compile, omitted for link + return f'{get_colored_text(label, color)} {get_time_str(s):>8}{pct}' + + +def print_buildstats_deep(stats: TraceStats, scope_label: str): + """Stage 2 report: frontend/backend/link split + slowest TUs + costliest headers (+ codegen symbols).""" + console(f'\n Build Insights ({scope_label})', color=Color.BLUE) + if stats.empty: + console(' no compiler activity captured (nothing recompiled - try `rebuild buildstats`)') + return + + par = f'{stats.compile_s/stats.wall_s:.1f}x' if stats.wall_s > 0 else '-' + segs = [_seg('frontend', Color.MAGENTA, stats.frontend_s, stats.compile_s), + _seg('backend', Color.GREEN, stats.backend_s, stats.compile_s), + _seg('link', Color.BLUE, stats.link_s)] + console(f' {" ".join(segs)}') + console(f' {stats.n_tu} TU(s), {get_time_str(stats.compile_s)} compile over ' + f'{get_time_str(stats.wall_s)} wall ({par} parallel)') + + if stats.tus: + console(' slowest translation units:', color=Color.MAGENTA) + for name, s in stats.tus[:_TOP]: + console(f' {get_time_str(s):>8} {name}') + if stats.files: + console(' costliest headers (total parse, include count):', color=Color.MAGENTA) + for name, s, count in stats.files[:_TOP]: + console(f' {get_time_str(s):>8} x{count:<4} {name}') + if stats.symbols: + console(' costliest codegen:', color=Color.GREEN) + for name, s in stats.symbols[:_TOP]: + console(f' {get_time_str(s):>8} {_short(_demangle(name))}') diff --git a/mama/build_scheduler.py b/mama/build_scheduler.py new file mode 100644 index 0000000..c7d3c5a --- /dev/null +++ b/mama/build_scheduler.py @@ -0,0 +1,313 @@ +"""Parallel DAG scheduler for configure/build jobs: runs a graph of `Job`s honoring dep edges. +Governors: CONFIGURE jobs (cheap probes) are count-bounded; BUILD jobs (each spawns `cmake +--build -jN`) are gated by a core budget + CPU-load sample. Fail-fast: the first error stops new +launches AND fires the abort hook to kill in-flight children (so the build exits fast, not after +draining every sibling), then returns the failed job. Generic over `Job.run` (the cmake wiring +lives in the caller), so it unit-tests with fake jobs.""" + +from __future__ import annotations +import time, threading, concurrent.futures, contextlib +from types import SimpleNamespace +from typing import Callable, Iterable, List, Optional + +LOAD = 'load' # clone + parse mamafile + discover deps (network/IO bound) +CONFIGURE = 'configure' +BUILD = 'build' + + +class BuildInterrupted(RuntimeError): + """Raised in a build_slot barrier when the build is already failing/aborting, so a custom build() + waiting for budget bails instead of starting a new compile.""" + + +class Job: + def __init__(self, key, kind: str, run: Callable[[], None], + deps: Iterable['Job'] = (), weight=1, node=None, ungated=False): + self.key = key + self.kind = kind + self.run = run + self.deps = set(deps) + self.weight = weight # int, or a zero-arg callable resolved at launch (lazy TU probe) + self.node = node + self.ungated = ungated # BUILD that bypasses the CPU/budget gate (the root: runs alone after its deps) + self.started = False + self.done = False + self.error: Optional[BaseException] = None + self._rweight = 1 # weight resolved at launch, reused at completion + self.priority = 0.0 # critical-path bottom level: launch the longest-pole ready job first + + def __repr__(self): return f'Job({self.kind} {self.key})' + + +def _make_abort_job() -> Job: + """Synthetic job run() returns on Ctrl+C so the caller reports an interrupted (failed) build.""" + job = Job(('',), BUILD, run=(lambda: None), node=SimpleNamespace(name='')) + job.error = KeyboardInterrupt('build interrupted by Ctrl+C') + job.done = True + return job + + +def _check_acyclic(jobs: List[Job]): + """DFS cycle check; raises RuntimeError naming a node in the cycle.""" + WHITE, GREY, BLACK = 0, 1, 2 + color = {j: WHITE for j in jobs} + def visit(j: Job): + color[j] = GREY + for d in j.deps: + if d not in color: continue # dep outside this batch: already satisfied + if color[d] == GREY: raise RuntimeError(f'Cyclical dependency at {d}') + if color[d] == WHITE: visit(d) + color[j] = BLACK + for j in jobs: + if color[j] == WHITE: visit(j) + + +def build_dep_jobs(deps, configure_fn, build_fn, weight_fn=None, children_fn=None) -> List[Job]: + """Wire a configure + build job per dep: a dep's configure waits on every in-set child's build + (its dependencies.cmake embeds child outputs), its build on its own configure. `weight_fn(dep)` + gives the build's core weight, resolved lazily at launch. `children_fn` defaults to get_children. + Sets critical-path priorities so trunk deps launch first.""" + children_fn = children_fn or (lambda d: d.get_children()) + weight_fn = weight_fn or (lambda d: 1) + dep_set = set(deps) + cfg = {d: Job((d, 'c'), CONFIGURE, (lambda x=d: configure_fn(x)), node=d) for d in deps} + bld = {d: Job((d, 'b'), BUILD, (lambda x=d: build_fn(x)), weight=(lambda x=d: weight_fn(x)), + node=d, ungated=d.is_root) for d in deps} + for d in deps: + bld[d].deps.add(cfg[d]) + for child in children_fn(d): + if child in dep_set: cfg[d].deps.add(bld[child]) + jobs = list(cfg.values()) + list(bld.values()) + assign_priorities(jobs) + return jobs + + +def assign_priorities(jobs): + """Set job.priority to its critical-path DEPTH: the number of BUILD jobs on the longest chain of jobs + that depend on it (configure jobs are free). So a dep deep in the trunk - feeding the root through the + longest dependency chain - launches first. Purely structural: no (unreliable) build-time estimates.""" + jobs = list(jobs) + succ = {j: [] for j in jobs} + for j in jobs: + for d in j.deps: + if d in succ: succ[d].append(j) + memo = {} + def blevel(j): + v = memo.get(j) + if v is not None: return v + memo[j] = 0.0 # cycle guard (the graph is validated acyclic, but never recurse forever) + memo[j] = (1.0 if j.kind == BUILD else 0.0) + max((blevel(s) for s in succ[j]), default=0.0) + return memo[j] + for j in jobs: j.priority = blevel(j) + + +class Scheduler: + def __init__(self, *, max_configure: int, core_budget: int, max_load: int = 20, + load_threshold: float = 85.0, overprovision: float = 2.0, cpu_sampler: Callable[[], float] = None, + poll_interval: float = 1.0, max_workers: int = 256, + abort_hook: Callable[[], None] = None, pending_log: Callable[[Optional[tuple]], None] = None): + self._max_configure = max(1, max_configure) + self._core_budget = max(1, core_budget) + self._max_load = max(1, max_load) + self._load_threshold = load_threshold + self._overprovision = overprovision + self._cpu_sampler = cpu_sampler or (lambda: 0.0) + self._poll_interval = poll_interval + self._max_workers = max_workers + self._abort_hook = abort_hook # optional ()->None called on Ctrl+C to kill in-flight child processes + self._pending_log = pending_log # optional ((name,reason)|None)->None: the single next blocked task + self._aborted = False + self._cond = threading.Condition() + self._pending: List[Job] = [] # jobs not yet launched (grows dynamically via grow()) + self._running: set = set() # running jobs, for debug status + self._n_load = 0 # running LOAD jobs + self._n_config = 0 # running CONFIGURE jobs + self._n_slot = 0 # active build_slot() barriers (custom build()s compiling) + self._reserved = 0 # reserved cores of running BUILD jobs + acquired build_slots + self._n_running = 0 # total running jobs + self._cpu_now = 0.0 # system CPU%, sampled once per scheduler pass (reused by gate + debug) + self._gen = 0 # bumped on every state change, so a pass can tell it raced the display + self._error: Optional[Job] = None # first failed job + + def grow(self, build_fn: Callable[[], List[Job]]): + """Atomically extend the graph at runtime: `build_fn()` runs under the scheduler lock (so it + can safely mutate caller registries + add edges) and returns new jobs. Used by a LOAD job + that discovered children; the add happens before LOAD is marked done, so a parent's + CONFIGURE never launches before its children's edges exist.""" + with self._cond: + self._pending.extend(build_fn()) + self._gen += 1 + self._cond.notify_all() + + def run(self, jobs: List[Job]) -> Optional[Job]: + """Execute all jobs honoring deps + governors; returns the failed job or None. The graph may + grow via grow(); the loop ends only when nothing is pending AND nothing runs.""" + _check_acyclic(list(jobs)) + with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_workers) as ex: + try: + self._run_loop(ex, jobs) + except KeyboardInterrupt: + self._abort() # Ctrl+C: kill children + wake barriers so the pool drains fast + return self._error + + def _run_loop(self, ex, jobs: List[Job]): + """The scheduler pass loop. A KeyboardInterrupt here propagates to run()'s abort handler.""" + with self._cond: + self._pending = list(jobs) + while self._pending or self._n_running: + if self._error and not self._n_running: + break # failed and drained + self._cpu_now = self._cpu_sampler() # one sample per pass: reused by every gate check + debug + progressed = False + blocked: List[Job] = [] + if not self._error: + ready = sorted((j for j in self._pending if self._deps_done(j)), + key=lambda j: j.priority, reverse=True) # longest critical path first + for job in ready: + if self._can_launch(job): + self._pending.remove(job) + self._launch(job, ex) + progressed = True + else: + blocked.append(job) # deps ready but a governor held it back + if self._pending_log is not None: + # Terminal I/O must NOT hold the scheduler lock: a completing job takes the same lock, + # so a frame write would stall every launch and completion behind it. + hint, gen = self._pending_hint(blocked), self._gen + self._cond.release() + try: self._pending_log(hint) + finally: self._cond.acquire() + if self._gen != gen: continue # state moved while we drew: re-evaluate, don't sleep on it + if not progressed: + if self._n_running == 0 and self._pending and not self._error: + # nothing running and nothing launchable: the remaining jobs have unmet + # deps that no running job can satisfy -> a dependency cycle. Don't hang. + self._error = self._pending[0] + self._error.error = RuntimeError(f'Cyclical dependency at {self._error}') + break + # otherwise wait for a completion, a grow(), or a CPU re-sample + self._cond.wait(timeout=self._poll_interval) + + def _abort(self): + """Ctrl+C: stop launching, set a synthetic interrupted error, wake barrier waiters, then kill + in-flight children (outside the lock - kill() blocks up to ~1s each) so the pool drains fast.""" + with self._cond: + self._aborted = True + if self._error is None: self._error = _make_abort_job() + self._cond.notify_all() + if self._abort_hook: self._abort_hook() + + # -- governors --------------------------------------------------------- + + def _deps_done(self, job: Job) -> bool: + return all(d.done for d in job.deps) + + @staticmethod + def _resolve_weight(job: Job) -> int: + w = job.weight() if callable(job.weight) else job.weight + return max(0, int(w)) # 0 = unsizable build: reserves no budget, never blocks others + + def _can_launch(self, job: Job) -> bool: + if self._n_running >= self._max_workers: + return False + if job.kind == LOAD: + return self._n_load < self._max_load # network/IO bound: simple count cap + if job.kind == CONFIGURE: + return self._n_config < self._max_configure + if job.ungated: # BUILD the root: launch the moment its deps are done, no CPU/budget gate + return True + return self._build_admits(self._resolve_weight(job)) # BUILD + + def _build_admits(self, weight: int) -> bool: + """Budget gate shared by BUILD launch + build_slot() barriers: admit freely while reserved + cores stay within budget REGARDLESS of momentary CPU (one compile saturates the sampler; + gating on that stalled every build). CPU only gates over-provisioning + beyond budget. reserved==0 always admits, so one build always proceeds and no barrier deadlocks.""" + if self._reserved == 0: + return True + need = self._reserved + weight + if need <= self._core_budget: + return True + if self._cpu_now >= self._load_threshold: + return False + return need <= self._core_budget * self._overprovision + + @contextlib.contextmanager + def build_slot(self, weight: int): + """Acquire `weight` budget cores for a compile running INSIDE a job (a custom build()'s + cmake_build(), reached via system.build_barrier()): the worker thread suspends until the + budget admits, reserves, runs, then releases on exit. always-admit-when-idle = no deadlock.""" + weight = max(0, int(weight)) + with self._cond: + while True: + if self._error is not None: raise BuildInterrupted() # build failing/aborting: don't start a new compile + if self._build_admits(weight): break + self._cond.wait(timeout=self._poll_interval) + self._reserved += weight + self._n_slot += 1 + self._cond.notify_all() + try: + yield + finally: + with self._cond: + self._reserved -= weight + self._n_slot -= 1 + self._gen += 1 + self._cond.notify_all() + + @staticmethod + def _name(job: Job): + return job.node.name if job.node else str(job.key) # node is optional on a bare Job + + def _pending_hint(self, blocked: List[Job]): + """The single next task waiting to launch + why, for the live display: a governor-held job + (deps ready, gated by CPU/budget/slots) if any, else a job still waiting on its deps (up to + 3 named + a (+N) overflow). None if nothing's waiting (the scheduler is keeping up).""" + if blocked: + return (self._name(blocked[0]), self._block_reason(blocked[0])) + for job in self._pending: + undone = sorted(self._name(d) for d in job.deps if not d.done) # stable, alpha-ordered display + if undone: + more = f' (+{len(undone) - 3})' if len(undone) > 3 else '' + return (self._name(job), f'waiting for {", ".join(undone[:3])}{more}') + return None + + def _block_reason(self, job: Job) -> str: + if job.kind == LOAD: return f'clone slots full ({self._n_load}/{self._max_load})' + if job.kind == CONFIGURE: return f'configure slots full ({self._n_config}/{self._max_configure})' + w = self._resolve_weight(job) # BUILD: held by _build_admits (CPU gate or budget exhausted) + if self._cpu_now >= self._load_threshold: return f'cpu {self._cpu_now:.0f}% >= {self._load_threshold:.0f}%' + return f'budget {self._reserved}+{w} > {self._core_budget * self._overprovision:.0f}' + + def _account(self, job: Job, sign: int): + """Adjust the per-kind running counters (and reserved cores for BUILD) by +1/-1 in one place.""" + self._n_running += sign + if job.kind == LOAD: self._n_load += sign + elif job.kind == CONFIGURE: self._n_config += sign + else: self._reserved += sign * job._rweight + + def _launch(self, job: Job, ex: concurrent.futures.ThreadPoolExecutor): + job.started = True + self._running.add(job) + if job.kind == BUILD: job._rweight = self._resolve_weight(job) # fixed at launch, reused at completion + self._account(job, +1) + ex.submit(self._exec, job) + + def _exec(self, job: Job): + try: + job.run() + except BaseException as e: # noqa: BLE001 - surfaced to the caller via job.error + job.error = e + with self._cond: + job.done = True + self._running.discard(job) + self._account(job, -1) + self._gen += 1 + first_failure = job.error is not None and self._error is None + if first_failure: + self._error = job # first failure wins; stops further launches + self._cond.notify_all() + if first_failure and self._abort_hook: + # fail-fast: kill in-flight child processes (outside the lock - kill() blocks ~1s each) so the + # build exits now instead of draining every running sibling. notify_all above woke the barriers. + self._abort_hook() diff --git a/mama/build_target.py b/mama/build_target.py index 619f800..a33c8fb 100644 --- a/mama/build_target.py +++ b/mama/build_target.py @@ -8,7 +8,7 @@ from .types.artifactory_pkg import ArtifactoryPkg from .artifactory import artifactory_fetch_and_reconfigure -from .utils.system import System, console, Color, warning +from .utils.system import System, console, Color, warning, build_barrier from .utils.gdb import run_gdb, filter_gdb_arg from .utils.gtest import run_gtest from .utils.run import run_in_project_dir, run_in_working_dir, run_in_command_dir @@ -17,6 +17,7 @@ from .papa_upload import papa_upload_to import mama.msbuild as msbuild import mama.util as util +from ._version import __version__ import mama.cmake_configure as cmake import mama.package as package @@ -24,6 +25,11 @@ from .build_config import BuildConfig from .build_dependency import BuildDependency +# Non-library trees skipped by the source-file TU fallback (e.g. gtest is ~90% test/ + samples/). +_NON_LIB_DIRS = ['build', 'packages', 'libs', 'out', '.git', 'test', 'tests', 'samples', 'example', + 'examples', 'benchmark', 'benchmarks', 'doc', 'docs', 'third_party', 'thirdparty', + 'extern', 'external', 'vendor'] + ###################################################################################### @@ -90,6 +96,10 @@ def __init__(self, name, config:BuildConfig, dep:BuildDependency, args:List[str] self.exported_syslibs = [] # exported system libraries self.exported_assets: List[Asset] = [] # exported asset files self.packaging_result = '' # how we performed the package() step? + self._fetched = None # set by configure_phase: artifactory auto-fetch result, read by build_phase + self._did_configure = False # guards configure() to run once across configure/build phases + self._build_jobs = None # scheduler-sized -j for this target's build (None -> config.jobs) + self._out_sink = None # display sink for cmake output during a scheduled phase (None -> print) self.includes_root = ('','','') # if set, this is (parent_path, src_path, alias_name) for clean include deployment self.include_glob_filter = ['.h','.hpp','.hxx','.hh'] # default gather filter when deploying includes self.papa_path = None # recorded path for previous papa deployment @@ -756,6 +766,21 @@ def select(self, msvc, linux, macos, ios, android): return None + def requires_version(self, min_version: str): + """ + Require a minimum mamabuild version for this mamafile. Aborts the build immediately (during + target load, before any configure/build work) with an upgrade hint when the running mama is + older, instead of failing later on an API this mama doesn't have. + ``` + def settings(self): + self.requires_version('0.13.01') + ``` + """ + if util.version_at_least(__version__, min_version): return + raise RuntimeError(f'Target {self.name} requires mamabuild >= {min_version}, but this is {__version__}.' + \ + ' Upgrade with: pip install --upgrade mama') + + def prefer_gcc(self): """ Configures the entire build chain to prefer GCC if possible """ self.config.prefer_gcc(self.name) @@ -1007,17 +1032,20 @@ def get_cc_prefix(self): return os.path.join(os.path.dirname(cc), filename) - def run(self, command: str, src_dir=False, exit_on_fail=True): + def run(self, command: str, src_dir=False, exit_on_fail=True, quiet=False): """ Run a command in the build or source folder. Can be used for any custom commands or custom build systems. src_dir -- [False] If true, then command is relative to source directory. + quiet -- [False] Suppress the command's output entirely (it still runs and is exit-checked). + Use for noisy sub-steps like a script's own `git clone`. ``` self.run('./configure', src_dir=True) self.run('make release -j7') # run in build dir + self.run('./build_ffmpeg.sh', quiet=True) # silence a noisy custom script ``` """ - run_in_project_dir(self, command, src_dir, exit_on_fail) + run_in_project_dir(self, command, src_dir, exit_on_fail, quiet=quiet) def run_program(self, working_dir: str, command: str, exit_on_fail=True, env=None): @@ -1338,17 +1366,88 @@ def clean_target(self): self.dep.clean() + def _cmake_configure_step(self, out=None): + """CMake configure half of a build: ensure CMakeLists, inject env, run config.""" + self.dep.ensure_cmakelists_exists() + cmake.inject_env(self) + cmake.run_config(self, out=out) # THROWS on CMAKE failure + + def _cmake_build_step(self, out=None): + """CMake build+install half. -j was sized from the TU probe in configure_phase; size it + here too for the serial path (where configure_phase didn't run).""" + self._ensure_build_jobs() + cmake.run_build(self, install=True, out=out) # THROWS on CMAKE failure + + def _probe_build_jobs(self) -> int: + """TU count capped at config.jobs. 0 when nothing's countable (header-only / artifactory / + probe miss) -> weight 0, reserves no budget, never blocks real builds (it's a no-op anyway).""" + try: + n = self._count_tu()[0] + if n > 0: return min(n, self.config.jobs) + except Exception: pass + return 0 + + def _ensure_build_jobs(self) -> int: + """Lazily memoize the TU-probed -j. configure_phase sets it authoritatively post-configure; + this fills it in for the serial path / sched_debug where configure_phase never ran.""" + if self._build_jobs is None: self._build_jobs = self._probe_build_jobs() + return self._build_jobs + + def _reserved_cores(self) -> int: + """Budget cores this build occupies: the memoized TU probe (== its actual -j), capped at the + FULL pool. Reservation must equal -j so a heavy build (-j = all cores) reserves the whole pool + and runs ALONE at full threads instead of being oversubscribed against another full-j build; + small builds reserve little and pack. The CPU gate still overprovisions non-saturating builds. + 0 when unsizable (header-only / probe miss).""" + if not self._ensure_build_jobs(): return 0 + return min(self._build_jobs, self.config.jobs) + + def _count_tu(self) -> tuple: + """(TU count, method) - generator-agnostic, most-accurate first: + compile_commands.json (Ninja, or Make/VS only when export is on) -> "file" entries + *.vcxproj (Visual Studio generator) -> + CMakeFiles/**/DependInfo.cmake (Unix Makefiles, export off) -> one object per TU + C/C++ source files in the source tree -> cross-platform fallback + 0 when none match. The source walk skips build/vendored/test trees (see _NON_LIB_DIRS).""" + bd = self.build_dir() + cc = util.path_join(bd, 'compile_commands.json') + if os.path.exists(cc): + return util.read_text_from(cc).count('"file"'), 'compile_commands' + if os.path.isdir(bd): + n = sum(util.read_text_from(util.path_join(bd, fn)).count(' 0: return n, 'vcxproj' + n = self._count_makefile_tus(util.path_join(bd, 'CMakeFiles')) + if n > 0: return n, 'makefile' + src = self.source_dir() + if os.path.isdir(src): + srcs = util.glob_with_extensions(src, ['.c', '.cc', '.cpp', '.cxx', '.c++', '.cu', '.m', '.mm'], + exclude_dirs=_NON_LIB_DIRS) + return len(srcs), 'source' + return 0, 'none' + + @staticmethod + def _count_makefile_tus(cmakefiles_dir: str) -> int: + """Unix Makefiles generator: each target's DependInfo.cmake lists one object ('...o"') per TU.""" + if not os.path.isdir(cmakefiles_dir): return 0 + total = 0 + for dirpath, _, files in os.walk(cmakefiles_dir): + if 'DependInfo.cmake' in files: + total += util.read_text_from(util.path_join(dirpath, 'DependInfo.cmake')).count('.o"') + return total + def cmake_build(self): if self.config.print: console('\n\n#############################################################') console(f"CMakeBuild {self.name} ({self.cmake_build_type})") config_start = time.time() - self.dep.ensure_cmakelists_exists() - cmake.inject_env(self) - cmake.run_config(self) # THROWS on CMAKE failure + self._cmake_configure_step() config_stop = time.time() build_start = config_stop - cmake.run_build(self, install=True) # THROWS on CMAKE failure + # Barrier: a custom build() reaches here on a worker thread; suspend until the parallel + # scheduler grants budget for the compile (no-op on the serial path / no active scheduler). + with build_barrier(self._reserved_cores()): + self._cmake_build_step() build_stop = time.time() if self.config.print: e_config = util.get_time_str(config_stop - config_start) @@ -1422,20 +1521,64 @@ def try_automatic_artifactory_fetch(self): return None - ## TODO: move all of this into a new utility - def _execute_build_tasks(self): - can_build = not self.dep.nothing_to_build - if can_build and self.dep.should_rebuild and not self.dep.from_artifactory: - self.configure() # user customization - if can_build: - fetched = self.try_automatic_artifactory_fetch() - if not fetched: - self.build() # user build customization - - self.dep.successful_build() - if not fetched: - package.clean_intermediate_files(self) + def _build_work_enabled(self) -> bool: + """True when this target has real build work (not header-only, not artifactory, flagged for rebuild).""" + return not self.dep.nothing_to_build and self.dep.should_rebuild and not self.dep.from_artifactory + def _has_custom_build(self) -> bool: + """A mamafile overriding build() fuses cmake configure+build, so it can't be split; + the scheduler runs it whole in build_phase and skips the separate configure step.""" + return type(self).build is not BuildTarget.build + + def _run_configure_once(self): + """User configure() hook, guarded to run at most once (configure_phase / build_phase / serial).""" + if self._did_configure: return + self._did_configure = True + self.configure() # user customization + + def configure_phase(self, out=None): + """Scheduled CONFIGURE job: user configure() hook + cmake configure. No-op for a + no-work node or a custom build() (which owns its own configure inside build_phase).""" + self._out_sink = out # so a custom build()'s cmake output is captured too, not just the default path + if not self._build_work_enabled() or self._has_custom_build(): + return + self._run_configure_once() + self._fetched = self.try_automatic_artifactory_fetch() + if not self._fetched: + self._cmake_configure_step(out=out) + # Size build weight NOW (compile_commands.json exists) so the scheduler knows the core + # count at BUILD launch; left None it falls back to all cores -> serial builds. + self._build_jobs = self._probe_build_jobs() + + def build_phase(self, out=None): + """Scheduled BUILD job: compile (if any) then ALWAYS package - so no-work nodes + still package their exports in dependency order. Mirrors _execute_build_tasks.""" + self._out_sink = out # captures cmake output from a custom build()->cmake_build() too + if self._build_work_enabled(): + if self._has_custom_build(): + self._run_configure_once() # custom build's configure_phase was a no-op + self._fetched = self.try_automatic_artifactory_fetch() + if not self._fetched: + self.build() # user override owns configure+build + elif not self._fetched: + self._cmake_build_step(out=out) + self.dep.successful_build() + if not self._fetched: + package.clean_intermediate_files(self) + self._run_packaging() + + def _execute_build_tasks(self): + if self._build_work_enabled(): + self._run_configure_once() # user customization + fetched = self.try_automatic_artifactory_fetch() + if not fetched: + self.build() # user build customization + self.dep.successful_build() + if not fetched: + package.clean_intermediate_files(self) + self._run_packaging() + + def _run_packaging(self): # skip package() if we already fetched it as a package from artifactory() # unless user has specified for a local rebuild if self.dep.should_rebuild or not self.dep.from_artifactory: diff --git a/mama/cmake_compiler_cache.py b/mama/cmake_compiler_cache.py new file mode 100644 index 0000000..4e2acca --- /dev/null +++ b/mama/cmake_compiler_cache.py @@ -0,0 +1,279 @@ +"""Cross-build-dir reuse of CMake compiler detection (~5s of a ~6.5s cold configure, cut to ~1.7s). + +Mechanism (a warm reconfigure of the same dir reproduced in a fresh dir, validated cross-project): +capture the toolchain detection files (`CMakeFiles//CMake{C,CXX,RC}Compiler.cmake`, +`CMakeSystem.cmake`, the ABI `.bin`, VS `VCTargetsPath.txt`); inject them into a fresh build dir + +write a `CMakeCache.txt` with the `CMAKE_PLATFORM_INFO_INITIALIZED` marker (makes cmake trust the +cached info) + `CMAKE_HOME_DIRECTORY`. ONLY toolchain detection is transplanted, never project +flags - so a seed can't poison another project. The fingerprint auto-invalidates on toolchain +change; a failed seeded configure self-heals. Pure file/string ops + injected clock -> no-cmake tests.""" + +from __future__ import annotations +import os, shutil, hashlib, json, time, threading +from .util import path_join, normalized_path, read_text_from + +# lang -> (compiler module file, ABI probe binary or None) +_LANG_FILES = { + 'C': ('CMakeCCompiler.cmake', 'CMakeDetermineCompilerABI_C.bin'), + 'CXX': ('CMakeCXXCompiler.cmake', 'CMakeDetermineCompilerABI_CXX.bin'), + 'RC': ('CMakeRCCompiler.cmake', None), +} +_SHARED_FILES = ['CMakeSystem.cmake'] +_VS_FILES = ['VCTargetsPath.txt'] # VS-generator MSBuild probe result (reusable, toolset-bound) +_MANIFEST = 'seed.json' +# ABI facts CMakeDetermineCompilerABI puts in the CACHE, not the compiler module. Seeding skips that probe, +# so unless replayed every install-RPATH add_executable dies: 'not supported ... unless on an ELF-based platform'. +_ABI_CACHE_KEYS = ('CMAKE_EXECUTABLE_FORMAT', 'CMAKE_LIBRARY_ARCHITECTURE') +BACKSTOP_TTL = 7 * 24 * 3600 # seconds; fingerprint is the real gate, this is just paranoia + + +def compute_fingerprint(inputs: dict) -> str: + """Stable 16-hex hash of every toolchain input that affects detection (cmake version, generator, + compiler path+mtime+size, SDK, ...). A toolchain change flips the hash -> auto-invalidate.""" + blob = json.dumps(inputs, sort_keys=True, default=str) + return hashlib.sha1(blob.encode('utf-8')).hexdigest()[:16] + + +def compiler_stat(path: str) -> dict: + """Path + size + mtime of a compiler binary, for the fingerprint. {} if missing.""" + try: + st = os.stat(path) + return {'path': normalized_path(path), 'size': st.st_size, 'mtime': int(st.st_mtime)} + except OSError: + return {'path': path} + + +def detected_langs(build_files_dir: str) -> list: + """Which languages a build dir actually detected (by which compiler files it wrote).""" + return [lang for lang, (mod, _) in _LANG_FILES.items() + if os.path.exists(path_join(build_files_dir, mod))] + + +def detection_is_partial(build_files_dir: str) -> bool: + """True if a compiler module exists but its ABI/feature probe never finished (configure killed mid-detection). + cmake trusts such a stage-1 module on the next run, so every target_compile_features() dies with + 'no known features'. RC is skipped - it has no ABI step.""" + for lang, (mod, abi) in _LANG_FILES.items(): + if not abi: continue + path = path_join(build_files_dir, mod) + if not os.path.exists(path): continue + try: text = read_text_from(path) + except OSError: return True # unreadable module: distrust it, a needless redetect is the cheap outcome + if f'set(CMAKE_{lang}_ABI_COMPILED TRUE)' not in text: return True + return False + + +_CORE_LANGS = ('C', 'CXX') # RC is windows-only and has no ABI step, so it never gates a seed + + +def covers_core_langs(langs) -> bool: + """Backstop: the probe is a C+CXX project so its seed always covers both, but a seed that somehow + lacks one would let a project enabling it skip detection and die on 'CMAKE__COMPILER not set'.""" + return all(lang in (langs or []) for lang in _CORE_LANGS) + + +def _seed_file_names(langs: list) -> list: + names = list(_SHARED_FILES) + list(_VS_FILES) + for lang in langs: + mod, abi = _LANG_FILES[lang] + names.append(mod) + if abi: names.append(abi) + return names + + +def read_abi_cache_lines(build_dir: str) -> list: + """_ABI_CACHE_KEYS lines verbatim from a configured dir's CMakeCache.txt, for inject() to replay.""" + try: text = read_text_from(path_join(build_dir, 'CMakeCache.txt')) + except OSError: return [] + return [ln for ln in text.splitlines() if ln.split(':', 1)[0] in _ABI_CACHE_KEYS] + + +def publish(seed_dir: str, build_files_dir: str, fingerprint='', probe='', build_dir='', clock=time.time) -> bool: + """Capture detection artifacts from a freshly-configured `build_files_dir` + (`/CMakeFiles/`) into `seed_dir`. Returns False if nothing usable was found. Each + file lands via a temp + os.replace so a concurrent reader never copies a half-written file. The + manifest records `fingerprint` (so a load can re-verify it) and `probe` (the compiler binary whose + disappearance invalidates the seed).""" + langs = detected_langs(build_files_dir) + # never publish a half-detected toolchain, nor one missing a core language + if not covers_core_langs(langs) or detection_is_partial(build_files_dir): return False + os.makedirs(seed_dir, exist_ok=True) + copied = [] + for name in _seed_file_names(langs): + src = path_join(build_files_dir, name) + if os.path.exists(src): + dst = path_join(seed_dir, name) + shutil.copy2(src, dst + '.tmp'); os.replace(dst + '.tmp', dst) # atomic: no partial reads + copied.append(name) + manifest = {'created': int(clock()), 'cmake_files_ver': os.path.basename(build_files_dir.rstrip('/')), + 'langs': langs, 'files': copied, 'fingerprint': fingerprint, 'probe': probe, + 'abi_cache': read_abi_cache_lines(build_dir) if build_dir else []} + mtmp = path_join(seed_dir, _MANIFEST + '.tmp') + with open(mtmp, 'w', encoding='utf-8') as f: json.dump(manifest, f) + os.replace(mtmp, path_join(seed_dir, _MANIFEST)) # manifest last + atomic (load() gates on it) + return True + + +def is_valid(manifest, fingerprint: str) -> bool: + """Cheap recheck that a loaded seed still matches the live toolchain: its embedded fingerprint equals + the current one AND the recorded compiler binary still exists. Pure stat/compare - never runs cmake.""" + if not manifest or manifest.get('fingerprint') != fingerprint: + return False + if not covers_core_langs(manifest.get('langs')): + return False # a seed missing C or CXX can't serve a project that enables it + probe = manifest.get('probe') + return not probe or os.path.exists(probe) + + +def gc_stale(seed_root: str, log=lambda m: None): + """One cheap sweep of sibling seeds: drop any that can't be valid anymore - a legacy seed from an + older mama (no fingerprint recorded) or one whose compiler binary is gone (an upgraded/removed + toolset). A seed for a still-installed toolchain is untouched, so alternate-config seeds survive.""" + try: names = os.listdir(seed_root) + except OSError: return + for name in names: + sd = path_join(seed_root, name) + m = load(sd, ttl=float('inf')) + if m is None: continue # no manifest (maybe a publish in flight) - leave it + probe = m.get('probe') + if 'fingerprint' not in m: + log(f' drop stale seed {name} (legacy: no fingerprint)'); purge(sd) + elif probe and not os.path.exists(probe): + log(f' drop stale seed {name} (compiler gone: {probe})'); purge(sd) + + +def load(seed_dir: str, ttl=BACKSTOP_TTL, clock=time.time): + """Return the manifest dict if a valid (present + not past the backstop TTL) seed exists, else None.""" + mpath = path_join(seed_dir, _MANIFEST) + if not os.path.exists(mpath): return None + try: + with open(mpath, encoding='utf-8') as f: manifest = json.load(f) + except (OSError, ValueError): + return None + if clock() - manifest.get('created', 0) > ttl: + return None + return manifest + + +def inject(seed_dir: str, build_dir: str, build_files_dir: str, src_dir: str) -> bool: + """Make a fresh `build_dir` look already-configured so cmake skips ALL detection: copy the + captured toolchain files into CMakeFiles/ + write a CMakeCache.txt with the + PLATFORM_INFO_INITIALIZED marker + CMAKE_HOME_DIRECTORY. Returns False writing NO marker when the + seed is empty or vanished mid-copy (a concurrent heal), so the caller detects normally instead of + trusting a marker with no compiler files.""" + manifest = load(seed_dir, ttl=float('inf')) + os.makedirs(build_files_dir, exist_ok=True) + copied = 0 + for name in (manifest.get('files', []) if manifest else []): + src = path_join(seed_dir, name) + if not os.path.exists(src): continue + try: + shutil.copy2(src, path_join(build_files_dir, name)); copied += 1 + except OSError: + return False # seed file vanished under us (a concurrent purge): bail, don't trust it + if not copied: return False + cache = (f'CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1\n' + f'CMAKE_HOME_DIRECTORY:INTERNAL={normalized_path(src_dir)}\n') + cache += ''.join(f'{line}\n' for line in manifest.get('abi_cache', [])) # facts the skipped ABI probe would set + with open(path_join(build_dir, 'CMakeCache.txt'), 'w', encoding='utf-8') as f: + f.write(cache) + return True + + +def purge(seed_dir: str): + """Drop a seed (self-heal after a seeded configure fails). Never raises.""" + shutil.rmtree(seed_dir, ignore_errors=True) + + +class Coordinator: + """Builds ONE seed per fingerprint from a synthetic C+CXX probe, then injects it into every fresh build + dir. Probing instead of piggybacking on a real target is what makes it safe: the seed always covers both + core languages. In-process election; a cross-process race just probes twice for identical content.""" + + def __init__(self, seed_root, fp_fn, paths_fn, probe_fn=None, seed_fn=None, enabled=True, clock=time.time, + wait_timeout=180.0, log_fn=None): + self._root = seed_root + self._fp = fp_fn + self._paths = paths_fn + self._probe = probe_fn or (lambda t: '') # compiler binary recorded so a dead toolset self-invalidates + self._seed_fn = seed_fn # context manager: (target) -> (build_dir, build_files_dir) of a C+CXX probe + self._enabled = enabled + self._clock = clock + self._wait = wait_timeout + self._log = log_fn or (lambda m: None) + self._lock = threading.Lock() + self._states: dict = {} # fp -> {'event': Event, 'ok': bool} + self._gc_done = False + + def seed_dir(self, target) -> str: + return path_join(self._root, self._fp(target)) + + def status(self, target) -> tuple: + """(fingerprint, seed-present) for this target - verbose diagnostics, even when prepare is skipped.""" + fp = self._fp(target) + return fp, os.path.exists(path_join(self._root, fp)) + + def begin_session(self): + """Once per mama session (not per package): log the seed root and sweep stale seeds. Called from + the coordinator factory so it runs even when every build dir is already configured (prepare skipped).""" + with self._lock: + if self._gc_done: return + self._gc_done = True # flag set under lock first -> exactly one thread sweeps, the rest skip + if not self._enabled: + self._log('compiler-seed cache: disabled (nocache)'); return + self._log(f'compiler-seed cache: {self._root}') + gc_stale(self._root, self._log) + + def _try_use(self, target, fp) -> bool: + """Load this fp's seed and, if it's still valid, inject it. Purges a present-but-stale seed so a + clean one gets rebuilt (toolset moved, or a legacy seed with no fingerprint).""" + sd = self.seed_dir(target) + m = load(sd, clock=self._clock) + if is_valid(m, fp) and inject(sd, *self._paths(target)): + return True + if m is not None: purge(sd) + return False + + def prepare(self, target) -> str: + """'use' (seed injected into the fresh build dir, cmake skips detection) or 'none' (detect + normally). The first caller per fingerprint runs the probe; the rest wait for it, then inject.""" + if not self._enabled: return 'none' + self.begin_session() + fp = self._fp(target) + if self._try_use(target, fp): return 'use' + with self._lock: + elected = fp not in self._states + if elected: self._states[fp] = {'event': threading.Event(), 'ok': False} + st = self._states[fp] + if elected: + ok = self._build_seed(target, fp) + self._finish(fp, ok) + else: + st['event'].wait(self._wait) # the probe is running - wait for it + ok = st['ok'] + return 'use' if ok and self._try_use(target, fp) else 'none' + + def _build_seed(self, target, fp) -> bool: + """Run the synthetic probe and publish its detection. Never raises: a probe failure just means + everyone detects normally.""" + if self._seed_fn is None: return False + try: + with self._seed_fn(target) as paths: # the probe's temp tree lives until publish has copied it + if not paths: return False + build_dir, build_files_dir = paths + return publish(self.seed_dir(target), build_files_dir, fingerprint=fp, + probe=self._probe(target), build_dir=build_dir, clock=self._clock) + except Exception as e: + self._log(f'compiler-seed probe failed: {e}') + return False + + def heal(self, target): + """A seeded ('use') configure failed: drop the seed so the retry detects clean.""" + purge(self.seed_dir(target)) + + def _finish(self, fp, ok): + with self._lock: + st = self._states.pop(fp, None) # pop so a failed probe can be retried later + if st: + st['ok'] = ok + st['event'].set() diff --git a/mama/cmake_configure.py b/mama/cmake_configure.py index 5de36a0..c337597 100644 --- a/mama/cmake_configure.py +++ b/mama/cmake_configure.py @@ -1,18 +1,18 @@ from __future__ import annotations from typing import TYPE_CHECKING -import os +import os, contextlib, re, shutil, tempfile, threading from .utils.system import System, console, Color, warning -from .utils.sub_process import SubProcess, execute_piped_echo +from .utils.sub_process import SubProcess, execute_piped_echo, execute_piped from mama import util +from mama import cmake_compiler_cache as seedcache if TYPE_CHECKING: from .build_target import BuildTarget from .build_config import BuildConfig -def _rerunnable_cmake_conf(cmd, cwd, allow_rerun, target:BuildTarget, delete_cmakecache:bool = False): +def _rerunnable_cmake_conf(cmd, cwd, allow_rerun, target:BuildTarget, delete_cmakecache:bool = False, env=None, out=None): rerun = False - error = '' if target.config.verbose: console(cmd) if delete_cmakecache: @@ -21,7 +21,8 @@ def _rerunnable_cmake_conf(cmd, cwd, allow_rerun, target:BuildTarget, delete_cma def handle_output(p:SubProcess, line:str): nonlocal rerun, delete_cmakecache - print(line) # newline is not included + if out: out(line) + else: console(line) # NOT print: a raw write tears the live region's cursor math if line.startswith('CMake Error: The source'): rerun = True delete_cmakecache = True @@ -34,13 +35,15 @@ def handle_output(p:SubProcess, line:str): delete_cmakecache = True # run CMake configure and handle output - exit_status = SubProcess.run(cmd, cwd, io_func=handle_output) + exit_status = SubProcess.run(cmd, cwd, env=env, io_func=handle_output) if rerun and allow_rerun: if target.config.print: console('Rerunning CMake configure') - return _rerunnable_cmake_conf(cmd, cwd, False, target, delete_cmakecache=delete_cmakecache) + return _rerunnable_cmake_conf(cmd, cwd, False, target, delete_cmakecache=delete_cmakecache, env=env, out=out) if exit_status != 0: - raise Exception(f'CMake configure error: {error}') + # BuildError, not Exception: the cmake output above already says what's wrong, so this is + # reported as a clean one-liner instead of a traceback through mama's internals. + raise util.BuildError(f'CMake configure failed for {target.name} (exit code {exit_status})') target.dep.save_enabled_sanitizers() target.dep.save_enabled_coverage() @@ -48,30 +51,223 @@ def handle_output(p:SubProcess, line:str): def _set_compiler_paths(target:BuildTarget, opt:list[str]): """ Configures compilers for CMake, this needs to be done every time to prevent Ninja - or other backends incorrectly picking wrong compilers. + or other backends incorrectly picking wrong compilers. CC/CXX are stripped from the + subprocess env by `compute_env` (not the global os.environ), so this is thread-safe. """ cc, cxx, ver = target.config.get_preferred_compiler_paths() if cc: - cc_normalized = util.forward_slashes(cc) - opt.append(f'CMAKE_C_COMPILER={cc_normalized}') - if 'CC' in os.environ: - del os.environ['CC'] # remove CC env var to avoid conflicts, since CMake prioritizes this option + opt.append(f'CMAKE_C_COMPILER={util.forward_slashes(cc)}') if target.enable_cxx_build: - cxx_normalized = util.forward_slashes(cxx) - opt.append(f'CMAKE_CXX_COMPILER={cxx_normalized}') - if 'CXX' in os.environ: - del os.environ['CXX'] # remove CXX env var to avoid conflicts, since CMake prioritizes this option + opt.append(f'CMAKE_CXX_COMPILER={util.forward_slashes(cxx)}') elif 'CC' in os.environ or 'CXX' in os.environ: warning('Warning: CMake C/C++ compiler not detected and Global ENV CC/CXX are set') +def compute_env(target:BuildTarget) -> dict: + """Per-job cmake env: a COPY of os.environ with CC/CXX removed when we pass explicit + -DCMAKE_*_COMPILER (cmake prioritizes CC/CXX otherwise). Fresh dict -> thread-safe.""" + env = os.environ.copy() + cc, cxx, _ = target.config.get_preferred_compiler_paths() + if cc: + env.pop('CC', None) + if target.enable_cxx_build: env.pop('CXX', None) + return env + + +# a run of backslash-separated path segments, e.g. `\windows\bin\protoc.exe`. Deliberately NOT a bare +# backslash: an escaped quote (\") or a literal separator define (-DSEP=\\) must survive untouched. +_BACKSLASH_PATH = re.compile(r'(?:\\[\w.\-+~$()]+)+') + + def _opts_to_defines(opts:list[str]) -> str: + """`-D` flags for the cmake command line. Backslash PATHS become forward slashes because SubProcess + shlex-splits the command and would eat them: a mamafile passing a raw Windows path via + add_cmake_options() silently arrives as C:ProjectsfoobinX.exe. cmake takes / on every platform.""" opts_defines = '' - for opt in opts: opts_defines += '-D'+opt+' ' + for opt in opts: + opts_defines += '-D' + _BACKSLASH_PATH.sub(lambda m: m.group(0).replace('\\', '/'), opt) + ' ' return opts_defines -def run_config(target:BuildTarget): +_seed_lock = threading.Lock() + + +def _cmake_version_number(config) -> str: + """Parsed cmake version (e.g. '4.2.3'), which is also the CMakeFiles/ dir name. Cached.""" + v = config._cmake_ver_num + if v is None: + out = execute_piped([config.cmake_command, '--version'], throw=False) or '' + nums = [ln.split()[-1] for ln in out.splitlines() if 'version' in ln.lower()] + v = nums[0] if nums else 'unknown' + config._cmake_ver_num = v + return v + + +def _build_files_dir(target:BuildTarget) -> str: + return util.path_join(target.build_dir(), f'CMakeFiles/{_cmake_version_number(target.config)}') + + +def _seed_src_dir(target:BuildTarget) -> str: + """The dir cmake configures (the CMAKE_HOME_DIRECTORY the injected cache must match).""" + d = os.path.dirname(target.dep.cmakelists_path()) + return d if d else target.source_dir() + + +def _seed_paths(target:BuildTarget): + return (target.build_dir(), _build_files_dir(target), _seed_src_dir(target)) + + +_TOOLCHAIN_KEYS = ('CMAKE_TOOLCHAIN_FILE', 'CMAKE_SYSTEM_NAME', 'CMAKE_SYSTEM_PROCESSOR', + 'CMAKE_OSX_SYSROOT', 'CMAKE_OSX_ARCHITECTURES', 'CMAKE_C_COMPILER', 'CMAKE_CXX_COMPILER') + + +def _toolchain_inputs(target:BuildTarget) -> dict: + """Cross-compile inputs that change compiler detection but aren't caught by cc/cxx stat. Keyed off the + whole platform opt set (an SDK move changes CMAKE_SYSROOT, not the 7 obvious keys); the toolchain file + is stat'd so an edit in place invalidates too. Empty dict for a native build.""" + out = {} + for opt in _platform_opts(target) + [o for o in target.cmake_opts if o.partition('=')[0] in _TOOLCHAIN_KEYS]: + k, _, v = opt.partition('=') + out[k] = seedcache.compiler_stat(v.strip('"')) if k == 'CMAKE_TOOLCHAIN_FILE' else v + return out + + +def _seed_probe(target:BuildTarget) -> str: + """The compiler binary whose disappearance means the seed is stale (an upgraded/removed toolset). + For MSVC that's the toolset's cl.exe - which `get_preferred_compiler_paths` leaves empty - so resolve + it explicitly. seedcache records it and GC checks it cheaply with os.path.exists.""" + config = target.config + if config.msvc: + try: return util.normalized_path(config.get_msvc_cl64()) + except Exception: return '' + _, cxx, _ = config.get_preferred_compiler_paths() + return util.normalized_path(cxx) if cxx else '' + + +def _seed_inputs(target:BuildTarget) -> dict: + config = target.config + cc, cxx, ver = config.get_preferred_compiler_paths() + inputs = { + 'cmake': _cmake_version_number(config), 'gen': _generator(target), + 'arch': config.arch, 'platform': config.platform_build_dir_name(), + 'cc': seedcache.compiler_stat(cc) if cc else {}, 'cxx': seedcache.compiler_stat(cxx) if cxx else {}, + 'cver': ver, 'sdk': os.environ.get('WindowsSDKVersion', ''), 'toolchain': _toolchain_inputs(target), + 'stdlib': _abi_stdlib(config), # libc++ vs libstdc++ changes the CXX ABI probe's implicit link libs + } + if config.msvc: # MSVC leaves cc/cxx empty, so stat cl.exe directly - else a toolset upgrade is invisible + inputs['msvc'] = seedcache.compiler_stat(_seed_probe(target)) + elif not cc: # no explicit compiler -> CC/CXX env selects it, so they belong in the fingerprint + inputs['env_cc'] = os.environ.get('CC', ''); inputs['env_cxx'] = os.environ.get('CXX', '') + return inputs + + +def _abi_stdlib(config) -> str: + """The -stdlib that reaches the CXX ABI probe, '' where it isn't a choice (only linux clang picks).""" + return config.clang_stdlib if (config.linux and config.clang) else '' + + +def _abi_flags(config) -> tuple: + """(C, CXX) flags that change what the ABI probe records as implicit link libs, so the seed must be + detected with them. A sanitizer pulls its runtime into both; -stdlib is C++-only (clang warns on C).""" + san = [f'-fsanitize={config.sanitize}'] if (config.sanitize and not config.msvc) else [] + stdlib = [f'-stdlib={_abi_stdlib(config)}'] if _abi_stdlib(config) else [] + return ' '.join(san), ' '.join(san + stdlib) + + +_SEED_PROJECT = 'cmake_minimum_required(VERSION 3.15)\nproject(mama_seed C CXX)\n' + + +@contextlib.contextmanager +def _probe_toolchain(target:BuildTarget): + """Detect the toolchain in a throwaway C+CXX project, not in whichever real target configures first (a + C-only one would seed no CXX). Yields (build_dir, build_files_dir), or None if it didn't cover both. + Context manager: the temp tree lives exactly until publish has copied it; mkdtemp avoids collisions.""" + config = target.config + c_abi, cxx_abi = _abi_flags(config) # same ABI inputs as the real targets, per language + flags = (f' -DCMAKE_C_FLAGS="{c_abi}"' if c_abi else '') + (f' -DCMAKE_CXX_FLAGS="{cxx_abi}"' if cxx_abi else '') + opts = [] + _set_compiler_paths(target, opts) + opts += _platform_opts(target) # sysroot + cross binutils: without these the probe detects the HOST + with tempfile.TemporaryDirectory(prefix='mama_seed_', ignore_cleanup_errors=True) as tmp: + tmp = util.normalized_path(tmp) # shlex eats backslashes: never interpolate a raw Windows path + src, bld = util.path_join(tmp, 'src'), util.path_join(tmp, 'b') + os.makedirs(src, exist_ok=True) + util.write_text_to(util.path_join(src, 'CMakeLists.txt'), _SEED_PROJECT) + cmd = f'{target.cmake_command} {_generator(target)} {_opts_to_defines(opts)}{flags} -S "{src}" -B "{bld}"' + if config.verbose: console(f' seed probe: {cmd}', color=Color.BLUE) + if SubProcess.run(cmd, tmp, env=compute_env(target), io_func=lambda p, line: None) != 0: + yield None; return + files_dir = util.path_join(bld, f'CMakeFiles/{_cmake_version_number(config)}') + yield (bld, files_dir) if seedcache.covers_core_langs(seedcache.detected_langs(files_dir)) else None + + +def _seed_coordinator(target:BuildTarget) -> seedcache.Coordinator: + """Lazily build the per-run, config-shared Coordinator. Seed lives in the workspace `packages` + dir (dirname(dirname(build_dir))) so deleting `packages/` purges it.""" + config = target.config + co = config._seed_coord + if co is not None: return co + with _seed_lock: + co = config._seed_coord + if co is None: + root = util.path_join(os.path.dirname(os.path.dirname(target.build_dir())), '.mama_compiler_seed') + log = (lambda m: console(m, color=Color.BLUE)) if config.verbose else None + co = seedcache.Coordinator(root, fp_fn=lambda t: seedcache.compute_fingerprint(_seed_inputs(t)), + paths_fn=_seed_paths, probe_fn=_seed_probe, seed_fn=_probe_toolchain, + log_fn=log, + enabled=not config.no_compiler_cache) + co.begin_session() # once per session: log root + sweep stale seeds (even if every dir is configured) + config._seed_coord = co + return co + + +def _wipe_build_dir(target:BuildTarget): + """Drop CMakeCache + CMakeFiles so a self-heal retry detects cleanly.""" + cache = target.build_dir('CMakeCache.txt') + if os.path.exists(cache): os.remove(cache) + shutil.rmtree(util.path_join(target.build_dir(), 'CMakeFiles'), ignore_errors=True) + + +def cache_generator(cache_text:str) -> str: + """The CMAKE_GENERATOR recorded in a CMakeCache ('Ninja', 'Unix Makefiles', ...), '' if absent. + Matches the exact key so CMAKE_GENERATOR_PLATFORM/_TOOLSET/_INSTANCE don't get picked up.""" + for line in cache_text.splitlines(): + if line.startswith('CMAKE_GENERATOR:'): + return line.split('=', 1)[1].strip() if '=' in line else '' + return '' + + +def generator_build_file_exists(build_dir:str, generator:str) -> bool: + """Does the build file THIS generator emits exist? Deliberately generator-specific: targets pick + their own build system, so a leftover Makefile from an earlier Unix-Makefiles configure must NOT + make a Ninja-configured dir look complete - `cmake --build` would then die on a missing build.ninja. + An unrecognized generator is trusted (let cmake decide) rather than wrongly wiped.""" + gen = generator.lower() + if 'ninja' in gen: return os.path.exists(util.path_join(build_dir, 'build.ninja')) + if 'makefiles' in gen: return os.path.exists(util.path_join(build_dir, 'Makefile')) + if 'visual studio' in gen: return any(f.endswith('.sln') for f in os.listdir(build_dir)) + if 'xcode' in gen: return any(f.endswith('.xcodeproj') for f in os.listdir(build_dir)) + return True + + +def is_cmake_cache_valid(build_dir:str) -> bool: + """True only if `build_dir` holds the artifacts of a configure that ran to COMPLETION - a plain existence + check misses three poisoned shapes: truncated cache (no CMAKE_GENERATOR), no generated build file, and + the other generator's stale leftover file. All three -> reconfigure.""" + cache = util.path_join(build_dir, 'CMakeCache.txt') + if not os.path.exists(cache): return False + try: generator = cache_generator(util.read_text_from(cache)) + except OSError: return False # unreadable cache is as good as missing -> reconfigure + if not generator: return False + return generator_build_file_exists(build_dir, generator) + + +def _sink(target, out): + return out if out is not None else target._out_sink # capture even custom build()s + + +def run_config(target:BuildTarget, out=None, _seed=True): + out = _sink(target, out) must_configure = target.config.update or target.config.run_cmake_configure # also reconfigure if sanitizer flags changed if not must_configure: @@ -80,7 +276,15 @@ def run_config(target:BuildTarget): if current_sanitizers != previous_sanitizers: must_configure = True - if not must_configure and os.path.exists(target.build_dir('CMakeCache.txt')): + # A cache or a compiler detection left half-written by a killed configure poisons this run; drop both + # so it reconfigures clean instead of trusting what merely EXISTS. Detection is checked even with no + # cache at all: a kill mid-detection often saves none, and a `use` seed would re-add the marker. + if seedcache.detection_is_partial(_build_files_dir(target)) \ + or (os.path.exists(target.build_dir('CMakeCache.txt')) and not is_cmake_cache_valid(target.build_dir())): + if target.config.print: + warning(f' - Target {target.name: <16} incomplete build dir (interrupted configure) - rebuilding it') + _wipe_build_dir(target) + elif not must_configure and os.path.exists(target.build_dir('CMakeCache.txt')): if target.config.verbose: console('Not running CMake configure because CMakeCache.txt exists and `update` or `configure` was not specified') return @@ -89,42 +293,63 @@ def run_config(target:BuildTarget): options = target.cmake_opts + _default_options(target) + target.get_product_defines() cmake_defines = _opts_to_defines(options) generator = _generator(target) - src_dir = os.path.dirname(target.dep.cmakelists_path()) - src_dir = src_dir if src_dir else target.source_dir() + src_dir = _seed_src_dir(target) install_prefix = '-DCMAKE_INSTALL_PREFIX="."' # # use install prefix override for libraries, but for root target, leave it open-ended # install_prefix = '' if target.dep.is_root else '-DCMAKE_INSTALL_PREFIX="."' + + # Reuse cached compiler detection on a fresh build dir: prepare() injects a CMakeFiles seed + + # a PLATFORM_INFO_INITIALIZED CMakeCache so cmake skips ALL detection (~5s) (validated correct). + cache_exists = os.path.exists(target.build_dir('CMakeCache.txt')) + coord = _seed_coordinator(target) + role = coord.prepare(target) if (_seed and not cache_exists) else 'none' + if target.config.verbose and _seed: + fp, present = coord.status(target) + outcome = role if not cache_exists else 'skip (CMakeCache exists)' + console(f' seed[{target.name}] fp={fp} {"hit" if present else "miss"} -> {outcome}', color=Color.BLUE) + cmd = f'{target.cmake_command} {generator} {type_flags} {cmake_defines} {install_prefix} "{src_dir}"' - _rerunnable_cmake_conf(cmd, target.build_dir(), True, target) + try: + _rerunnable_cmake_conf(cmd, target.build_dir(), True, target, env=compute_env(target), out=out) + except Exception: + if role == 'use': # a stale seed can only cost one extra detection: drop it, retry clean + coord.heal(target) + _wipe_build_dir(target) + return run_config(target, out=out, _seed=False) + raise + + +_RERUNNABLE_ERRORS = ( + 'Makefile: No such file or directory', # configure died before emitting the makefile + "loading 'build.ninja': No such file or directory", # ...or the ninja file (same, Ninja generator) + 'CMAKE_GENERATOR in Cache', # cache truncated by a killed configure +) def is_rerunnable_error(output:str): """ Checks output string if a rerunnable error occurred. These are non-fatal errors that disappear with a simple cmake configure. """ - return 'Makefile: No such file or directory' in output + return any(s in output for s in _RERUNNABLE_ERRORS) -def run_build(target:BuildTarget, install:bool, extraflags='', rerun=True): +def run_build(target:BuildTarget, install:bool, extraflags='', rerun=True, out=None): + out = _sink(target, out) build_dir = target.build_dir() flags = _build_config(target, install) extraflags = _buildsys_flags(target) - opt = [] - _set_compiler_paths(target, opt) # only running this to clean the env vars cmd = f'{target.cmake_command} --build {build_dir} {flags} {extraflags}' if target.config.verbose: console(cmd, color=Color.GREEN) - status, output = execute_piped_echo(build_dir, cmd, echo=True) + status, output = execute_piped_echo(build_dir, cmd, echo=True, env=compute_env(target), out=out) if status != 0: if rerun and is_rerunnable_error(output): if target.config.verbose: console(f'Build {target.name} failed, attempting to rerun config', color=Color.GREEN) - cmake_cache = target.build_dir('CMakeCache.txt') - if os.path.exists(cmake_cache): - os.remove(cmake_cache) - run_config(target) - run_build(target, install, extraflags, rerun=False) + _wipe_build_dir(target) # cache AND CMakeFiles: a partial cache leaves stale detection behind + run_config(target, out=out) + run_build(target, install, extraflags, rerun=False, out=out) else: - raise Exception(f'{cmd} failed with return code {status}') + raise util.BuildError(f'Build failed for {target.name} (exit code {status})') def _generator(target:BuildTarget): @@ -150,6 +375,53 @@ def _make_program(target:BuildTarget): return '' +def _platform_opts(target:BuildTarget) -> list: + """The cross-compile setup that shapes toolchain DETECTION: sysroot, cross binutils, find-root modes, + toolchain file. Config-level only - no project flags - so the seed probe and the seed fingerprint can + both use it and stay target-independent.""" + config:BuildConfig = target.config + opt = [] + if config.msvc: + if config.is_target_arch_x86(): ## need to override the toolset host + opt.append('CMAKE_GENERATOR_TOOLSET=host=x86') + elif config.android: + opt += config.android.get_cmake_build_opts(target) + elif config.raspi: + opt += [ + 'RASPI=TRUE', + 'CMAKE_SYSTEM_NAME=Linux', + 'CMAKE_SYSTEM_VERSION=1', + 'CMAKE_SYSTEM_PROCESSOR=armv7-a', # ALWAYS ARMv7 + 'CMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER', # Use our definitions for compiler tools + 'CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY', # Search for libraries and headers in the target directories only + 'CMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY', + ] + if target.cmake_raspi_toolchain: + toolchain = target.source_dir(target.cmake_raspi_toolchain) + config.announce_once('toolchain', f'Toolchain: {toolchain}') + opt += [f'CMAKE_TOOLCHAIN_FILE="{toolchain}"'] + elif config.yocto_linux: + opt += config.yocto_linux.get_cmake_build_opts() + elif config.mips: + opt += config.mips.get_cmake_build_opts() + elif config.macos: + pass + elif config.ios: + opt += [ + 'IOS_PLATFORM=OS', + 'CMAKE_SYSTEM_NAME=Darwin', + 'CMAKE_XCODE_EFFECTIVE_PLATFORMS=-iphoneos', + 'CMAKE_OSX_ARCHITECTURES=arm64', # ALWAYS ARM64 + #'CMAKE_OSX_SYSROOT=/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk', + 'CMAKE_OSX_SYSROOT=iphoneos', + ] + if target.cmake_ios_toolchain: + toolchain = target.source_dir(target.cmake_ios_toolchain) + config.announce_once('toolchain', f'Toolchain: {toolchain}') + opt += [f'CMAKE_TOOLCHAIN_FILE="{toolchain}"'] + return opt + + def _default_options(target:BuildTarget): config:BuildConfig = target.config cxxflags:dict = target.cmake_cxxflags @@ -185,12 +457,15 @@ def get_flags_string(flags:dict): if not exceptions: add_flag('-fno-exceptions') + if config.buildstats and config.clang: # instrument for the Linux/Clang buildstats deep dive + add_flag('-ftime-trace') # per-TU Chrome-trace JSON written beside each .o (GCC has no equivalent) + if config.android: config.android.get_cxx_flags(add_flag) elif config.linux: add_flag('-march', config.get_gcc_linux_march()) if config.clang and target.enable_cxx_build: - add_flag('-stdlib', 'libc++') + add_flag('-stdlib', config.clang_stdlib) # config.use_gcc_stdlib_for_clang() picks libstdc++ elif config.macos: add_flag('-march', config.get_gcc_linux_march()) if target.enable_cxx_build: @@ -281,44 +556,7 @@ def get_flags_string(flags:dict): make = _make_program(target) if make: opt.append(f'CMAKE_MAKE_PROGRAM="{make}"') - if config.msvc: - if config.is_target_arch_x86(): ## need to override the toolset host - opt.append('CMAKE_GENERATOR_TOOLSET=host=x86') - elif config.android: - opt += config.android.get_cmake_build_opts(target) - elif config.raspi: - opt += [ - 'RASPI=TRUE', - 'CMAKE_SYSTEM_NAME=Linux', - 'CMAKE_SYSTEM_VERSION=1', - 'CMAKE_SYSTEM_PROCESSOR=armv7-a', # ALWAYS ARMv7 - 'CMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER', # Use our definitions for compiler tools - 'CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY', # Search for libraries and headers in the target directories only - 'CMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY', - ] - if target.cmake_raspi_toolchain: - toolchain = target.source_dir(target.cmake_raspi_toolchain) - if config.print: console(f'Toolchain: {toolchain}') - opt += [f'CMAKE_TOOLCHAIN_FILE="{toolchain}"'] - elif config.yocto_linux: - opt += config.yocto_linux.get_cmake_build_opts() - elif config.mips: - opt += config.mips.get_cmake_build_opts() - elif config.macos: - pass - elif config.ios: - opt += [ - 'IOS_PLATFORM=OS', - 'CMAKE_SYSTEM_NAME=Darwin', - 'CMAKE_XCODE_EFFECTIVE_PLATFORMS=-iphoneos', - 'CMAKE_OSX_ARCHITECTURES=arm64', # ALWAYS ARM64 - #'CMAKE_OSX_SYSROOT=/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk', - 'CMAKE_OSX_SYSROOT=iphoneos', - ] - if target.cmake_ios_toolchain: - toolchain = target.source_dir(target.cmake_ios_toolchain) - if config.print: console(f'Toolchain: {toolchain}') - opt += [f'CMAKE_TOOLCHAIN_FILE="{toolchain}"'] + opt += _platform_opts(target) return opt @@ -339,14 +577,23 @@ def _build_config(target:BuildTarget, install:bool): return conf +def _jobs(target:BuildTarget) -> int: + """Build parallelism for this target: a scheduler-sized `_build_jobs` (from the TU probe) + when set, else the global `config.jobs`. Per-target so concurrent builds never clobber + a shared `-j` value. The root runs alone after all deps, so it always gets full `config.jobs`.""" + if target.dep.is_root: return target.config.jobs + return target._build_jobs or target.config.jobs + + def _mp_flags(target:BuildTarget): config:BuildConfig = target.config if not target.enable_multiprocess_build: return '' - if config.msvc: return f'/maxcpucount:{config.jobs}' - if target.enable_unix_make: return f'-j{config.jobs}' - if config.ios: return f'-jobs {config.jobs}' - if config.macos: return f'-jobs {config.jobs}' - return f'-j{config.jobs}' + jobs = _jobs(target) + if config.msvc: return f'/maxcpucount:{jobs}' + if target.enable_unix_make: return f'-j{jobs}' + if config.ios: return f'-jobs {jobs}' + if config.macos: return f'-jobs {jobs}' + return f'-j{jobs}' def _buildsys_flags(target:BuildTarget): diff --git a/mama/dependency_chain.py b/mama/dependency_chain.py index 2e79613..71c6d75 100644 --- a/mama/dependency_chain.py +++ b/mama/dependency_chain.py @@ -1,17 +1,17 @@ -import os, concurrent.futures +import os, sys, shutil, time, concurrent.futures from typing import List from mama.build_config import BuildConfig from .build_dependency import BuildDependency -from .util import read_text_from, write_text_to, save_file_if_contents_changed -from .utils import ssh_multiplex -from .utils.system import Color, console, error +from ._version import __version__ +from .util import MAMA_SHIM_FILENAME, read_text_from, write_text_to, save_file_if_contents_changed, get_time_str, BuildError +from .utils import ssh_multiplex, system +from .utils.sub_process import SubProcess +from .utils.system import Color, console, error, warning, get_colored_text def _get_cmake_path_list(paths): - pathlist = '' - for path in paths: pathlist += f'\n "{path}"' - return pathlist + return ''.join(f'\n "{path}"' for path in paths) def _get_exported_libs(target): @@ -44,15 +44,15 @@ def _get_exported_libs(target): def _get_hierarchical_libs(root: BuildDependency): + """Exported libs of `root` and every dependency below it, in Unix link order (a lib comes after + everything that references it). Walks get_flat_deps - which already dedups with the [parent][child] + keep-last ordering - instead of a raw recursive DFS, which re-appended a shared dep's libs once per + path through the graph (a shared lib repeated dozens of times on one link line).""" deps = [] syslibs = [] - def add_deps(dep: BuildDependency): - nonlocal deps, syslibs + for dep in get_flat_deps(root): deps += _get_exported_libs(dep.target) syslibs += dep.target.exported_syslibs - for child in dep.get_children(): - add_deps(child) - add_deps(root) return deps + syslibs @@ -79,6 +79,44 @@ def get_flat_child_deps(dep: BuildDependency): return _get_flattened_deps(dep) +def mark_unbuilt_target_deps(root: BuildDependency, config: BuildConfig): + """`build target=X` builds only X - but a dep X NEEDS with nothing on disk must build too, or X compiles + against an include dir that doesn't exist. Scoped to X's own subtree: marking every unbuilt dep in the + workspace would build unrelated targets, and a mamafile that shells out to `mama build target=Y` would + then re-enter itself - a fork bomb.""" + target = find_dependency(root, config.target) + if target is None: return + for dep in get_flat_child_deps(target): + if dep.should_rebuild or dep.has_usable_artifacts(): continue + dep.should_rebuild = True + if config.print: warning(f' - Target {dep.name: <16} BUILD [dependency of {target.name} not built yet]') + + +# files mama writes into a build dir - one must be present before the sweep will delete a directory +_BUILD_DIR_MARKERS = ('CMakeCache.txt', 'mama-dependencies.cmake', 'mama_exported_libs', MAMA_SHIM_FILENAME, + 'papa.txt', 'git_status', 'mamafile_tag') + + +def sweep_orphaned_build_dirs(root: BuildDependency, config: BuildConfig) -> int: + """`clean all` promises to clean EVERYTHING for this platform, but the tree walk can't reach a dep + whose source is gone (an earlier clean removed its shim, so it parses no mamafile and declares no + children) - those children's build dirs would then survive every future clean. Enumerate from disk + as well, deleting only dirs that carry a mama marker file. Returns how many were removed.""" + workspace = os.path.dirname(root.dep_dir) + platform = config.platform_build_dir_name() + removed = 0 + try: names = os.listdir(workspace) + except OSError: return 0 + for name in names: + build_dir = os.path.join(workspace, name, platform) + if not os.path.isdir(build_dir): continue + if not any(os.path.exists(os.path.join(build_dir, m)) for m in _BUILD_DIR_MARKERS): continue + if config.print: console(f' - Target {name: <16} CLEAN {platform} (orphaned)') + shutil.rmtree(build_dir, ignore_errors=True) + removed += 1 + return removed + + def get_deps_only_targets(root: BuildDependency, deps_only_target_name: str, config: BuildConfig): """ For `deps_only` with a specific target, returns (flat_deps, flat_deps_reverse) @@ -169,20 +207,33 @@ def _get_compile_commands_path(dep: BuildDependency): return None +_COMPILER_TAGS = ('clang', 'gcc', 'msvc') + + def _find_matching_platform_config(dep: BuildDependency, configurations): config_name = dep.config.name() config_arch = dep.config.arch - - # first look for Platform + Arch match such as Windows x64 - for conf in configurations: - name = str(conf["name"]).lower() - if config_name in name and config_arch in name: - return conf - - # then look for only Platform match like 'windows' - for conf in configurations: - name = str(conf["name"]).lower() - if config_name in name: + compiler = 'clang' if dep.config.clang else ('gcc' if dep.config.gcc else '') + + def compiler_ok(name): + """Never repoint a config named for another compiler, eg 'Linux GCC' during a clang build.""" + return not any(tag in name and tag != compiler for tag in _COMPILER_TAGS) + + def match_text(conf): + """intelliSenseMode ('linux-gcc-x64') is structured and carries the arch; name is free text.""" + return f'{conf.get("intelliSenseMode", "")} {conf["name"]}'.lower() + + # most specific first: platform+arch+compiler, then platform+compiler, + # then the old platform+arch / platform passes, skipping foreign-compiler configs + for require_compiler, require_arch in ((True, True), (True, False), (False, True), (False, False)): + for conf in configurations: + name = match_text(conf) + if config_name not in name or not compiler_ok(name): + continue + if require_arch and config_arch not in name: + continue + if require_compiler and (not compiler or compiler not in name): + continue return conf return None @@ -434,7 +485,8 @@ def load_dependency_chain(root: BuildDependency): `serial` on the command line to disable parallel loading if you hit issues. """ - if root.config.update and not root.config.serial_load: + # Parallel by default (`serial` opts out): load() + add_child are now thread-safe. + if not root.config.serial_load: root.config.parallel_load = True ssh_multiplex.init_fetch_semaphore(root.config.parallel_max) @@ -516,6 +568,458 @@ def execute_task_chain(flat_deps_reverse: List[BuildDependency]): # print_dependencies(dep) # TODO: different output for non-root targets +def _make_display(config): + import sys, shutil, time + from .utils.build_display import BuildDisplay + from .utils.log_writer import open_build_log + out = sys.stdout + isatty = bool(getattr(out, 'isatty', lambda: False)()) + root = config.workspaces_root + log = open_build_log(os.path.join(root, 'packages', 'mamabuild.log')) if root else None + return BuildDisplay(out, isatty=isatty, clock=time.monotonic, + term_size=lambda: tuple(shutil.get_terminal_size((100, 24))), + verbose=config.verbose, log=log) + + +# Shared by the two parallel runners (execute_task_chain_parallel, execute_unified). +def _phase_label(dep, kind) -> str: + # 'load' opens optimistically (clone if no tree yet, else check) then _run_phase relabels it to + # what load() actually did (dep.load_action: check/clone/pulling); others show verbatim. + if kind == 'load': return 'clone' if not dep.is_real_clone() else 'check' + return kind + + +def _run_phase(display, dep, kind, body, build_slot, detail='', final=False): + """Run one scheduler phase for `dep` on its single dep-level display task (keyed by name so all + phases share one line): route this thread's console output + subprocess CPU + build barrier into + it, run `body(sink)`, then end the phase. `final=True` (the build) commits the merged summary.""" + tid = dep.name + sink = lambda line: display.feed(tid, line) + name = f'{_node_marker(dep)} {dep.name}' if dep.config.verbose else dep.name # tree markers: verbose only + display.start_task(tid, _phase_label(dep, kind), name, detail) + ok = False; t0 = time.monotonic() + try: + with system.capture_to(sink, display, tid, build_slot): # console + CPU + build barrier + body(sink) + ok = True + finally: + pt = dep.phase_times # accumulate for the `buildstats` breakdown + if pt is not None: pt[kind] = pt.get(kind, 0.0) + (time.monotonic() - t0) + if kind == 'load': display.relabel(tid, dep.load_action) # reflect what load() actually did + display.finish_task(tid, ok, final) + + +def _configure_body(dep, sink): + _save_mama_cmake_and_dependencies_cmake(dep) # children built -> their exports are ready + dep.target.configure_phase(out=sink) + + +def _build_body(dep, sink): + dep.target.build_phase(out=sink) + dep.already_executed = True + _save_vscode_compile_commands(dep) + + +def _stable_cpu_sampler(measure, clock, window=0.5): + """Gate `measure()` (CPU% since its last call) to >=`window`-second re-samples, caching between. + The scheduler polls at irregular sub-100ms-to-1s gaps; over a tiny window cpu_percent(interval=None) + reads a meaningless spiky 0% or 100%, so only re-measure once a real window has elapsed.""" + state = {'t': clock(), 'val': 0.0} + def sample(): + now = clock() + if now - state['t'] >= window: + state['val'] = measure(); state['t'] = now + return state['val'] + return sample + + +# Build-job overprovisioning (max reserved cores = core_budget * this). MSVC/MSBuild tolerates 2x; on Linux +# the build is memory-bound (below) - GCC/make already saturates the cores - so overprovisioning beyond the +# RAM-capped budget only risks OOM. _GB_PER_COMPILE is a heavy-C++ TU's peak RSS; total RAM / it caps how +# many parallel compiles we allow so a swarm can't take down a memory-limited box (a WSL-killer). +_OVERPROVISION_WIN, _OVERPROVISION_UNIX = 2.0, 1.0 +_GB_PER_COMPILE = 1.5 + + +def _mem_capped_budget(jobs: int) -> int: + """Cap the core budget by RAM so parallel heavy C++ compiles can't OOM. Never below 1 or above `jobs`.""" + import psutil + gb = psutil.virtual_memory().total / (1024 ** 3) + return max(1, min(jobs, int(gb / _GB_PER_COMPILE))) + + +def _make_scheduler(config, **extra): + """The build Scheduler with a stable psutil CPU sampler and the Ctrl+C child-killer.""" + import psutil, time + from .build_scheduler import Scheduler + cpu = psutil.cpu_count() or 4 + psutil.cpu_percent(interval=None) # prime the sampler (first call always returns 0.0) + win = system.System.windows + budget = config.jobs if win else _mem_capped_budget(config.jobs) # Linux: don't OOM on parallel C++ compiles + extra.setdefault('overprovision', _OVERPROVISION_WIN if win else _OVERPROVISION_UNIX) + return Scheduler(max_configure=min(cpu * 2, 32), core_budget=budget, abort_hook=SubProcess.terminate_all, + cpu_sampler=_stable_cpu_sampler(lambda: psutil.cpu_percent(interval=None), time.monotonic), + **extra) + + +def _handle_failure(display, failed): + """First failed job -> replay its captured output (TTY) + the reason, then RETURN so the caller can + still print the aggregate diagnostics summary before exiting nonzero. A Ctrl+C abort prints a terse + interrupted line (no replay/summary) and exits immediately.""" + import traceback + if isinstance(failed.error, KeyboardInterrupt): + console(' [BUILD INTERRUPTED] stopped by Ctrl+C', color=Color.RED) + exit(-1) + console(f' [BUILD FAILED] {failed.node.name}', color=Color.RED) + if display.isatty: # non-TTY already dumped the output on finish + display.replay(failed.node.name) + if not failed.error: return + # A BuildError is the user's build breaking, not a mama bug: the cmake/compiler output above already + # explains it, so print a one-liner. A traceback here would bury that under mama's own call stack. + # Anything else IS unexpected - keep the traceback (and keep it for BuildError under verbose too). + verbose = failed.node.config.verbose + if isinstance(failed.error, BuildError) and not verbose: + error(f' {failed.error}') + else: + console(''.join(traceback.format_exception(type(failed.error), failed.error, failed.error.__traceback__))) + + +def _deploy_run_postpass(deps, config): + """Deploy/run/test post-pass: target-specific, cheap, kept serial and children-first.""" + for dep in deps: + dep.target._execute_deploy_tasks() + dep.target._execute_run_tasks() + if config.verbose and not config.test and dep.is_root_or_config_target(): + print_dependencies(dep) + + +def execute_task_chain_parallel(flat_deps_reverse: List[BuildDependency]): + """Parallel counterpart of execute_task_chain: a DAG scheduler runs each dep's configure and + build as separate jobs (configure waits on children's builds). Deploy/run/test stay serial.""" + import time + from .build_scheduler import build_dep_jobs + deps = list(flat_deps_reverse) + config = deps[0].config + root = next((d for d in deps if d.is_root), deps[-1]) + display = _make_display(config) + sched = _make_scheduler(config, pending_log=display.set_pending) + cfg = lambda d: _run_phase(display, d, 'configure', lambda s: _configure_body(d, s), sched.build_slot) + bld = lambda d: _run_phase(display, d, 'build', lambda s: _build_body(d, s), sched.build_slot, + _build_detail(d), final=True) # build is the dep's last phase -> commit its summary + jobs = build_dep_jobs(deps, cfg, bld, weight_fn=_reserve_weight) # sets critical-path (trunk) priorities + system.set_active_display(display) + start = time.monotonic() + with _build_insights_session(config, root): # MSVC buildstats: wrap the build in a vcperf trace (else no-op) + try: + failed = sched.run(jobs) + finally: + display.close() + system.set_active_display(None) + SubProcess.clear_abort() # re-arm spawning (run() returned -> all workers drained) + if failed is not None: + _handle_failure(display, failed) # replay the failed target + traceback (scroll up for detail) + _print_diagnostics(display, deps, failed.node.name); exit(-1) # ...then the aggregate summary last + _print_build_summary(deps, time.monotonic() - start) + _print_diagnostics(display, deps) + if config.buildstats: + print_buildstats(deps) + _print_build_insights(config, deps) + _deploy_run_postpass(flat_deps_reverse, config) + + +def _reserve_weight(dep) -> int: + """Cores reserved for a build job AT LAUNCH. The root is ungated and runs alone, and a custom + build() reserves from inside cmake_build() (the barrier): both launch free (0). A default build + reserves its capped cores.""" + if dep.is_root or dep.target._has_custom_build(): return 0 + return dep.target._reserved_cores() + + +def _build_detail(dep) -> str: + cores = dep.config.jobs if dep.is_root else dep.target._reserved_cores() # root runs alone at full -j + return f'J{cores:<2}' + + +def _node_marker(dep) -> str: + """[R]oot / [L]eaf (no deps of its own) / [T]runk (has deps) - quick visual of tree position.""" + if dep.is_root: return '[R]' + return '[L]' if not dep.get_children() else '[T]' + + +def print_sched_debug(root: BuildDependency): + """TEMP diagnostic (CLI: sched_debug): print each target's build-weight calc WITHOUT building. + Reads existing build-dir artifacts, so it runs in seconds for fast iteration on the TU probe.""" + deps = get_flat_deps(root) + console(f' {"target":<22}{"TU":>6} {"via":<16}{"probe":>6}{"reserve":>9}{"-j":>5} flags', color=Color.BLUE) + for d in deps: + t = d.target + try: tu, via = t._count_tu() + except Exception as e: tu, via = -1, f'ERR:{type(e).__name__}' + probe = t._probe_build_jobs() + reserve = t._reserved_cores() # canonical reserve (== actual -j); was a stale jobs//2 formula + flags = [] + if t._has_custom_build(): flags.append('custom-build') # -> configure skips probe -> -j=config.jobs + if d.nothing_to_build: flags.append('nothing_to_build') + if d.from_artifactory: flags.append('artifactory') + console(f' {d.name:<22}{tu:>6} {via:<16}{probe:>6}{reserve:>9}{probe:>5} {" ".join(flags)}') + + +def _print_build_summary(deps, elapsed: float): + """End-of-session line: how many targets actually compiled (cached/artifactory ones excluded).""" + built = sum(1 for d in deps if d.should_rebuild and not d.from_artifactory and not d.nothing_to_build) + console(f'Built {built} target(s) in {get_time_str(elapsed)}', color=Color.GREEN) + + +_DIAG_LIMIT = 8 # compiler warnings/errors surfaced per target in the post-build summary + + +def _print_diagnostics(display, deps, failed_name=None): + """Post-build: surface the compiler warnings/errors the live display swallowed on a successful + build (parallel builds only replay output on failure). Up to _DIAG_LIMIT per target, errors first. + `failed_name` (the target that broke the build) is listed FIRST, so the reason to fix is the last + thing on screen instead of being buried under unrelated siblings' warnings.""" + ordered = sorted(deps, key=lambda d: d.name != failed_name) if failed_name else deps + printed = False + for dep in ordered: + diags, n_err, n_warn = display.diagnostics(dep.name, _DIAG_LIMIT) + if not diags: continue + if not printed: console('\n Compiler diagnostics:', color=Color.BLUE); printed = True + counts = ', '.join(f'{n} {w}(s)' for n, w in ((n_err, 'error'), (n_warn, 'warning')) if n) + console(f' {dep.name}: {counts}' + (' <-- BUILD FAILED HERE' if dep.name == failed_name else '')) + for sev, text in diags: # a cmake block spans lines; keep its body indented under the header + (error if sev == 'error' else warning)(f' {text}'.replace('\n', '\n ')) + if n_err + n_warn > len(diags): console(f' ... (+{n_err + n_warn - len(diags)} more)') + + +# buildstats (stage 1): a normalized horizontal bar per package, segmented load/configure/build. +_BAR_FILL = 40 # the slowest package fills this width; the rest scale down proportionally +_BUILDSTATS_FLOOR = 0.33 # omit packages faster than this - they're noise on the chart +_BAR = (('load', Color.BLUE), ('configure', Color.MAGENTA), ('build', Color.GREEN)) +_GLYPHS_SHADE = ('░', '▒', '▓') # light/medium/dark blocks (UTF-8 terminals) +_GLYPHS_ASCII = ('-', '=', '#') # legacy code-page fallback (Windows cp1252 can't encode the blocks) +_glyphs_cache = None + + +def _can_encode_blocks(encoding) -> bool: + try: ''.join(_GLYPHS_SHADE).encode(encoding); return True + except (UnicodeEncodeError, LookupError): return False + + +def _bar_glyphs(): + """Block shades on a UTF-8 terminal, ASCII on a legacy code page. Decided ONCE - the output + encoding is constant per process, so there's no point re-testing it per report or per row.""" + global _glyphs_cache + if _glyphs_cache is None: + _glyphs_cache = _GLYPHS_SHADE if _can_encode_blocks(getattr(sys.stdout, 'encoding', None) or 'ascii') \ + else _GLYPHS_ASCII + return _glyphs_cache + + +def _buildstats_bar(times: dict, total: float, max_total: float, glyphs) -> str: + """Bar whose length scales with total/max_total; inside, load/configure/build take shares of that + length (shaded, coloured). Right-padded to full width so the trailing total aligns across rows.""" + bar_len = max(1, round(total / max_total * _BAR_FILL)) if max_total > 0 else 0 + out, used, last = [], 0, len(_BAR) - 1 + for i, ((kind, color), ch) in enumerate(zip(_BAR, glyphs)): + n = (bar_len - used) if i == last else min(round(times.get(kind, 0.0) / total * bar_len), bar_len - used) + if n > 0: out.append(get_colored_text(ch * n, color)) + used += n + return ''.join(out) + ' ' * (_BAR_FILL - bar_len) + + +def print_buildstats(deps): + """`buildstats`: one normalized bar per package (load / configure / build), slowest first, with its + total wall time. Packages faster than _BUILDSTATS_FLOOR seconds are omitted so the chart stays relevant.""" + label = 'Build times' + rows = [] + for d in deps: + pt = d.phase_times + if not pt: continue + total = sum(pt.values()) # once per dep, not recomputed in a filter + if total >= _BUILDSTATS_FLOOR: rows.append((d.name, pt, total)) + if not rows: return + rows.sort(key=lambda r: r[2], reverse=True) + max_total = rows[0][2] + name_w = max(min(max(len(name) for name, _, _ in rows), 24), len(label)) # fit the names AND the header label + glyphs = _bar_glyphs() + legend = ' '.join(get_colored_text(f'{ch} {kind}', color) for (kind, color), ch in zip(_BAR, glyphs)) + console(f'\n {label:<{name_w}} {legend}') # label padded to the name column so the legend sits over the bars + for name, pt, total in rows: + console(f' {name:<{name_w}.{name_w}} {_buildstats_bar(pt, total, max_total, glyphs)} {get_time_str(total)}') + + +def _build_insights_session(config, root: BuildDependency): + """MSVC `buildstats`: a live vcperf /timetrace session wrapping the build. Linux `buildstats`: record the + build start time so the post-build report collects only the clang -ftime-trace JSONs written this run. + Otherwise a null context.""" + import contextlib, time + if not config.buildstats: + return contextlib.nullcontext() + if config.msvc: + from .build_insights import find_vcperf, VcPerfSession, timetrace_path + vcperf = find_vcperf(config) + if not vcperf: + warning('buildstats: vcperf.exe not found (set VCPERF= or run from a Developer Command Prompt);' + ' skipping MSVC Build Insights') + return contextlib.nullcontext() + config._timetrace_json = timetrace_path(root.build_dir) + return VcPerfSession(vcperf, config._timetrace_json) + config._buildstats_start = time.time() # wall start: post-build we analyze only traces newer than this + return contextlib.nullcontext() + + +def _insights_target(config, deps): + """(scope-label, scoped-dep-or-None) for the deep report: the named , else whole-build 'root'.""" + if config.has_target() and not config.targets_all(): + dep = next((d for d in deps if d.name.lower() == config.target.lower()), None) + if dep: return dep.name, dep + return 'root', None + + +def _print_build_insights(config, deps): + """After the Stage 1 bars: the compiler-specific deep dive. MSVC -> the vcperf trace; Linux/Clang -> the + clang -ftime-trace JSONs written this build; Linux/GCC -> a note (GCC has no per-file trace).""" + label, dep = _insights_target(config, deps) + if config.msvc: + _print_msvc_insights(config, label, dep) + elif config._buildstats_start is not None: + _print_clang_insights(config, deps, label, dep) + + +def _print_msvc_insights(config, label, dep): + path = config._timetrace_json + if not path or not os.path.exists(path): return + import json + from .build_insights import parse_timetrace, print_buildstats_deep + scope_paths = [p for p in (dep.src_dir, dep.build_dir) if p] if dep else None + try: + with open(path, encoding='utf-8') as f: data = json.load(f) + stats = parse_timetrace(data, scope_paths) + except Exception as e: + warning(f'buildstats: failed to read vcperf trace: {e}'); return + print_buildstats_deep(stats, label) + + +def _print_clang_insights(config, deps, label, dep): + import time + from .build_insights import collect_clang_traces, parse_clang_traces, print_buildstats_deep + if not config.clang: + warning('buildstats: deep per-file insights need Clang -ftime-trace; build with `clang` for the breakdown') + return + start = config._buildstats_start + scoped = [dep] if dep else deps # a -> just its build dir; else every package's + paths = [] + for d in scoped: + bd = d.build_dir + if bd: paths += collect_clang_traces(bd, since=start) + stats = parse_clang_traces(paths, wall_s=time.time() - start) + print_buildstats_deep(stats, f'{label} (clang)') + + +def _command_verb(config) -> str: + """What this run is doing, in the user's terms. rebuild sets build+clean and update sets build, + so the most specific command wins.""" + if config.rebuild: return 'rebuilding' + if config.update: return 'updating' + if config.clean: return 'cleaning' + return 'building' + + +def _compiler_version(config) -> str: + """Version for the banner: MSVC's toolset major.minor, else the detected cc/cxx version. '' when it + can't be resolved - a banner must never fail a build.""" + try: + if config.msvc: + toolset = os.path.basename(config.get_msvc_tools_path().rstrip('\\/')) # 14.44.35207 -> 14.44 + return '.'.join(toolset.split('.')[:2]) + _, _, ver = config.get_preferred_compiler_paths() + return '.'.join(ver.split('.')[:2]) if ver else '' + except Exception: + return '' + + +def _toolchain_name(config) -> str: + """'clang 18.1 libstdc++' / 'gcc 14.3' / 'msvc 14.44' - what this run actually builds with. The + stdlib only distinguishes a linux clang build.""" + name = 'msvc' if config.msvc else ('clang' if config.clang else ('gcc' if config.gcc else '')) + if not name: return '' + ver = _compiler_version(config) + stdlib = f' {config.clang_stdlib}' if (config.clang and config.linux) else '' + return f'{name}{" " + ver if ver else ""}{stdlib}' + + +def print_build_banner(config, count=None): + """One-line preview above the first task line: version, command, target count, toolchain. `count` is + None on the unified path - its graph grows as deps load, so the total isn't known until it's over.""" + targets = f' {count} target(s)' if count is not None else '' + toolchain = _toolchain_name(config) + console(f'Mama {__version__} {_command_verb(config)}{targets}' + (f' with {toolchain}' if toolchain else ''), + color=Color.GREEN) + + +def execute_unified(root: BuildDependency): + """Dynamic DAG scheduler interleaving cloning with configure+build: each dep is a LOAD job whose + completion GROWS the graph with its children's LOAD/CONFIGURE/BUILD jobs; a dep's CONFIGURE waits + on its own LOAD + its children's BUILDs. So leaf nodes build while deeper deps still clone. Used + for a plain full build (main() falls back to the old path otherwise); deploy/run/test stay serial.""" + import time + from .build_scheduler import Job, LOAD, CONFIGURE, BUILD, assign_priorities + config = root.config + ssh_multiplex.init_fetch_semaphore(config.parallel_max) + config.update_stats.start() + display = _make_display(config) + sched = _make_scheduler(config, max_load=config.parallel_max, pending_log=display.set_pending) + load_jobs: dict = {}; cfg_jobs: dict = {}; bld_jobs: dict = {} # dep -> Job (mutated under sched lock) + + def make_jobs(dep, parent_load): + L = Job((dep, 'L'), LOAD, (lambda d=dep: _do_load(d)), deps=({parent_load} if parent_load else set()), node=dep) + C = Job((dep, 'C'), CONFIGURE, (lambda d=dep: _do_configure(d)), deps={L}, node=dep) + B = Job((dep, 'B'), BUILD, (lambda d=dep: _do_build(d)), deps={C}, node=dep, + weight=(lambda d=dep: _reserve_weight(d)), ungated=dep.is_root) + load_jobs[dep] = L; cfg_jobs[dep] = C; bld_jobs[dep] = B + return [L, C, B] + + def _do_load(dep): + def body(sink): + dep.load() # clone + parse mamafile + dependencies() -> populates dep.children (no recursion) + def grow(): # runs under the scheduler lock: safe to mutate registries + add edges + new = [] + for child in dep.get_children(): + if child not in load_jobs: + new += make_jobs(child, load_jobs[dep]) + cfg_jobs[dep].deps.update(bld_jobs[c] for c in dep.get_children()) # configure waits on child builds + assign_priorities(list(cfg_jobs.values()) + list(bld_jobs.values())) # re-rank the critical path (trunk) + return new + sched.grow(grow) + _run_phase(display, dep, 'load', body, sched.build_slot) + if dep.is_root: print_build_banner(config) # root settings() has now locked compiler + stdlib + + def _do_configure(d): _run_phase(display, d, 'configure', lambda s: _configure_body(d, s), sched.build_slot) + def _do_build(d): + _run_phase(display, d, 'build', lambda s: _build_body(d, s), sched.build_slot, _build_detail(d), final=True) + + system.set_active_display(display) + start = time.monotonic() + with _build_insights_session(config, root): # MSVC buildstats: wrap the build in a vcperf trace (else no-op) + try: + failed = sched.run(make_jobs(root, None)) + finally: + display.close() + system.set_active_display(None) + config.update_stats.stop() + SubProcess.clear_abort() # re-arm spawning (run() returned -> all workers drained) + flat = get_flat_deps(root) + if failed is not None: + _handle_failure(display, failed) # replay the failed target + traceback (scroll up for detail) + _print_diagnostics(display, flat, failed.node.name); exit(-1) # ...then the aggregate summary last + _print_build_summary(flat, time.monotonic() - start) + _print_diagnostics(display, flat) + if config.buildstats: + print_buildstats(flat) + _print_build_insights(config, flat) + _deploy_run_postpass(reversed(flat), config) + + def find_dependency(root: BuildDependency, name: str) -> BuildDependency: """ This is mainly used for finding root target or specific command line target """ if root.name.lower() == name.lower(): diff --git a/mama/main.py b/mama/main.py index 77f4c61..51e0309 100644 --- a/mama/main.py +++ b/mama/main.py @@ -8,7 +8,10 @@ from .build_config import BuildConfig from .build_target import BuildTarget from .build_dependency import BuildDependency -from .dependency_chain import load_dependency_chain, execute_task_chain, find_dependency, get_flat_deps, get_deps_only_targets, get_deps_that_depend_on_target +from .dependency_chain import (load_dependency_chain, execute_task_chain, execute_task_chain_parallel, + execute_unified, print_sched_debug, find_dependency, get_flat_deps, print_build_banner, + get_deps_only_targets, get_deps_that_depend_on_target, + mark_unbuilt_target_deps, sweep_orphaned_build_dirs) from .init_project import mama_init_project from ._version import __version__ @@ -84,6 +87,9 @@ def print_usage(): console(' unshallow - Allow unshallowing shallow git clones') console(' https-override - rewrite add_git() ssh urls (git@host:path) to https://host/path') console(' ssh-override - rewrite add_git() https urls to ssh (git@host:path)') + console(' serial - Disable parallel build of dependencies, useful for debugging') + console(' buildstats - After the build, print per-package load/configure/build bars; plus a deep') + console(' frontend/backend breakdown on MSVC (vcperf) or Clang (-ftime-trace). scopes it') console(' examples:') console(' mama init Initialize a new project. Tries to create mamafile.py and CMakeLists.txt') console(' mama build Update and build main project only. This only clones, but does not update!') @@ -155,19 +161,48 @@ def open_project(config: BuildConfig, root_dependency: BuildDependency): console('Android IDE selection not implemented, using VSCode instead.') execute(f'code {found.src_dir}', echo=True) +_RETIRED_ARGS = {'buildtimes': 'buildstats'} # removed flags worth naming, so they don't read as a target + + def set_target_from_unused_args(config: BuildConfig): + """An unrecognized bare word is the target name, so `mama rebuild ReCpp` works. An option-shaped arg + (`jobz=4`, `-foo`) can never be one, so it's a typo - fail here, not 20s later as 'target not found'.""" for arg in config.unused_args: + if arg in _RETIRED_ARGS: # else a removed flag reads as a target and fails with 'target not found' + console(f"ERROR: '{arg}' was removed, use '{_RETIRED_ARGS[arg]}' instead") + exit(-1) + if arg.startswith('-') or '=' in arg: + console(f"ERROR: unknown option '{arg}'") + exit(-1) if config.has_target(): console(f"ERROR: Deduced Target='{arg}' from unused argument, but target is already set to '{config.target}'") exit(-1) else: config.target = arg +def _init_platform_compilers(config: BuildConfig): + """Cross-compile platform setup (NDK/sysroot paths) - config-derived, no dep tree needed.""" + if config.android: config.android.android_home() + if config.raspi: config.raspi_bin() + if config.yocto_linux: config.yocto_linux.init_default() + if config.mips: config.mips.init_default() + + +def _can_unify(config: BuildConfig) -> bool: + """True for a plain full build/update that the unified clone+build scheduler handles. Targeted / + list / deps_only / dirty / mama_init / serial runs need the classic load->execute path (which + resolves the whole tree up front for target lookup and filtering).""" + return (not config.serial_load and (config.build or config.update) + and config.no_specific_target() and not config.list and not config.deps_only + and not config.dirty and not config.mama_init) + + def check_config_target(config: BuildConfig, root: BuildDependency): if config.has_target() and not config.targets_all(): dep = find_dependency(root, config.target) - if dep is None: - console(f"ERROR: specified target='{config.target}' not found!") + if dep is None: # list what IS valid: the name was likely a typo'd target or a misspelled flag + names = ', '.join(sorted(d.name for d in get_flat_deps(root))) + console(f"ERROR: specified target='{config.target}' not found! Available targets: {names}") exit(-1) @@ -237,7 +272,6 @@ def mamabuild(args, source_dir=os.getcwd()): config = BuildConfig(args) if config.print: - print_title() if config.verbose: console(f'Build jobs={config.jobs}') @@ -264,7 +298,7 @@ def mamabuild(args, source_dir=os.getcwd()): exit(-1) if config.update: - if config.no_target(): + if config.no_specific_target(): config.target = 'all' if config.print: console(f'Updating all targets') else: @@ -272,7 +306,7 @@ def mamabuild(args, source_dir=os.getcwd()): deps_only_target_name = None if config.deps_only: - if config.no_target(): + if config.no_specific_target(): config.target = 'all' if config.print: console(f'Executing deps_only action on all targets') else: @@ -286,57 +320,93 @@ def mamabuild(args, source_dir=os.getcwd()): if config.clean and config.no_target() and not config.deps_only: root.clean() - load_dependency_chain(root) - check_config_target(config, root) + if config.sched_debug: # TEMP: load the tree, print the build-weight calc per target, then stop + load_dependency_chain(root) + print_sched_debug(root) + return - # get the main target dependency - if config.has_target(): - dep = find_dependency(root, config.target) - else: + # Plain full build/update -> unified clone+configure+build scheduler; everything else (which + # needs the fully-loaded tree up front for lookup/filtering) -> classic load->execute path. + if _can_unify(config): + _init_platform_compilers(config) + execute_unified(root) dep = root + flat_deps = get_flat_deps(root) # the graph is fully grown by now; keep it defined for the code below + else: + load_dependency_chain(root) + check_config_target(config, root) - # target init - if config.mama_init and config.has_target(): - if not dep: - console(f'init command failed: target {config.target} not found') - exit(-1) - mama_init_project(dep) - return + # clean is not a build: dirs wiped during load, so packaging them fabricates an empty package or + # dies in a mamafile assert ('libX.so not found'). rebuild sets build=True, so it still runs. + if config.clean_only(): + if config.targets_all(): sweep_orphaned_build_dirs(root, config) # deps with no source on disk + return - flat_deps = get_flat_deps(root) # root, dep2, deepest_dep - flat_deps_reverse = list(reversed(flat_deps)) # deepest_dep, dep2, root + # Only now is the tree loaded, so X's subtree is known: revive the deps X needs but that have + # nothing on disk. _should_build can't do this at load time - deps have no parent link then. + if (config.build or config.update) and config.has_target() and not config.targets_all(): + mark_unbuilt_target_deps(root, config) - if config.deps_only: - if deps_only_target_name: - flat_deps, flat_deps_reverse = get_deps_only_targets(root, deps_only_target_name, config) - config.target = 'all' + # get the main target dependency + if config.has_target(): + dep = find_dependency(root, config.target) else: - flat_deps.remove(root) - flat_deps_reverse.remove(root) + dep = root + + # target init + if config.mama_init and config.has_target(): + if not dep: + console(f'init command failed: target {config.target} not found') + exit(-1) + mama_init_project(dep) + return + + flat_deps = get_flat_deps(root) # root, dep2, deepest_dep + flat_deps_reverse = list(reversed(flat_deps)) # deepest_dep, dep2, root + + # `build X` runs X and what X needs - not the whole tree. An out-of-scope dep did no build work + # but still reached _run_packaging(), re-packaging a target that was never built (and asserting + # on libs that don't exist). + targeted = config.build and config.has_target() and not config.targets_all() and not config.deps_only + if targeted and dep is not None: + flat_deps = get_flat_deps(dep) + flat_deps_reverse = list(reversed(flat_deps)) + + if config.deps_only: + if deps_only_target_name: + flat_deps, flat_deps_reverse = get_deps_only_targets(root, deps_only_target_name, config) + config.target = 'all' + else: + flat_deps.remove(root) + flat_deps_reverse.remove(root) + + if config.list: + # if listing, then mark all deps for no-build + for d in flat_deps: + d.target.nothing_to_build() + + if config.dirty: + if not dep: + console(f'dirty command failed: target {config.target} not found') + exit(-1) + mama_dirty(root, dep) + return + + _init_platform_compilers(config) + if config.build or config.update: # not for list/deploy/test runs, which build nothing + print_build_banner(config, len(flat_deps_reverse)) - if config.list: - # if listing, then mark all deps for no-build - for d in flat_deps: - d.target.nothing_to_build() - - if config.dirty: - if not dep: - console(f'dirty command failed: target {config.target} not found') - exit(-1) - mama_dirty(root, dep) - return - - # initialize platform compiler config - if config.android: config.android.android_home() - if config.raspi: config.raspi_bin() - if config.yocto_linux: config.yocto_linux.init_default() - if config.mips: config.mips.init_default() - - if config.verbose: - chain = ' -> '.join([d.name for d in flat_deps_reverse]) - console(f'Executing task chain for build:\n {chain}', Color.BLUE) - - execute_task_chain(flat_deps_reverse) + if config.verbose: + chain = ' -> '.join([d.name for d in flat_deps_reverse]) + console(f'Executing task chain for build:\n {chain}', Color.BLUE) + + # Parallel by default; only an explicit `serial` opts out. A one-dep graph has nothing to + # overlap, but the scheduler owns the live display - falling back to the serial runner for it + # dumped raw cmake output instead, so `build ` looked nothing like a full build. + if config.serial_load: + execute_task_chain(flat_deps_reverse) + else: + execute_task_chain_parallel(flat_deps_reverse) if config.list: flat_deps_names = [d.name for d in flat_deps] diff --git a/mama/papa_deploy.py b/mama/papa_deploy.py index d8da85c..4a5d4dd 100644 --- a/mama/papa_deploy.py +++ b/mama/papa_deploy.py @@ -123,6 +123,12 @@ def append(relpath, abs_include): +def _compiler_stamp(config) -> str: + """'gcc14.3' / 'clang18.1', '' if the platform can't name one - a diagnostic, never a deploy failure.""" + try: return config.compiler_version() + except Exception: return '' + + def papa_deploy_to(target:BuildTarget, package_full_path:str, r_includes:bool, r_dylibs:bool, r_syslibs:bool, r_assets:bool): @@ -142,8 +148,11 @@ def papa_deploy_to(target:BuildTarget, package_full_path:str, if not os.path.exists(package_full_path): # check to avoid Access Denied errors os.makedirs(package_full_path, exist_ok=True) - # set up project and dependencies + # set up project and dependencies. `C` = compiler that built these libs (same string as the archive name), + # so a load can spot a gcc tree landing in a clang build. descr = [ f'P {target.name}' ] + compiler = _compiler_stamp(config) + if compiler: descr.append(f'C {compiler}') for d in dependencies: if detail_echo: console(f' D {d.dep_source}') descr.append(f'D {d.dep_source.get_papa_string()}') @@ -207,6 +216,7 @@ def __init__(self, papa_file:str): self.papa_dir = os.path.dirname(papa_file) self.project_name = None + self.compiler = None # 'gcc14.3' / 'clang18.1'; None for packages predating the C record self.dependencies = [] self.includes = [] self.libs = [] @@ -218,6 +228,7 @@ def append_to(to:list, line): for line in read_lines_from(self.papa_file): if line.startswith('P '): self.project_name = line[2:].strip() + elif line.startswith('C '): self.compiler = line[2:].strip() elif line.startswith('D '): self.dependencies.append(make_dep_source(line[2:].strip())) elif line.startswith('I '): append_to(self.includes, line) elif line.startswith('L '): append_to(self.libs, line) diff --git a/mama/platforms/android.py b/mama/platforms/android.py index 962d92f..316dd55 100644 --- a/mama/platforms/android.py +++ b/mama/platforms/android.py @@ -236,8 +236,7 @@ def get_cmake_build_opts(self, target: BuildTarget) -> list: if toolchain: opts.append(f'CMAKE_TOOLCHAIN_FILE="{toolchain}"') - if self.config.print: - console(f'Toolchain: {toolchain}') + self.config.announce_once('toolchain', f'Toolchain: {toolchain}') make = self._get_make(target) if make: opts.append(f'CMAKE_MAKE_PROGRAM="{make}"') diff --git a/mama/platforms/generic_yocto.py b/mama/platforms/generic_yocto.py index 5de1bfa..482c22a 100644 --- a/mama/platforms/generic_yocto.py +++ b/mama/platforms/generic_yocto.py @@ -186,8 +186,7 @@ def get_ldflags_with_defaults(self, ldflags: dict): def get_cmake_build_opts(self) -> list: if self.toolchain_file: - if self.config.print: - console(f'Toolchain: {self.toolchain_file}') + self.config.announce_once('toolchain', f'Toolchain: {self.toolchain_file}') return [ f'{self.platform_define}=TRUE', f'CMAKE_TOOLCHAIN_FILE="{self.toolchain_file}"' diff --git a/mama/platforms/mips.py b/mama/platforms/mips.py index 66c044c..53e6b26 100644 --- a/mama/platforms/mips.py +++ b/mama/platforms/mips.py @@ -109,8 +109,7 @@ def get_cxx_flags(self, add_flag: Callable[[str,str], None]): def get_cmake_build_opts(self) -> list: if self.toolchain_file: - if self.config.print: - console(f'MIPS Toolchain: {self.toolchain_file}') + self.config.announce_once('toolchain', f'MIPS Toolchain: {self.toolchain_file}') return [ 'MIPS=TRUE', f'CMAKE_TOOLCHAIN_FILE="{self.toolchain_file}"' diff --git a/mama/types/git.py b/mama/types/git.py index 96b0fbe..f03bf96 100644 --- a/mama/types/git.py +++ b/mama/types/git.py @@ -7,7 +7,7 @@ from ..utils.sub_process import SubProcess, execute_piped, execute_piped_echo from ..utils import ssh_multiplex from ..util import (is_dir_empty, save_file_if_contents_changed, read_lines_from, path_join, - is_network_error, get_time_str, normalized_path, git_dir_fingerprint) + is_network_error, get_time_str, normalized_path, git_dir_fingerprint, git_progress_status) if TYPE_CHECKING: @@ -21,6 +21,32 @@ _SCHEME_RE = re.compile(r'^(?P[a-zA-Z][\w+.-]*)://(?P.*)$') _FILTERED_GIT_PROGRESS_REPORT_INTERVAL = 0.005 +# Benign post-op chatter from git reset/checkout/pull - pure noise outside verbose mode. The +# "merge with the ref ... no such ref was fetched" pair is the expected pull-then-fetch fallback. +_GIT_NOISE = ('Reset branch ', 'Your branch is up to date with ', 'Already up to date', + 'Already on ', 'Switched to branch ', 'Switched to a new branch ', 'HEAD is now at ', + 'Your configuration specifies to merge with the ref ', 'from the remote, but no such ref was fetched', + 'There is no tracking information for the current branch') + +def _is_git_status_noise(line: str) -> bool: + return line.startswith(_GIT_NOISE) or 'set up to track ' in line + + +def _filter_git_progress(dep, line: str, state: dict, label='') -> bool: + """True if `line` is git transfer progress (the caller drops it) - collapsing the per-percent flood + the PTY makes git emit into one throttled redraw. EVERY git runner routes output through this single + chokepoint. `state` carries the throttle across calls; `label` prefixes (e.g. CLONE).""" + st = git_progress_status(line) + if st is None: return False + if dep.config.print: + now = time.monotonic() + if 'at' not in state: state['at'] = now # seed on first sight; don't emit until the throttle elapses + if st != state.get('last') and (st[1] >= 100 or now - state['at'] >= _FILTERED_GIT_PROGRESS_REPORT_INTERVAL): + state['last'] = st; state['at'] = now + tag = f'{label} ' if label else '' + progress(f' {dep.name: <16} {tag}{st[0]} {st[1]:3}%') + return True + def parse_git_url(url: str): """Split a remote git url into (scheme, user, host, path). Returns None for local paths or anything without a network host, which overrides leave untouched.""" @@ -140,12 +166,21 @@ def run_git(self, dep: BuildDependency, git_command, throw=True): ssh_multiplex.ensure_master_for_url(self.url) # Capture and prefix each line so parallel updates don't tear and the # user can see which target said what (e.g. 'remote: Enumerating ...'). + prog: dict = {} def prefixed(p:SubProcess, line:str): line = line.rstrip() - if line: console(f' {dep.name: <16} {line}') + if not line: return + if _filter_git_progress(dep, line, prog): return # collapse the transfer-progress flood + if not dep.config.verbose and _is_git_status_noise(line): return # drop benign reset/track/up-to-date chatter + console(f' {dep.name: <16} {line}') with ssh_multiplex.fetch_slot(): # cwd= instead of `cd && cmd` because SubProcess uses execve, not a shell. - result = SubProcess.run(cmd, cwd=dep.src_dir, io_func=prefixed) + # idle_timeout: kill a fetch stuck on an auth prompt so a parallel run never freezes. + try: + result = SubProcess.run(cmd, cwd=dep.src_dir, io_func=prefixed, idle_timeout=dep.config.git_timeout) + except subprocess.TimeoutExpired: + error(f' {dep.name: <16} git stalled {dep.config.git_timeout}s, killed (auth prompt or hung server)') + result = -1 if result != 0 and throw: raise RuntimeError(f'{cmd} (in {dep.src_dir}) failed with return code {result}') return result @@ -156,6 +191,19 @@ def _has_local_modifications(self, dep: BuildDependency) -> bool: return self.run_git(dep, "diff --quiet HEAD", throw=False) != 0 + def _ensure_no_local_modifications(self, dep: BuildDependency): + """Raise (with an actionable message + `git status`) when the working tree has uncommitted + changes an update's reset --hard would overwrite. Called at the TOP of the update path so a + dirty dep fails loudly (marked `x`), even when upstream is unchanged - otherwise the pull below + fails, gets swallowed by its fetch fallback, and the dep silently reports success un-updated.""" + if not self._has_local_modifications(dep): return + name = dep.name + error(f" Target {name} has local modifications that would be overwritten by update.\n" + f" To discard local changes and re-fetch, run: `mama wipe {name}`") + self.run_git(dep, "status --porcelain") # show the user what files are modified + raise RuntimeError(f"Target {name} has local modifications. Use 'mama wipe {name}' to discard changes.") + + def working_tree_fingerprint(self, dep: BuildDependency) -> str: """'' for a clean tree, else a content-aware hash of uncommitted source. See util.git_dir_fingerprint. Guarded on is_real_clone so a shim (no working tree on @@ -433,42 +481,12 @@ def _run_git_with_filtered_progress(self, dep: BuildDependency, cmd: str, label: Returns (exit_code, captured_output, elapsed_str). Does not raise. Used by full clone and by the sparse mamafile probe so both share the same nice UI instead of spewing git's raw remote: output.""" - output = '' + output = [] # list + join, not output += line (O(n^2) over a big checkout's file list) start = time.monotonic() - last_progress_at = None - last_progress = (None, -1) + prog: dict = {} def print_output(p:SubProcess, line:str): - nonlocal output, last_progress_at, last_progress - if 'remote: Counting objects:' in line or \ - 'remote: Compressing objects:' in line or \ - 'Receiving objects:' in line or \ - 'Resolving deltas:' in line or \ - 'Updating files:' in line: - if dep.config.print: - parts = line.split('%')[0].split(':') - if not parts: - console(line) - return - percent_string = parts[len(parts)-1].strip() - percent = int(percent_string) if percent_string else 0 - status = 'status ' - if 'remote: Counting objects:' in line: status = 'counting objects ' - elif 'remote: Compressing objects:' in line: status = 'compressing objects' - elif 'Receiving objects:' in line: status = 'receiving objects ' - elif 'Resolving deltas:' in line: status = 'resolving deltas ' - elif 'Updating files:' in line: status = 'updating files ' - now = time.monotonic() - cur_progress = (status, percent) - if last_progress_at is None: - last_progress_at = now - should_report = percent >= 100 or \ - (now - last_progress_at) >= _FILTERED_GIT_PROGRESS_REPORT_INTERVAL - if last_progress != cur_progress and should_report: - last_progress = cur_progress - last_progress_at = now - elapsed = get_time_str(now - start) - progress(f' - Target {dep.name: <16} {label} {status} {percent:3}% ({elapsed})') - elif 'Cloning into ' in line: + if _filter_git_progress(dep, line, prog, label=label): return # same chokepoint as run_git + if 'Cloning into ' in line: return elif 'Are you sure you want to continue connecting' in line: # TODO: maybe auto-add the key before running clone? @@ -476,8 +494,7 @@ def print_output(p:SubProcess, line:str): console(line) p.write('yes\n') # get us unstuck elif line: - output += line - output += '\n' + output.append(line) if dep.config.verbose: console(line) @@ -485,8 +502,14 @@ def print_output(p:SubProcess, line:str): console(f' {dep.name: <16} {cmd}') ssh_multiplex.ensure_master_for_url(self.url) with ssh_multiplex.fetch_slot(): - result = SubProcess.run(cmd, io_func=print_output) - return result, output, get_time_str(time.monotonic() - start) + # idle_timeout: kill a clone stuck on a passphrase prompt so a parallel wave never + # freezes; a real download streams progress and is never wrongly aborted. + try: + result = SubProcess.run(cmd, io_func=print_output, idle_timeout=dep.config.git_timeout) + except subprocess.TimeoutExpired: + output.append(f'[mama] git stalled {dep.config.git_timeout}s, killed (auth prompt or hung server)') + result = -1 + return result, '\n'.join(output), get_time_str(time.monotonic() - start) def clone_with_filtered_progress(self, dep: BuildDependency, clone_args: str, clone_to_dir: str): @@ -513,6 +536,7 @@ def clone_or_pull(self, dep: BuildDependency, wiped=False): if not dep.config.is_network_available(): raise RuntimeError(f'Target {dep.name} requires network to clone but network is unavailable.' + \ ' Check your connection or use a cached artifactory package.') + dep.load_action = 'clone' # actual full repo clone (display label) if not wiped and dep.config.print: console(f" - Target {dep.name: <16} CLONE because src is missing", color=Color.BLUE) br_or_tag = self.branch_or_tag() @@ -527,15 +551,11 @@ def clone_or_pull(self, dep: BuildDependency, wiped=False): if dep.config.print: warning(f" - Target {dep.name: <16} SKIP PULL (network unavailable, using cached source)") return + dep.load_action = 'pulling' # actual git pull/fetch (display label) if dep.config.print: console(f" - Pulling {dep.name: <16} SCM change detected", color=Color.BLUE) # check for local modifications before potentially destructive operations - if self._has_local_modifications(dep): - name = dep.name - error(f" Target {name} has local modifications that would be overwritten by update.\n" - f" To discard local changes and re-fetch, run: `mama wipe {name}`") - self.run_git(dep, "status --porcelain") # show the user what files are modified - raise RuntimeError(f"Target {name} has local modifications. Use 'mama wipe {name}' to discard changes.") + self._ensure_no_local_modifications(dep) if unshallow: self.unshallow(dep) is_commit_pin = Git.is_hex_string(self.branch_or_tag()) @@ -591,6 +611,9 @@ def dependency_checkout(self, dep: BuildDependency): changed = False if config.update and is_target: + # Fail loudly on a dirty tree BEFORE the pull below - which would otherwise fail, get + # swallowed by its fetch fallback, and leave the dep un-updated but reporting success. + self._ensure_no_local_modifications(dep) changed = self.check_status(dep) wiped = False diff --git a/mama/util.py b/mama/util.py index 2e0e46f..73575cb 100644 --- a/mama/util.py +++ b/mama/util.py @@ -1,4 +1,4 @@ -import os, stat, shutil, zipfile, subprocess, hashlib +import os, re, stat, shutil, zipfile, subprocess, hashlib from typing import List import time, ssl, pathlib, random from .utils.system import System, console, progress @@ -107,9 +107,11 @@ def normalized_join(path1: str, *pathsN) -> str: return normalized_path(os.path.join(path1, *pathsN)) -def glob_with_extensions(rootdir: str, extensions: List[str]) -> List[str]: +def glob_with_extensions(rootdir: str, extensions: List[str], exclude_dirs: List[str] = None) -> List[str]: results = [] - for dirpath, _, dirfiles in os.walk(rootdir): + exclude = set(exclude_dirs) if exclude_dirs else None + for dirpath, dirnames, dirfiles in os.walk(rootdir): + if exclude: dirnames[:] = [d for d in dirnames if d not in exclude] # prune generated/vendored trees for file in dirfiles: _, fext = os.path.splitext(file) if fext in extensions: @@ -233,7 +235,7 @@ def get_file_size_str(size): def get_time_str(seconds: float): - if seconds < 1: return f'{int(seconds*1000)}ms' + if seconds < 0.1: return f'{int(seconds*1000)}ms' # ms only below 0.1s, else 0.0s; 0.2s beats 200ms if seconds < 60: return f'{seconds:.1f}s' if seconds < 60*60: return f'{int(seconds/60)}m {int(seconds%60)}s' if seconds < 24*60*60: return f'{int(seconds/(60*60))}h {int(seconds/60)%60}m {int(seconds)%60}s' @@ -355,11 +357,6 @@ def make_symlink(zipmember: zipfile.ZipInfo, symlink_location, is_directory): return True return False - def print_debug(zipmember: zipfile.ZipInfo): - what = 'DIR' if zipmember.is_dir() else 'FILE' - what = what + ' LINK' if is_symlink else what - print(f'{what} {zipmember.filename} S_IMODE={stat.S_IMODE(mode):0o} S_IFMT={stat.S_IFMT(mode):0o}') - num_unzipped = 0 with zipfile.ZipFile(local_zip, "r") as zip: @@ -400,7 +397,6 @@ def print_debug(zipmember: zipfile.ZipInfo): os.utime(dst_path, times=(mtime, mtime), follow_symlinks=False) if did_extract: num_unzipped += 1 - #print_debug(zipmember) return num_unzipped @@ -580,3 +576,54 @@ def is_network_error(e: Exception) -> bool: return True return False + + +# git transfer progress ('Receiving objects: 42% (...)') classification - shared so every place that +# captures git output collapses the per-percent flood identically (one source of truth). +_GIT_PROGRESS = (('remote: Counting objects:', 'counting objects '), ('remote: Compressing objects:', 'compressing objects'), + ('Receiving objects:', 'receiving objects '), ('Resolving deltas:', 'resolving deltas '), + ('Updating files:', 'updating files ')) + + +def git_progress_status(line: str): + """(status label, percent) for a raw git transfer-progress line ('Receiving objects: 42%'), else None.""" + for needle, status in _GIT_PROGRESS: + if needle in line: + pct = line.split('%')[0].rsplit(':', 1)[-1].strip() + return status, (int(pct) if pct.isdigit() else 0) + return None + + +_PERCENT_RE = re.compile(r'\b\d{1,3}%') # a NN% completion token: git 'Receiving objects: 42%', a + # download bar '|===| 42%', mama's collapsed redraw, wget/curl, ... + + +def is_progress_line(line: str) -> bool: + """True for any transfer/download progress update - a line carrying a NN% completion token. + Consecutive such lines collapse to just the latest so a progress bar (git, artifactory download, + a custom build's own downloader) can't flood a captured buffer with hundreds of per-percent updates.""" + return _PERCENT_RE.search(line) is not None + + +def parse_version(version: str) -> tuple: + """'0.13.01' -> (0, 13, 1). Segments parse as ints so zero-padding is irrelevant and 0.13 ranks + ABOVE 0.9 (a plain string compare gets that backwards). Non-numeric junk in a segment is dropped.""" + parts = [] + for segment in str(version).split('.'): + digits = ''.join(c for c in segment if c.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) if parts else (0,) + + +def version_at_least(current: str, required: str) -> bool: + """True if `current` >= `required`, comparing segment-wise and zero-padding the shorter one + (so '0.13' < '0.13.01').""" + cur, req = parse_version(current), parse_version(required) + width = max(len(cur), len(req)) + return cur + (0,) * (width - len(cur)) >= req + (0,) * (width - len(req)) + + +class BuildError(RuntimeError): + """A configure/build command failed: the USER's build is broken, not mamabuild. Reported as a clean + message with no Python traceback - a stack trace through mama's internals only buries the actual + compiler/cmake error the user needs to read.""" diff --git a/mama/utils/build_display.py b/mama/utils/build_display.py new file mode 100644 index 0000000..fc3402b --- /dev/null +++ b/mama/utils/build_display.py @@ -0,0 +1,431 @@ +"""Unified live display for parallel configure/build jobs. + +TTY: a live region of one line per ACTIVELY-running task, capped to terminal height, redrawn in +place (superconsole style). A dep flows through phases (load -> configure -> build) on ONE task that +stays put across them; when its whole workflow finishes it commits a single summary line above the +region with a per-phase timing breakdown - `git 3.7s cfg 0.4s bld 0.4s` (git/loc/art load, cfg +configure, bld build), every step shown + tagged (even an instant configure) for a consistent column. +Non-TTY: that same one merged summary line per dep, + a full output dump when verbose. +Every task keeps its raw colour-preserving output for failure replay. Injected seams (out / isatty / +term_size / clock) -> unit-testable with no real terminal/threads/subprocesses.""" + +from __future__ import annotations +import re, time, threading +from . import proc_cpu +from .system import Color, get_colored_text +from ..util import get_time_str, is_progress_line + + +_CURSOR_UP = '\x1b[1A' +_ERASE_EOL = '\x1b[K' # erase to end of line (colorama enables it on Windows) +_ERASE_EOL_LF = _ERASE_EOL + '\n' # clear-to-EOL then newline: one written task/permanent line +_ANSI_RE = re.compile(r'\x1b\[[0-9;]*m') # SGR colour codes, for width-correct previews +# A diagnostic line: MSVC 'warning C4996:' / 'error C2065:' / 'error LNK2019:', GCC/Clang 'warning:' / +# 'error:', and CMake's 'CMake Error at ' (no colon, so the generic form below never caught it - +# which is exactly how a failing target's real error went missing from the summary). +# \b guards keep -Werror, std::error_code and '0 errors' from matching. +_DIAG_RE = re.compile(r'\bcmake\s+(?Perror|warning)\b|\b(?Perror|warning)\b\s*(?:[A-Za-z]+[0-9]+)?\s*:', + re.IGNORECASE) + + +_CMAKE_HEAD = re.compile(r'^cmake\s+(error|warning)\b', re.IGNORECASE) +_MAX_BODY = 8 # continuation lines kept per cmake block, so one chatty warning can't bury the rest + + +def _cmake_body(lines, i): + """A cmake diagnostic is a header plus an INDENTED body that ends on a blank pair - scanning by line + alone reports a bare 'CMake Warning:' with the actual message thrown away. Always walks to the END of + the block - stopping at the cap left the scanner inside it, re-reporting body lines as diagnostics of + their own - but keeps only the first _MAX_BODY lines. Returns (body, next_i).""" + body, j, blanks, dropped = [], i + 1, 0, 0 + while j < len(lines): + raw = _ANSI_RE.sub('', lines[j]).rstrip() + if not raw.strip(): + blanks += 1 + if blanks >= 2: break # cmake terminates the block with two line breaks + j += 1; continue + if not raw[:1].isspace(): break # unindented again: back to ordinary build output + blanks = 0 + if len(body) < _MAX_BODY: body.append(raw.strip()) + else: dropped += 1 + j += 1 + if dropped: body.append(f'... (+{dropped} more lines)') # capped, but never silently + return body, j + + +def scan_diagnostics(lines, limit=8): + """Pull compiler warning/error diagnostics out of a task's raw output for the post-build summary + (parallel builds only replay output on failure, so a successful build's diagnostics are lost). + De-duplicated, errors before warnings, capped to `limit`. Returns (diags, n_err, n_warn) with + diags = [(severity, ansi-stripped text)]; a cmake block keeps its body as embedded newlines.""" + seen = set(); errs = []; warns = [] + i = 0 + while i < len(lines): + text = _ANSI_RE.sub('', lines[i]).strip() + m = _DIAG_RE.search(text) + if not m: + i += 1; continue + if _CMAKE_HEAD.match(text): + body, i = _cmake_body(lines, i) + if body: text = text + '\n' + '\n'.join(body) + else: + i += 1 + if text in seen: continue + seen.add(text) + severity = (m.group('cm') or m.group('sev')).lower() + (errs if severity == 'error' else warns).append(text) + diags = [('error', t) for t in errs] + [('warning', t) for t in warns] + return diags[:limit], len(errs), len(warns) + +_ICON = {'run': '*', 'ok': '+', 'fail': 'x'} +_ICON_COLOR = {'run': Color.BLUE, 'ok': Color.GREEN, 'fail': Color.RED} +# Short lowercase tag per phase for the timing breakdown (lowercase stands out between the times): +# git for any git load (check/clone/pull), loc local source, art artifactory, cfg configure, bld build. +_PHASE_TAG = {'check': 'git', 'clone': 'git', 'pulling': 'git', 'local': 'loc', 'artifactory': 'art', + 'configure': 'cfg', 'build': 'bld'} + + +def _fmt_dur(d: float) -> str: + """One phase's duration for the timing column, right-aligned to a fixed width so the cfg/bld + columns line up across rows. Sub-0.1s shows 2 decimals (`0.03s`) not the noisy `34ms`; 0.1s+ + uses the shared get_time_str (`0.5s`, `2m 44s`).""" + s = f'{d:.2f}s' if d < 0.1 else get_time_str(d) + if s == '0.00s': s = '0.0s' # an instant phase: 0.0s reads better than an over-precise 0.00s + return s.rjust(6) + + +class Task: + """One dep across its whole workflow. `kind`/`detail`/`start` track the CURRENT phase; `phases` + accumulates (duration, kind, detail) for each completed phase that did real work, so the final + summary can show the breakdown. `lines` accumulates across phases for failure replay.""" + def __init__(self, id, kind: str, name: str, start: float, detail: str = ''): + self.id = id + self.kind = kind # current phase: 'check' | 'configure' | 'build' | ... + self.detail = detail # e.g. 'J16' = cores this build uses, shown after the kind + self.name = name + self.start = start # current phase start + self.end = None + self.state = 'run' # 'run' | 'ok' | 'fail' + self.cpu = 0.0 # live subprocess-tree CPU% (Linux-style: 8 busy cores ~ 800%) + self.lines: list[str] = [] # full raw output, colours intact (for replay) + self.phase_start = 0 # index in `lines` where the CURRENT phase's output begins + self.current = '' # last non-empty line, shown live + self.phases: list = [] # completed (duration, kind, detail), interesting phases only + + def begin(self, kind: str, start: float, detail: str = ''): + """Resume this task on a new phase (keeps phases/lines, resets the live preview + timer).""" + self.kind = kind; self.detail = detail; self.start = start + self.end = None; self.state = 'run'; self.current = '' + self.phase_start = len(self.lines) # replay shows THIS phase, not the whole dep's history + + def feed(self, line: str): + # Collapse a run of progress updates (git 'Receiving objects: 0%..100%', a download bar's + # per-percent frames) to just its latest line, so a captured clone/download doesn't flood the + # buffer (and thus the log + failure replay) with hundreds of updates. + if self.lines and is_progress_line(line) and is_progress_line(self.lines[-1]): + self.lines[-1] = line + else: + self.lines.append(line) + s = line.strip() + if s: self.current = s + + def elapsed(self, now: float) -> float: + return (self.end if self.end is not None else now) - self.start + + +class BuildDisplay: + def __init__(self, out, isatty: bool, term_size, clock, verbose=False, color=True, + min_interval=0.1, margin=1, reveal_delay=0.1, cpu_sampler=None, sample_interval=1.5, log=None): + self._out = out + self._log = log # optional AsyncLogWriter: full per-target output + permanent lines -> mamabuild.log + self._isatty = isatty + self._term_size = term_size # () -> (cols, rows) + self._clock = clock # () -> float + self._verbose = verbose + self._color = color + self._min_interval = min_interval + self._margin = margin + self._reveal = reveal_delay # hide tasks that start+finish faster than this (instant no-ops) + self._tasks: dict[object, Task] = {} + self._active: list[object] = [] # ids in start order + self._pending: list[str] = [] # permanent lines to flush above the region + self._pending_hint = None # (name, reason) of the single next blocked task, shown live + self._drawn = 0 # active region lines drawn last frame + self._last_render = 0.0 + self._lock = threading.RLock() # guards task/region state (held only briefly) + self._render_lock = threading.Lock() # serializes terminal writes; non-forced renders skip if busy + self._cpu_sampler = cpu_sampler # (set[int]) -> float total tree CPU%; None -> auto (psutil) + self._cpu_auto = cpu_sampler is None + self._pids: dict[object, set] = {} # tid -> live child pids, for CPU sampling + self._sampler = None # daemon thread, lazily started on first attach_pid + self._stop = threading.Event() + self._closed = False # after close() the region is gone: never draw over the final output + self._sample_interval = sample_interval + + @property + def isatty(self) -> bool: + return self._isatty + + # -- task lifecycle ---------------------------------------------------- + + def start_task(self, id, kind: str, name: str, detail: str = '') -> Task: + # Create on the first phase, else RESUME the existing dep task on a new phase (so check -> + # configure -> build stay one line). Either way INVISIBLE until it outlives reveal_delay, so + # an instant no-op (~0.0s cached dep) never clutters output. + with self._lock: + t = self._tasks.get(id) + if t is None: t = self._tasks[id] = Task(id, kind, name, self._clock(), detail) + else: t.begin(kind, self._clock(), detail) + if id not in self._active: self._active.append(id) + if self._isatty: self.render() # render OUTSIDE the state lock (terminal I/O must not block feeders) + return t + + def relabel(self, id, kind: str): + """Change a task's kind after the fact (a load task only knows it cloned/pulled/checked once done).""" + with self._lock: + t = self._tasks.get(id) + if t is not None: t.kind = kind + + def set_pending(self, hint): + """Show the single next blocked task `(name, reason)` below the live region, or clear it (None). + Renders on change so the line updates even when nothing else draws - the stalled-scheduler case + the user most wants to see.""" + with self._lock: + if hint == self._pending_hint: return + self._pending_hint = hint + if self._isatty: self.render() + + def feed(self, id, line: str): + with self._lock: + t = self._tasks.get(id) + if t is None: return + t.feed(line) + if self._isatty: self.render() # state lock released first: a slow draw can't stall the subprocess reader + + def finish_task(self, id, ok: bool, final: bool = True): + # End the current phase. A non-final success stays DORMANT (no summary yet); the dep's last + # phase (final=True) or any failure commits ONE merged summary for the whole dep. Every phase + # is recorded so the table shows all steps (incl. an instant 0ms configure); only a dep whose + # every phase was instant (a pure cached no-op) is hidden. + with self._lock: + t = self._tasks.get(id) + if t is None: return + t.end = self._clock() + t.state = 'ok' if ok else 'fail' + if id in self._active: self._active.remove(id) + t.phases.append((t.elapsed(t.end), t.kind, t.detail)) + done = final or not ok # workflow over -> emit; else dormant, resume later + show = done and (not ok or any(d >= self._reveal for d, _, _ in t.phases)) # hide a wholly-instant dep + if done: self._log_task(t) # full per-target output -> log, even for deps hidden from the live region + if not self._isatty: + if show: + self._writeln(self._summary_line(t)) + if self._verbose or not ok: + for line in t.lines: self._writeln(line) + return + if show: self._pending.append(self._summary_line(t)) + self.render(force=True) # commit the summary + redraw the shrunken region, off the state lock + + # -- permanent output (above the live region) -------------------------- + + def print_above(self, text: str): + """Emit a line that survives above the live region (status messages).""" + if self._log is not None: self._log.write(text + '\n') + with self._lock: + if not self._isatty or self._closed: # no live region (or it's gone): write it straight out + self._writeln(text); return + self._pending.append(text) + self.render(force=True) + + def _log_task(self, t: Task): + """Write a target's whole captured buffer to the build log as ONE contiguous block (all phases, + verbatim) so the log has the full configure/build output the live region only previews, never + intermixed across parallel targets.""" + if self._log is None or not t.lines: return + self._log.write(f'\n{"=" * 100}\n{self._summary_line(t)}\n{"-" * 100}\n') + self._log.write('\n'.join(t.lines) + '\n') + + def replay(self, id): + """Dump the FAILING phase's captured output permanently (colours intact). Not the whole dep + buffer: replaying load/configure output after the build died reads as stale mid-build noise + long after the fact. The full history is still in mamabuild.log.""" + with self._lock: + t = self._tasks.get(id) + if t is None: return + self._clear_region() + for line in t.lines[t.phase_start:]: self._writeln(line) + + def diagnostics(self, id, limit=8): + """Compiler warnings/errors captured for a finished dep task, for the post-build summary.""" + t = self._tasks.get(id) + return scan_diagnostics(t.lines, limit) if t else ([], 0, 0) + + # -- rendering --------------------------------------------------------- + + def render(self, force=False): + """Draw the live frame. A forced render waits for the terminal; a normal one SKIPS if another + thread is already drawing (it'll be covered by that draw / the next tick), so feeders never + block. State is snapshotted under the short state lock; the terminal write happens off it.""" + if not self._isatty or self._closed: + return + if force: self._render_lock.acquire() + elif not self._render_lock.acquire(blocking=False): return + try: + with self._lock: + if self._closed: return # closed while we waited for the terminal + now = self._clock() + if not force and (now - self._last_render) < self._min_interval: + return + self._last_render = now + pending, self._pending = self._pending, [] + region = self._region_lines(now) + prev_drawn, self._drawn = self._drawn, len(region) + frame = (_CURSOR_UP + '\r' + _ERASE_EOL) * prev_drawn + frame += ''.join(line + _ERASE_EOL_LF for line in pending + region) + self._out.write(frame) + self._flush() + finally: + self._render_lock.release() + + def close(self): + """Finalize: stop the CPU sampler, flush any pending permanent lines, drop the live region.""" + self._stop.set() + if self._sampler is not None: self._sampler.join(timeout=1.0) # join off-lock: sampler takes it + # take the render lock too: a slow sampler scan can outlive the join, and its render would + # otherwise redraw the region UNDER the final output with _drawn already reset to 0 + with self._render_lock, self._lock: + self._closed = True + if self._isatty: + self._clear_region() + for line in self._pending: self._writeln(line) + self._pending.clear() + self._drawn = 0 + self._flush() + if self._log is not None: self._log.close() # drain + close the async log writer + + # -- internals --------------------------------------------------------- + + def _clear_region(self): + # Cursor sits below the region (after trailing newlines); walk up, + # erasing each line, to land at the region's top-left. + if self._drawn: + self._out.write((_CURSOR_UP + '\r' + _ERASE_EOL) * self._drawn) + self._drawn = 0 + + def _region_lines(self, now: float) -> list[str]: + cols, rows = self._term_size() + cap = max(1, rows - self._margin) + ids = [i for i in self._active if self._tasks[i].elapsed(now) >= self._reveal] # past reveal delay + lines = [self._task_line(self._tasks[i], now, cols) for i in ids] + if self._pending_hint: # the single next blocked task + why, so a stall is visible at a glance + lines.append(self._pending_line(self._pending_hint[0], self._pending_hint[1], cols)) + if len(lines) > cap: + lines[cap - 1:] = [self._truncate(f' ... (+{len(lines) - (cap - 1)} more)', cols)] + return lines + + def _pending_line(self, name: str, reason: str, cols: int) -> str: + icon = self._colored('~', Color.BLUE) + return self._truncate(f'{icon} {"pending":<24}{name:<22} {reason}', cols) + + @staticmethod + def _kind_field(kind: str, detail: str, cpu: float = 0.0) -> str: + s = f'{kind} {detail}' if detail else kind # 'build J12' / 'build J8 ' / 'configure' + if cpu >= 1.0: s += ' cpu:' + f'{cpu:.0f}%'.ljust(5) # fixed-width slot: 'cpu:132% ' / 'cpu:2790%' + return s + + @staticmethod + def _tag(kind: str) -> str: + return _PHASE_TAG.get(kind, (kind[:3] or '?').lower()) + + def _time_field(self, t: Task, now: float) -> str: + # Always tag every phase (even a lone build -> 'bld 4.0s'), so the timing column stays + # consistent whether or not configure/load did visible work. + phases = t.phases + ([(t.elapsed(now), t.kind, t.detail)] if t.state == 'run' else []) + return ' '.join(f'{self._tag(k)} {_fmt_dur(d)}' for d, k, _ in phases) + + def _task_line(self, t: Task, now: float, cols: int) -> str: + icon = self._colored(_ICON[t.state], _ICON_COLOR[t.state]) + preview = _ANSI_RE.sub('', t.current) # strip colours so width math is correct + head = f'{icon} {self._kind_field(t.kind, t.detail, t.cpu):<24}{t.name:<22} {self._time_field(t, now)} ' + return self._truncate((head + preview).rstrip(), cols) # rstrip: no trailing pad when there's no preview yet + + def _summary_line(self, t: Task) -> str: + icon = self._colored(_ICON[t.state], _ICON_COLOR[t.state]) + _, kind, detail = t.phases[-1] if t.phases else (0, t.kind, t.detail) # kind = last phase that did work + return f'{icon} {self._kind_field(kind, detail):<24}{t.name:<22} {self._time_field(t, t.end)}' + + # -- live CPU sampling ------------------------------------------------- + + def attach_pid(self, tid, pid: int): + """Register a running child pid so the sampler can attribute its process-tree CPU to `tid`. + Only build tasks are sampled - a CPU% on a configure/clone/update step is noise and wasted work.""" + with self._lock: + t = self._tasks.get(tid) + if t is None or t.kind != 'build': return + self._pids.setdefault(tid, set()).add(pid) + self._ensure_sampler() + + def detach_pid(self, tid, pid: int): + with self._lock: + pids = self._pids.get(tid) + if not pids: return + pids.discard(pid) + if not pids: + del self._pids[tid] + t = self._tasks.get(tid) + if t is not None: t.cpu = 0.0 # subprocess gone -> stop showing stale CPU + + def _ensure_sampler(self): + if self._sampler is not None or not self._isatty: return + with self._lock: + if self._sampler is not None: return + if self._cpu_auto: + self._cpu_sampler = proc_cpu.make_sampler(); self._cpu_auto = False + if self._cpu_sampler is None: return # psutil unavailable -> feature off + self._sampler = threading.Thread(target=self._sample_loop, daemon=True) + self._sampler.start() + + def _next_wait(self, sample_cost: float) -> float: + # back off when a sample is expensive (busy host, huge process table) so CPU sampling can never + # exceed ~10% of wall-time - a hard cap against starving the build threads (cost*9 -> 1-in-10). + return max(self._sample_interval, sample_cost * 9) + + def _sample_loop(self): + wait = self._sample_interval + while not self._stop.wait(wait): + t0 = self._clock() + try: self._sample_once() + except Exception: pass # CPU readout is best-effort, never break the display + wait = self._next_wait(self._clock() - t0) + self.render() # reflect updated CPU numbers (throttled by min_interval) + + def _sample_once(self): + with self._lock: + snapshot = {tid: set(pids) for tid, pids in self._pids.items() if pids} + if not snapshot: return + cpus = self._cpu_sampler(snapshot) # ONE process scan for ALL build trees -> {tid: cpu%}; off-lock + with self._lock: + for tid, cpu in cpus.items(): + if tid not in self._pids: continue # detached mid-scan: don't resurrect a dead task's CPU + t = self._tasks.get(tid) + if t is not None: t.cpu = cpu + + def _truncate(self, text: str, cols: int) -> str: + # Cap to cols-1 to avoid wrapping that would break the cursor math. If it + # fits, keep colours; if not, truncate the plain text (drops the icon colour). + # Newlines are neutralized first: ONE stray \n in a region line shifts the cursor and + # desyncs the whole redraw, so the region must be physically unable to emit one. + if '\n' in text or '\r' in text: text = text.replace('\r', ' ').replace('\n', ' ') + limit = max(1, cols - 1) + plain = _ANSI_RE.sub('', text) + return text if len(plain) <= limit else plain[:limit] + + def _colored(self, text: str, color) -> str: + return get_colored_text(text, color) if self._color else text + + def _writeln(self, text: str): + self._out.write(text + _ERASE_EOL_LF if self._isatty else text + '\n') + + def _flush(self): + flush = getattr(self._out, 'flush', None) + if flush: flush() diff --git a/mama/utils/log_writer.py b/mama/utils/log_writer.py new file mode 100644 index 0000000..735db16 --- /dev/null +++ b/mama/utils/log_writer.py @@ -0,0 +1,55 @@ +"""Async build log: a daemon thread drains a queue to packages/mamabuild.log so write() never blocks +a build thread. ANSI is stripped for a clean, greppable file. Best-effort - a bad path or IO error +never breaks a build.""" +import os, re, threading, queue + +_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[A-Za-z]') # SGR colours + cursor moves, stripped for the log file + + +class AsyncLogWriter: + def __init__(self, stream, flush_interval=1.0): + """`stream`: an open, writable text stream this writer owns (open_build_log wraps the + packages/mamabuild.log case; tests pass a capture stream). `flush_interval`: writes go to the + buffered stream as they arrive (cheap) but are fsync-flushed only on an idle lull, so bursts are + amortized yet confirmed output still lands on disk promptly (tail-able mid-build).""" + self._stream = stream + self._flush_interval = flush_interval + self._q: queue.Queue = queue.Queue() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def write(self, text: str): + self._q.put(text) + + def _flush(self): + try: self._stream.flush() + except (OSError, ValueError): pass # stream closed/broken mid-build: the log is best-effort + + def _loop(self): + dirty = False + while True: + try: + item = self._q.get(timeout=self._flush_interval) + except queue.Empty: + if dirty: self._flush(); dirty = False # a lull -> persist the confirmed output so far + continue + if item is None: break + try: self._stream.write(_ANSI_RE.sub('', item)); dirty = True + except (OSError, ValueError): pass + self._flush() + + def close(self): + self._q.put(None) + self._thread.join(timeout=2.0) + try: self._stream.flush(); self._stream.close() + except (OSError, ValueError): pass + + +def open_build_log(path: str): + """Open `path` (truncating) as an AsyncLogWriter, or None if it can't be created - the log must + never break a build.""" + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + return AsyncLogWriter(open(path, 'w', encoding='utf-8')) + except OSError: + return None diff --git a/mama/utils/proc_cpu.py b/mama/utils/proc_cpu.py new file mode 100644 index 0000000..b5680eb --- /dev/null +++ b/mama/utils/proc_cpu.py @@ -0,0 +1,136 @@ +"""Per-build subprocess-tree CPU% sampling, isolated for unit-testing + benchmarking. + +A sampler is `callable(snapshot) -> {tid: cpu%}`, where snapshot is `{tid: set(root_pids)}`. It sums +the cpu-time delta since the last sample over wall-clock for each build's OWN process tree (cmake -> +ninja/make/msbuild -> compilers), so a tree saturating N cores reads ~N*100%. It reads CPU only for +those trees, never every system process. Windows uses kernel32 directly (one CreateToolhelp32Snapshot ++ GetProcessTimes); other platforms use psutil. make_sampler() picks the right one (None if neither).""" +import time +from .system import System + + +def make_sampler(): + if System.windows: + try: return WinTreeCpu() # low-level kernel32: far cheaper than psutil on Windows + except Exception: pass # ctypes oddity -> fall back to psutil + try: import psutil + except ImportError: return None + return PsutilTreeCpu(psutil) + + +def accumulate_cpu(state: dict, now: float, trees: dict) -> dict: + """trees: {tid: {pid: (cpu_seconds, create_ts)}} -> {tid: cpu%}. Per pid, CPU% is the cpu-time + delta since the last sample over wall-clock (first sight averages over the lifetime so far), so a + build tree saturating N cores reads ~N*100%. `state` (pid -> (cpu_seconds, ts)) carries the delta + across samples and is pruned of pids no longer in any tree.""" + result, seen = {}, set() + for tid, procs in trees.items(): + total = 0.0 + for pid, (cur, create) in procs.items(): + seen.add(pid) + base_cpu, base_ts = state.get(pid, (0.0, create)) + state[pid] = (cur, now) + dt = now - base_ts + if dt > 0: total += max(0.0, (cur - base_cpu) / dt * 100.0) + result[tid] = total + for pid in [p for p in state if p not in seen]: del state[pid] # drop dead procs + return result + + +class PsutilTreeCpu: + """Process-tree CPU% via psutil (non-Windows): cpu_times read ONLY for each build's own tree, never + every system process. oneshot() batches the per-proc cpu_times + create_time into one read.""" + def __init__(self, psutil): + self._ps = psutil + self._state: dict = {} # pid -> (cpu_seconds, wallclock_ts) + + def __call__(self, snapshot) -> dict: + ps, trees = self._ps, {} + for tid, roots in snapshot.items(): + procs = {} + for rp in roots: + try: + root = ps.Process(rp) + tree = [root] + root.children(recursive=True) + except ps.Error: continue + for proc in tree: + try: + with proc.oneshot(): procs[proc.pid] = (sum(proc.cpu_times()[:2]), proc.create_time()) + except ps.Error: continue + trees[tid] = procs + return accumulate_cpu(self._state, time.time(), trees) + + +class WinTreeCpu: + """Process-tree CPU% straight from kernel32 (no psutil): ONE CreateToolhelp32Snapshot builds the + ppid tree of all processes, then GetProcessTimes reads CPU for each build-tree pid. psutil's + children(recursive=True) snapshots the whole process table once PER build root, plus heavy + per-process handle work - that is what made Windows sampling slow.""" + _SNAPPROCESS = 0x00000002 + _QUERY = 0x1000 # PROCESS_QUERY_LIMITED_INFORMATION + + def __init__(self): + import ctypes + from ctypes import wintypes + DW, H, BOOL, PTR = wintypes.DWORD, wintypes.HANDLE, wintypes.BOOL, ctypes.POINTER + class PE32(ctypes.Structure): + _fields_ = [('dwSize', DW), ('cntUsage', DW), ('th32ProcessID', DW), + ('th32DefaultHeapID', PTR(ctypes.c_ulong)), ('th32ModuleID', DW), ('cntThreads', DW), + ('th32ParentProcessID', DW), ('pcPriClassBase', ctypes.c_long), ('dwFlags', DW), + ('szExeFile', ctypes.c_char * 260)] + class FT(ctypes.Structure): + _fields_ = [('lo', DW), ('hi', DW)] + k = ctypes.WinDLL('kernel32', use_last_error=True) + k.CreateToolhelp32Snapshot.restype, k.CreateToolhelp32Snapshot.argtypes = H, [DW, DW] + k.OpenProcess.restype, k.OpenProcess.argtypes = H, [DW, BOOL, DW] + k.CloseHandle.argtypes = [H] + k.Process32First.argtypes = k.Process32Next.argtypes = [H, PTR(PE32)] + k.GetProcessTimes.argtypes = [H] + [PTR(FT)] * 4 + self._ct, self._k, self._PE32, self._FT = ctypes, k, PE32, FT + self._invalid = ctypes.c_void_p(-1).value + self._state: dict = {} # pid -> (cpu_seconds, wallclock_ts) + + def _ppid_map(self) -> dict: + ct, k = self._ct, self._k + snap = k.CreateToolhelp32Snapshot(self._SNAPPROCESS, 0) + if not snap or snap == self._invalid: return {} + try: + e = self._PE32(); e.dwSize = ct.sizeof(self._PE32); out = {} + ok = k.Process32First(snap, ct.byref(e)) + while ok: + out[e.th32ProcessID] = e.th32ParentProcessID + ok = k.Process32Next(snap, ct.byref(e)) + return out + finally: + k.CloseHandle(snap) + + def _proc_times(self, pid): + ct, k = self._ct, self._k + h = k.OpenProcess(self._QUERY, False, pid) + if not h: return None + try: + c, x, kern, usr = self._FT(), self._FT(), self._FT(), self._FT() + if not k.GetProcessTimes(h, ct.byref(c), ct.byref(x), ct.byref(kern), ct.byref(usr)): + return None + tick = lambda f: (f.hi << 32) | f.lo # 100ns units + return (tick(kern) + tick(usr)) / 1e7, (tick(c) - 116444736000000000) / 1e7 # cpu_s, create_unix + finally: + k.CloseHandle(h) + + def __call__(self, snapshot) -> dict: + ppids = self._ppid_map() + kids: dict = {} + for pid, ppid in ppids.items(): kids.setdefault(ppid, []).append(pid) + trees = {} + for tid, roots in snapshot.items(): + tree, stack = set(), [p for p in roots if p in ppids] + while stack: + pid = stack.pop() + if pid in tree: continue + tree.add(pid); stack.extend(kids.get(pid, ())) + procs = {} + for pid in tree: + t = self._proc_times(pid) + if t is not None: procs[pid] = t + trees[tid] = procs + return accumulate_cpu(self._state, time.time(), trees) diff --git a/mama/utils/run.py b/mama/utils/run.py index 0410bbb..4065c54 100644 --- a/mama/utils/run.py +++ b/mama/utils/run.py @@ -76,10 +76,10 @@ def run_in_working_dir(target: BuildTarget, working_dir: str, command: str, exit execute_echo(cwd=cwd, cmd=f'{exe} {args}', exit_on_fail=exit_on_fail, env=env) -def run_in_project_dir(target: BuildTarget, command: str, src_dir=False, exit_on_fail=True, env=None): +def run_in_project_dir(target: BuildTarget, command: str, src_dir=False, exit_on_fail=True, env=None, quiet=False): cwd = target.source_dir() if src_dir else target.build_dir() cwd, exe, args = get_cwd_exe_args(target, command, cwd=cwd) - execute_echo(cwd=cwd, cmd=f'{exe} {args}', exit_on_fail=exit_on_fail, env=env) + execute_echo(cwd=cwd, cmd=f'{exe} {args}', exit_on_fail=exit_on_fail, env=env, quiet=quiet) def run_in_command_dir(target: BuildTarget, command: str, src_dir=False, exit_on_fail=True, env=None): diff --git a/mama/utils/ssh_multiplex.py b/mama/utils/ssh_multiplex.py index bea7c52..1e30a4e 100644 --- a/mama/utils/ssh_multiplex.py +++ b/mama/utils/ssh_multiplex.py @@ -46,6 +46,11 @@ _DEFAULT_KEEPALIVE_COUNT = '3' _DEFAULT_CONTROL_PERSIST = '10m' +# Always-on so a parallel clone never freezes on an SSH prompt: bound the TCP connect + auto-accept +# NEW host keys (still rejects CHANGED). NOT BatchMode (would break interactive-passphrase keys); +# the SubProcess idle-timeout is the backstop for stuck auth prompts. +_SAFETY_OPTS = ['-oConnectTimeout=30', '-oStrictHostKeyChecking=accept-new'] + # Module state ------------------------------------------------------------- @@ -156,7 +161,7 @@ def options_to_add(probe: dict[str, str]) -> tuple[list[str], bool]: cleaning it up). False if the user already has multiplex configured, or if multiplex is known-broken on this platform. """ - opts: list[str] = [] + opts: list[str] = list(_SAFETY_OPTS) we_own_master = False if not multiplex_known_broken() and not is_multiplex_configured(probe): we_own_master = True diff --git a/mama/utils/sub_process.py b/mama/utils/sub_process.py index 85a61b9..e5408a0 100644 --- a/mama/utils/sub_process.py +++ b/mama/utils/sub_process.py @@ -1,6 +1,6 @@ -import os, shlex, shutil, threading, queue +import os, shlex, shutil, threading, queue, time, signal import subprocess -from .system import System, console, error +from .system import System, console, error, report_subprocess, capture_to, capture_context # Linux/macOS: we allocate a PTY for the child so git etc. still see a TTY @@ -12,13 +12,17 @@ # the threaded-deadlock risk. if not System.windows: import pty - import select READER_IDLE_TIMEOUT = 0.1 # seconds; how long to wait before flushing a \r-progress partial READER_CHUNK = 8192 +_procs_lock = threading.Lock() +_live_procs = set() # live SubProcess instances; killed en masse by terminate_all() on Ctrl+C +_aborting = False # set by terminate_all(): blocks new spawns so an interrupted build can't relaunch + + class SubProcess: """ Subprocess wrapper with optional line-by-line output capture. @@ -42,6 +46,9 @@ def __init__(self, cmd, cwd=None, env=None, io_func=None): self._reader_exc = None # exception raised inside io_func (re-raised in run()) self._master_fd = None # UNIX PTY master fd; None on Windows or no-io_func paths self._swallow_lf = False # after \r-progress idle-flush, swallow a leading \n (or \r\n) in next chunk + self._last_output = time.monotonic() # bumped on every chunk; drives the idle-timeout watchdog + self._group = False # True: child leads its own group/session -> kill() tears down its whole tree + self._killed = False # set by kill(); close() only force-closes the pipe early when we killed env = env if env else os.environ.copy() args = shlex.split(cmd) if isinstance(cmd, str) else list(cmd) @@ -70,62 +77,49 @@ def __init__(self, cmd, cwd=None, env=None, io_func=None): # reader can byte-level split on \r as well as \n (ninja/cmake progress). self.process = subprocess.Popen(args, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self._group = True # kill() uses taskkill /T to take down cmake's ninja/compiler subtree else: # Allocate a PTY pair; child gets the slave end as its stdin/stdout/stderr. self._master_fd, slave = pty.openpty() try: - self.process = subprocess.Popen(args, cwd=cwd, env=env, - stdin=slave, stdout=slave, stderr=slave, close_fds=True) + # start_new_session: child leads its own session so kill() can killpg the whole tree + # (cmake -> ninja -> compilers), not just the spawned pid. + self.process = subprocess.Popen(args, cwd=cwd, env=env, stdin=slave, stdout=slave, + stderr=slave, close_fds=True, start_new_session=True) + self._group = True finally: os.close(slave) # parent doesn't need the slave once Popen has it + # io_func runs on the reader thread; carry the caller's console-capture context onto it so its + # console() lines feed the owning display task instead of leaking above the live region. + self._capture_ctx = capture_context() self._reader_thread = threading.Thread(target=self._read_loop, daemon=True) self._reader_thread.start() def _read_loop(self): try: - if self._master_fd is not None: - self._read_loop_pty() - else: - self._read_loop_pipe() + with capture_to(*self._capture_ctx): # io_func's console() -> owning task, not above the region + fd = self._master_fd if self._master_fd is not None else \ + (self.process.stdout.fileno() if self.process.stdout else None) + if fd is not None: self._read_loop_queued(fd) except Exception as e: - # Capture so run() can surface it. Don't crash the reader thread. - self._reader_exc = e + self._reader_exc = e # captured so run() can surface it; don't crash the reader thread - def _read_loop_pty(self): - """Drain PTY master with select-based idle detection so \\r-terminated - progress (ninja/cmake/git) surfaces without waiting for a \\n.""" - buf = bytearray() - while True: - ready, _, _ = select.select([self._master_fd], [], [], READER_IDLE_TIMEOUT) - if ready: - try: chunk = os.read(self._master_fd, READER_CHUNK) - except OSError: break # EIO on a closed slave end - if not chunk: break - buf.extend(chunk) - self._drain_buffer(buf) - else: - self._drain_buffer(buf, idle=True) - self._drain_buffer(buf, eof=True) - - - def _read_loop_pipe(self): - """Windows path: select() doesn't work on pipes, so a helper thread does the blocking - byte reads and hands chunks to a queue. The drain loop waits on the queue with - READER_IDLE_TIMEOUT, giving the same \\r-progress idle-flush as the PTY path. Without it - a \\r at a chunk boundary hangs until the next read, and a CRLF after it (the CRT turns - the child's \\n into \\r\\n) surfaces as a spurious empty line.""" - stdout = self.process.stdout - if not stdout: return - fd = stdout.fileno() + def _read_loop_queued(self, fd): + """One reader for both PTY (UNIX) and pipe (Windows): a pump thread does the blocking + os.read(fd) and hands chunks to a queue; this drain loop turns them into lines with the + \\r-progress idle-flush (queue.get timeout). Decoupling the read from io_func means a slow + consumer (or GIL contention from CPU sampling) never stalls os.read, so the child's PTY/pipe + can't fill and block the read.""" chunks: queue.Queue = queue.Queue() def pump(): while True: try: chunk = os.read(fd, READER_CHUNK) - except OSError: chunk = b'' - chunks.put(chunk) + except OSError: chunk = b'' # EIO on a closed PTY slave / closed pipe = EOF + if chunk: self._last_output = time.monotonic() # reset idle watchdog AS DATA ARRIVES, not + chunks.put(chunk) # when the drain processes it (drain can lag under load) if not chunk: break threading.Thread(target=pump, daemon=True).start() buf = bytearray() @@ -158,19 +152,19 @@ def _drain_buffer(self, buf:bytearray, idle=False, eof=False): cr = buf.find(b'\r', pos) if cr >= 0: if cr + 1 < n and buf[cr + 1] != 0x0a: - self._emit_io_out(buf[pos:cr]); - pos = cr + 1; + self._emit_io_out(buf[pos:cr]) + pos = cr + 1 continue if cr + 1 == n and (idle or eof): - self._emit_io_out(buf[pos:cr]); - pos = cr + 1; - self._swallow_lf = True; + self._emit_io_out(buf[pos:cr]) + pos = cr + 1 + self._swallow_lf = True continue nl = buf.find(b'\n', pos) if nl >= 0: end = nl - 1 if nl > pos and buf[nl - 1] == 0x0d else nl - self._emit_io_out(buf[pos:end]); - pos = nl + 1; + self._emit_io_out(buf[pos:end]) + pos = nl + 1 continue break if eof and pos < n: @@ -201,29 +195,82 @@ def write(self, text: str): pass + @staticmethod + def terminate_all(): + """Ctrl+C handler: block new spawns and kill every live child so a parallel build unwinds + fast instead of the thread pool blocking on in-flight compiles. Idempotent.""" + global _aborting + with _procs_lock: # set the flag + snapshot atomically so a concurrent run() can't slip a + _aborting = True # child past us (it re-checks _aborting under the same lock after spawning) + procs = list(_live_procs) + for p in procs: + try: p.kill() + except Exception: pass + + @staticmethod + def clear_abort(): + """Re-arm spawning after a terminated build (so a later run in the same process starts clean).""" + global _aborting + _aborting = False + def kill(self): - if self.process and self.process.poll() is None: + p = self.process + if not p or p.poll() is not None: + return + self._killed = True + if self._group: + self._kill_tree(p) # build/clone child: take down its whole subtree, not just the pid + return + try: + p.terminate() + p.wait(timeout=1.0) + except subprocess.TimeoutExpired: try: - self.process.terminate() - self.process.wait(timeout=1.0) - except subprocess.TimeoutExpired: - try: - self.process.kill() - self.process.wait(timeout=1.0) - except Exception: - pass + p.kill() + p.wait(timeout=1.0) except Exception: pass + except Exception: + pass + + def _kill_tree(self, p): + """Kill the child AND its descendants (ninja + compilers). A plain terminate()/kill() hits + only the spawned cmake/git pid; on Windows TerminateProcess and on UNIX a single SIGKILL both + leave the compiler grandchildren running. taskkill /T walks the child tree; killpg signals the + whole session. Falls back to a single-process kill if the tree call fails. + Raw subprocess.run (not SubProcess.run): the killer must not register in _live_procs nor be + blocked by the _aborting guard (it runs precisely while aborting).""" + try: + if System.windows: + subprocess.run(['taskkill', '/F', '/T', '/PID', str(p.pid)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + else: + os.killpg(os.getpgid(p.pid), signal.SIGKILL) + except Exception: + try: p.kill() + except Exception: pass + try: p.wait(timeout=2.0) + except Exception: pass def close(self): - self.kill() - # Reader thread exits when the PTY master sees EOF (slave closed by - # the child or by Popen's __exit__). Join briefly to drain any - # trailing buffered output the io_func hasn't seen yet. + self.kill() # no-op if the child already exited; sets self._killed if it had to kill a live one + win_out = self.process.stdout if (System.windows and self.process) else None + # Force the Windows read-end shut ONLY when we killed the child: its grandchildren (ninja/compilers) + # may still hold the write end, so the pump would block in os.read forever. On a CLEAN exit the write + # end is already closed, so closing here would race the pump and DROP the final buffered lines (e.g. + # the compiler error that failed the build) - instead drain first (join) and close after. + if win_out and self._killed: + try: win_out.close() + except OSError: pass + # Reader thread drains its queue then exits on EOF (Windows pipe closed, or UNIX PTY master closed + # below). Join so all trailing output reaches io_func before we return. if self._reader_thread: self._reader_thread.join(timeout=2.0) self._reader_thread = None + if win_out and not win_out.closed: # clean-exit path: now that the reader has drained, close it + try: win_out.close() + except OSError: pass if self._master_fd is not None: try: os.close(self._master_fd) @@ -243,8 +290,24 @@ def try_wait(self): return self.status + def _wait_idle(self, timeout, idle_timeout): + """Wait for the child, killing it if it's silent for `idle_timeout` s (or exceeds total + `timeout`). The idle bound catches a git op stuck on an auth prompt / hung server without + aborting a slow-but-streaming clone. Raises TimeoutExpired on either bound.""" + start = time.monotonic() + while True: + try: + return self.process.wait(timeout=0.25) + except subprocess.TimeoutExpired: + pass + now = time.monotonic() + if timeout is not None and now - start > timeout: + self.kill(); raise subprocess.TimeoutExpired(self.process.args, timeout) + if now - self._last_output > idle_timeout: + self.kill(); raise subprocess.TimeoutExpired(self.process.args, idle_timeout) + @staticmethod - def run(cmd, cwd=None, env=None, io_func=None, timeout=None): + def run(cmd, cwd=None, env=None, io_func=None, timeout=None, idle_timeout=None): """ Runs `cmd` and returns its exit status. - cmd: command string (shlex.split) or list of args. @@ -252,17 +315,30 @@ def run(cmd, cwd=None, env=None, io_func=None, timeout=None): - env: environment dict, defaults to os.environ. - io_func: callback `(SubProcess, line:str)` for each output line; if None, child inherits parent's std streams. - - timeout: kill the child after this many seconds (raises - subprocess.TimeoutExpired). Default: no timeout. + - timeout: kill the child after this many seconds total (raises TimeoutExpired). + - idle_timeout: kill if silent this many seconds (raises TimeoutExpired). Needs io_func set. + For network git ops that may hang on a prompt; a streaming clone is never killed. """ + if _aborting: raise KeyboardInterrupt('build aborted') # fast path: don't even spawn after Ctrl+C p = SubProcess(cmd, cwd=cwd, env=env, io_func=io_func) + pid = p.process.pid if p.process else None + with _procs_lock: + if _aborting: # aborted mid-spawn: kill this child now instead of leaking it past terminate_all + p.close(); raise KeyboardInterrupt('build aborted') + _live_procs.add(p) # registered so terminate_all() can kill it on Ctrl+C + if pid is not None: report_subprocess(pid, True) # live CPU sampling for the owning display task try: try: - p.status = p.process.wait(timeout=timeout) + if idle_timeout is not None: + p.status = p._wait_idle(timeout, idle_timeout) + else: + p.status = p.process.wait(timeout=timeout) except subprocess.TimeoutExpired: p.kill() raise finally: + with _procs_lock: _live_procs.discard(p) + if pid is not None: report_subprocess(pid, False) p.close() if p._reader_exc is not None: raise p._reader_exc @@ -305,18 +381,26 @@ def execute_piped(command, cwd=None, timeout=None, throw=True): return None -def execute_echo(cwd, cmd, exit_on_fail=False, env=None): +def execute_echo(cwd, cmd, exit_on_fail=False, env=None, quiet=False): """ Wrapper around SubProcess.run(), by default throws if exit_status != 0 - cwd: working dir for the subprocess - cmd: command string - exit_on_fail: if True, exits the application with exit_status - env: overrrides the environment for the subprocess, default is os.environ + - quiet: if True, drop the child's output entirely (it still runs and is exit-checked) """ + # Inside a scheduled build phase a capture sink is active: route the child's output through console() + # so a custom build()'s commands land in the owning display task (and the log) instead of tearing the + # live region. Outside it (serial path, interactive run/gdb/test post-pass) keep stdio direct - the + # child needs the real terminal for prompts, and there's nowhere to capture to anyway. + if quiet: io = lambda p, line: None # caller asked for silence: drop output + elif capture_context()[0] is not None: io = lambda p, line: console(line) + else: io = None exit_status = -1 throw_on_fail = not exit_on_fail try: - exit_status = SubProcess.run(cmd, cwd, env=env, io_func=None) + exit_status = SubProcess.run(cmd, cwd, env=env, io_func=io) except: error(f'SubProcess exited cwd={cwd} cmd={cmd}') if throw_on_fail: @@ -328,24 +412,24 @@ def execute_echo(cwd, cmd, exit_on_fail=False, env=None): exit(exit_status) -def execute_piped_echo(cwd, cmd, echo=True, env=None): +def execute_piped_echo(cwd, cmd, echo=True, env=None, out=None): """ Wrapper around SubProcess.run(), returns status code with piped output (status, output). - cwd: working dir for the subprocess - cmd: command string - echo: if True, also prints the output to console - env: overrrides the environment for the subprocess, default is os.environ + - out: optional `(line) -> None` sink; when set, lines go there instead of being printed - returns: (exit_status, output_string) """ + lines = [] # list + join, NOT output += line: the latter is O(n^2) over a big build's output + def handle_output(p:SubProcess, line:str): + if out: out(line) + elif echo: console(line) # NOT print: a raw write tears the live region's cursor math + lines.append(line) try: - exit_status = -1 - output = '' - def handle_output(p:SubProcess, line:str): - nonlocal output - if echo: print(line) - output += line - output += '\n' # newline is not included exit_status = SubProcess.run(cmd, cwd, env=env, io_func=handle_output) - return (exit_status, output) + return (exit_status, '\n'.join(lines)) except Exception as e: - return (-1, f'{output}{e}') + lines.append(str(e)) + return (-1, '\n'.join(lines)) diff --git a/mama/utils/system.py b/mama/utils/system.py index c94974a..f0011a6 100644 --- a/mama/utils/system.py +++ b/mama/utils/system.py @@ -1,4 +1,4 @@ -import sys, subprocess, platform, threading +import sys, subprocess, platform, threading, contextlib from termcolor import colored is_windows = sys.platform == 'win32' @@ -53,20 +53,86 @@ def get_colored_text(text:str, color): _console_lock = threading.Lock() _progress_active = False # last write left cursor mid-row _ERASE_EOL = '\x1b[K' # ANSI erase-to-end-of-line (colorama enables it on Windows) +_active_display = None # duck-typed BuildDisplay; routes normal lines above its live region +_capture = threading.local() # per-thread sink: a running job's console() lines go to its display task + + +def set_active_display(display): + """While a live display is active, normal console() lines route above its region instead of + tearing it. None detaches. Duck-typed (has print_above) to avoid importing build_display.""" + global _active_display + _active_display = display + + +def capture_context(): + """Snapshot this thread's console-capture state as a (sink, display, tid, build_slot) tuple. A + helper thread that runs io_func - SubProcess's reader thread - re-establishes it via + capture_to(*ctx); without that, io_func's console() lines have no sink and leak above the live + region instead of feeding the owning display task.""" + return (getattr(_capture, 'sink', None), getattr(_capture, 'display', None), + getattr(_capture, 'tid', None), getattr(_capture, 'build_slot', None)) + + +@contextlib.contextmanager +def capture_to(sink, display=None, tid=None, build_slot=None): + """Route THIS thread's console() lines to `sink` (a display task feed) so a job's banners land + in its display line instead of tearing the live region; restores the previous sink on exit. + `display`/`tid` let SubProcess report child pids for CPU sampling; `build_slot` is the + scheduler barrier so a custom build()'s cmake_build() can self-gate.""" + prev = capture_context() + _capture.sink, _capture.display, _capture.tid, _capture.build_slot = sink, display, tid, build_slot + try: + yield + finally: + _capture.sink, _capture.display, _capture.tid, _capture.build_slot = prev + + +def build_barrier(weight: int): + """Wrap a heavy compile (cmake_build's build step) so it occupies `weight` budget cores in the + active scheduler, suspending the worker until admitted. A no-op (null context) on the serial + path / in tests, so mamafile build() call sites need no changes.""" + factory = getattr(_capture, 'build_slot', None) + return factory(weight) if factory is not None else contextlib.nullcontext() + + +def report_subprocess(pid: int, started: bool): + """SubProcess calls this on child start/exit, routing the pid to this thread's display task + (set by capture_to) for process-tree CPU sampling. Best-effort: never breaks a build.""" + display = getattr(_capture, 'display', None) + tid = getattr(_capture, 'tid', None) + if display is None or tid is None: return + try: + if started: display.attach_pid(tid, pid) + else: display.detach_pid(tid, pid) + except Exception: + pass def console(text:str, color=None, end="\n"): """ Always flush to support most build environments """ global _progress_active - # Cheap O(1) check: redraws start with \r to reset the cursor; only those - # may overwrite an in-flight progress line. Anything else gets a leading \n. - is_redraw = text.startswith('\r') + is_redraw = text.startswith('\r') # redraws start with \r (cursor reset); see progress() + clean = text[1:] if is_redraw else text # \r stripped: line-based sinks/region want a clean line + # While a display owns the screen, route EVERYTHING through it - any direct stdout write (even a + # \r-redraw or a partial) desyncs the region's cursor math and walks it down the screen. Owned + # output feeds the job's task preview; an ownerless full line goes above the region; an ownerless + # mid-progress redraw is dropped (can't place it in the line-based region without corrupting it). + sink = getattr(_capture, 'sink', None) + if sink is not None or _active_display is not None: + # Split an embedded-newline message into SEPARATE lines: the display is line-based, and a + # multi-line string smuggled through as one 'line' shifts the terminal cursor, desyncing the + # live region's cursor-up math and stranding running task lines in the scrollback. + for part in (clean.split('\n') if '\n' in clean else (clean,)): + line = get_colored_text(part, color) # not `colored`: that's termcolor's, imported above + if sink is not None: sink(line) + elif end == '\n': _active_display.print_above(line) + return + text = get_colored_text(text, color) with _console_lock: + # a status line right after an in-flight \r-progress needs a leading \n so it isn't overwritten if _progress_active and not is_redraw: print() - text = get_colored_text(text, color) - # Erase to EOL so a shorter redraw fully clears a longer previous line (no stale tail chars). - if is_redraw: text += _ERASE_EOL + if is_redraw: text += _ERASE_EOL # erase-to-EOL so a shorter redraw clears the longer prev line print(text, end=end, flush=True) _progress_active = (end != '\n') diff --git a/tests/conftest.py b/tests/conftest.py index d033ad1..143aa63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,5 +4,13 @@ # Tests/ for `import testutils`, project root for `from mama.x import y` - # saves every new test file from repeating the same sys.path.insert dance. _here = os.path.dirname(__file__) +_repo_root = os.path.abspath(os.path.join(_here, '..')) sys.path.insert(0, _here) -sys.path.insert(0, os.path.abspath(os.path.join(_here, '..'))) +sys.path.insert(0, _repo_root) + + +def pytest_configure(config): + # tmp_path artifacts go in the gitignored repo subtree (not system temp) for self-contained, + # CI-identical isolation. pytest wipes it at session start; --basetemp still overrides. + if not config.option.basetemp: + config.option.basetemp = os.path.join(_repo_root, '.pytest_tmp') diff --git a/tests/test_artifactory_noise/test_artifactory_noise.py b/tests/test_artifactory_noise/test_artifactory_noise.py new file mode 100644 index 0000000..ffa0b70 --- /dev/null +++ b/tests/test_artifactory_noise/test_artifactory_noise.py @@ -0,0 +1,30 @@ +"""Pins artifactory-skip chatter: a clean/rebuild's deliberate skip is verbose-only, noart stays visible.""" +from testutils import make_mock_dep + + +def _skips(tmp_path, **cfg): + cfg.setdefault('verbose', False) + dep = make_mock_dep(tmp_path, print=True, **cfg) + dep.config.target_matches.return_value = True # `clean all` -> every dep is a target + lines = [] + import mama.build_dependency as bd + orig = bd.warning + bd.warning = lines.append + try: dep.can_fetch_artifactory(print=True, which='LOAD') + finally: bd.warning = orig + return lines + + +def test_clean_and_rebuild_skips_are_quiet(tmp_path): + assert _skips(tmp_path, clean=True) == [] # the CLEAN line already says it + assert _skips(tmp_path, rebuild=True) == [] + + +def test_noart_override_still_reports(tmp_path): + lines = _skips(tmp_path, disable_artifactory=True) + assert len(lines) == 1 and 'noart override' in lines[0] + + +def test_verbose_still_explains_the_skip(tmp_path): + dep_lines = _skips(tmp_path, clean=True, verbose=True) + assert len(dep_lines) == 1 and 'target clean' in dep_lines[0] diff --git a/tests/test_build_banner/test_build_banner.py b/tests/test_build_banner/test_build_banner.py new file mode 100644 index 0000000..cd681a0 --- /dev/null +++ b/tests/test_build_banner/test_build_banner.py @@ -0,0 +1,38 @@ +"""Pins the pre-build banner: command verb, target count when known, and the toolchain it builds with.""" +from types import SimpleNamespace +import pytest +from mama._version import __version__ +from mama.dependency_chain import print_build_banner + + +def _cfg(**over): + c = SimpleNamespace(rebuild=False, update=False, clean=False, build=True, + msvc=False, linux=True, clang=False, gcc=True, clang_stdlib='libc++') + c.get_preferred_compiler_paths = lambda: ('/usr/bin/gcc', '/usr/bin/g++', '14.3.0') + for k, v in over.items(): setattr(c, k, v) + return c + + +def _banner(capsys, config, count=None): + print_build_banner(config, count) + return capsys.readouterr().out.strip() + + +@pytest.mark.parametrize('flags,verb', [({}, 'building'), ({'update': True}, 'updating'), + ({'rebuild': True, 'clean': True}, 'rebuilding'), + ({'clean': True, 'build': False}, 'cleaning')]) +def test_verb_follows_the_command(capsys, flags, verb): + assert _banner(capsys, _cfg(**flags)) == f'Mama {__version__} {verb} with gcc 14.3' + + +def test_counts_targets_only_when_known(capsys): + assert _banner(capsys, _cfg(), 26) == f'Mama {__version__} building 26 target(s) with gcc 14.3' + assert _banner(capsys, _cfg()) == f'Mama {__version__} building with gcc 14.3' # unified: graph still growing + + +def test_toolchain_names_the_clang_stdlib_on_linux(capsys): + assert _banner(capsys, _cfg(clang=True, gcc=False, clang_stdlib='libstdc++'), 3) \ + == f'Mama {__version__} building 3 target(s) with clang 14.3 libstdc++' + assert 'msvc' in _banner(capsys, _cfg(msvc=True, linux=False, gcc=False)) + # off linux the stdlib isn't a choice, so it isn't reported + assert _banner(capsys, _cfg(clang=True, gcc=False, linux=False)) == f'Mama {__version__} building with clang 14.3' diff --git a/tests/test_build_config/test_build_config.py b/tests/test_build_config/test_build_config.py new file mode 100644 index 0000000..f0f47c2 --- /dev/null +++ b/tests/test_build_config/test_build_config.py @@ -0,0 +1,68 @@ +"""Pins BuildConfig: default jobs (Linux leaves a core free), the once-only compiler-conflict note, flag aliases.""" +import psutil, threading +from mama.build_config import BuildConfig +from mama.utils import system + + +def test_default_jobs_leaves_one_core_free_on_linux(monkeypatch): + monkeypatch.setattr(psutil, 'cpu_count', lambda: 32) + monkeypatch.setattr(system.System, 'linux', True) + assert BuildConfig._default_build_jobs() == 31 # N-1: don't saturate the box into an OOM/freeze + monkeypatch.setattr(system.System, 'linux', False) + assert BuildConfig._default_build_jobs() == 32 # Windows/macOS use all cores + + +def test_default_jobs_never_below_one(monkeypatch): + monkeypatch.setattr(psutil, 'cpu_count', lambda: 1) + monkeypatch.setattr(system.System, 'linux', True) + assert BuildConfig._default_build_jobs() == 1 + + +def _bare_cfg(**attrs): + c = object.__new__(BuildConfig) # skip the heavy __init__; set only what prefer_gcc touches + c.linux = True; c.raspi = False; c.gcc = False; c.clang = True + c.compiler_cmd = True; c.print = True; c.compiler_conflict_warned = False + for k, v in attrs.items(): setattr(c, k, v) + return c + + +def test_compiler_conflict_note_fires_once_across_deps(monkeypatch): + printed = [] + monkeypatch.setattr('mama.build_config.console', lambda t, **k: printed.append(t)) + c = _bare_cfg() # compiler locked to Clang + for name in ('myapp', 'netlib', 'ReCpp'): c.prefer_gcc(name) # every dep re-requests GCC + assert len(printed) == 1 # one note, not one per dep + assert 'myapp requested GCC but compiler already set to Clang' in printed[0] + + +def test_buildstats_flag_enables_the_timing_report(): + c = object.__new__(BuildConfig) # parse_args touches nothing else for these flags + c.buildstats = False + c.parse_args(['buildstats']) + assert c.buildstats + + +def test_the_retired_buildtimes_flag_is_no_longer_recognized(): + c = object.__new__(BuildConfig) + c.buildstats = False; c.unused_args = [] + c.parse_args(['buildtimes']) + assert not c.buildstats and c.unused_args == ['buildtimes'] # falls through as an unknown arg + + +def test_announce_once_prints_a_key_only_the_first_time(monkeypatch): + # platform option builders run per fingerprint computation, not per configure - a plain console() + # repeats 'Toolchain: ...' several times per target with nothing new to say + printed = [] + monkeypatch.setattr('mama.build_config.console', lambda t, **k: printed.append(t)) + c = object.__new__(BuildConfig) + c.print = True; c._announced = set(); c._announce_lock = threading.Lock() + for _ in range(3): c.announce_once('toolchain', 'Toolchain: /opt/sdk/arm.cmake') + c.announce_once('other', 'MIPS Toolchain: /opt/mips.cmake') + assert printed == ['Toolchain: /opt/sdk/arm.cmake', 'MIPS Toolchain: /opt/mips.cmake'] + + +def test_announce_once_is_silent_when_printing_is_off(): + c = object.__new__(BuildConfig) + c.print = False; c._announced = set(); c._announce_lock = threading.Lock() + c.announce_once('toolchain', 'nope') + assert c._announced == set() diff --git a/tests/test_build_dir/test_build_dir.py b/tests/test_build_dir/test_build_dir.py index c32bfa4..26734fe 100644 --- a/tests/test_build_dir/test_build_dir.py +++ b/tests/test_build_dir/test_build_dir.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from mama.build_config import BuildConfig @@ -44,3 +45,35 @@ def test_coverage_composes_with_sanitizer(): c.coverage = 'default' c.sanitize = 'address' assert c.platform_build_dir_name() == 'linux-coverage-asan' + + +def test_clang_gets_its_own_dir_and_gcc_keeps_the_bare_name(): + c = linux_config() + assert c.platform_build_dir_name() == 'linux' # gcc default: no churn for existing trees + c.clang = True; c.gcc = False + assert c.platform_build_dir_name() == 'linux-clang' + + +def test_compiler_is_the_coarsest_suffix(): + c = linux_config() + c.clang = True; c.sanitize = 'thread' + assert c.platform_build_dir_name() == 'linux-clang-tsan' + c.coverage = 'default'; c.sanitize = 'address' + assert c.platform_build_dir_name() == 'linux-clang-coverage-asan' + + +def test_arm_linux_also_gets_the_clang_suffix(): + c = linux_config() + c.arch = 'arm64'; c.clang = True + assert c.platform_build_dir_name() == 'linuxarm-clang' + + +def test_non_linux_platforms_are_unaffected_by_clang(): + # set_platform() is exclusive: these never see the suffix, toolset/SDK fixes their compiler + for platform in ('macos', 'ios', 'android', 'msvc'): + c = linux_config() + c.linux = False; setattr(c, platform, True); c.clang = True + assert '-clang' not in c.platform_build_dir_name() + yocto = linux_config() + yocto.linux = False; yocto.yocto_linux = SimpleNamespace(build_dir='oclea'); yocto.clang = True + assert yocto.platform_build_dir_name() == 'oclea' diff --git a/tests/test_build_display/test_build_display.py b/tests/test_build_display/test_build_display.py new file mode 100644 index 0000000..538386c --- /dev/null +++ b/tests/test_build_display/test_build_display.py @@ -0,0 +1,509 @@ +"""Pins BuildDisplay: TTY live-region rendering + non-TTY fallback, capture/replay, throttle.""" +import io, re +from types import SimpleNamespace +from mama.utils import system +from mama.utils.build_display import BuildDisplay, Task, _fmt_dur, scan_diagnostics +from testutils import strip_ansi as strip + +def squeeze(s: str) -> str: return re.sub(r' +', ' ', strip(s)) # collapse fixed-width padding for breakdown asserts + + +class Clock: + def __init__(self): self.t = 0.0 + def __call__(self): return self.t + def tick(self, d=1.0): self.t += d + + +def _disp(isatty, cols=80, rows=24, **kw): + out = io.StringIO(); clk = Clock() + d = BuildDisplay(out, isatty=isatty, term_size=lambda: (cols, rows), clock=clk, color=False, **kw) + return d, out, clk + + +def test_non_tty_emits_one_summary_line_per_slow_task(): + d, out, clk = _disp(isatty=False) + d.start_task(1, 'configure', 'foo'); clk.tick(2.0); d.finish_task(1, ok=True) + text = out.getvalue() + assert 'configure' in text and 'foo' in text and '2.0s' in text # one finish summary, no start line + assert '\x1b[' not in text # never emit ANSI when not a TTY + + +def test_elapsed_over_a_minute_uses_the_shared_mm_ss_formatter(): + d, out, clk = _disp(isatty=False) + d.start_task(1, 'build', 'myapp', detail='J32'); clk.tick(164.3); d.finish_task(1, ok=True) + text = out.getvalue() + assert '2m 44s' in text and '164' not in text # get_time_str, not raw 164.3s + + +def test_instant_success_tasks_are_hidden_failures_are_not(): + d, out, clk = _disp(isatty=False) + d.start_task(1, 'build', 'instant'); d.finish_task(1, ok=True) # ~0.0s success -> hidden + d.start_task(2, 'build', 'slow'); clk.tick(0.5); d.finish_task(2, ok=True) # slow -> shown + d.start_task(3, 'build', 'boom'); d.finish_task(3, ok=False) # instant FAIL -> still shown + text = out.getvalue() + assert 'instant' not in text and 'slow' in text and 'boom' in text + + +def test_phases_merge_into_one_summary_with_breakdown(): + d, out, clk = _disp(isatty=False) + d.start_task('geo', 'pulling', 'geo'); clk.tick(3.7); d.finish_task('geo', ok=True, final=False) + assert out.getvalue() == '' # an intermediate phase commits nothing - the dep is still working + d.start_task('geo', 'configure', 'geo'); clk.tick(0.3); d.finish_task('geo', ok=True, final=False) + d.start_task('geo', 'build', 'geo', detail='J32'); clk.tick(0.5); d.finish_task('geo', ok=True, final=True) + text = squeeze(out.getvalue()) + assert text.count('geo') == 1 and 'build J32' in text # one merged line; kind = last phase that did work + assert 'git 3.7s' in text and 'cfg 0.3s' in text and 'bld 0.5s' in text # git pull, configure, build + + +def test_summary_keeps_an_instant_phase_when_the_dep_did_real_work(): + d, out, clk = _disp(isatty=False) + d.start_task('z', 'pulling', 'z'); clk.tick(2.0); d.finish_task('z', ok=True, final=False) # real load + d.start_task('z', 'configure', 'z'); d.finish_task('z', ok=True, final=False) # instant configure + d.start_task('z', 'build', 'z', detail='J4'); clk.tick(0.5); d.finish_task('z', ok=True, final=True) + assert 'cfg 0.0s' in squeeze(out.getvalue()) # the instant configure is shown, not hidden - "did cfg run?" + + +def test_subsecond_phase_shows_fractional_seconds_not_milliseconds(): + # ms is noise in the breakdown: a 34ms step reads '.03s'. (>=0.1s keeps get_time_str: 'git 2.0s'.) + d, out, clk = _disp(isatty=False) + d.start_task('m', 'pulling', 'm'); clk.tick(2.0); d.finish_task('m', ok=True, final=False) + d.start_task('m', 'build', 'm', detail='J4'); clk.tick(0.034); d.finish_task('m', ok=True, final=True) + text = squeeze(out.getvalue()) + assert 'git 2.0s' in text and 'bld 0.03s' in text # 34ms shown as 0.03s, no ms noise + + +def test_instant_phase_rounds_to_one_decimal_not_two_zeros(): + assert _fmt_dur(0.0).strip() == '0.0s' and _fmt_dur(0.002).strip() == '0.0s' # not an over-precise 0.00s + assert _fmt_dur(0.01).strip() == '0.01s' # a real sub-0.1s value keeps 2 decimals + + +def test_lone_phase_still_shows_its_tag(): + d, out, clk = _disp(isatty=False) + d.start_task('x', 'build', 'x', detail='J4'); clk.tick(2.0); d.finish_task('x', ok=True) # build-only dep + assert 'bld 2.0s' in squeeze(out.getvalue()) # the tag shows even for a lone phase, for a consistent column + + +def test_live_line_shows_all_prior_phases_including_an_instant_one(): + d, _, clk = _disp(isatty=True) + d.start_task('g', 'pulling', 'g'); clk.tick(3.7); d.finish_task('g', ok=True, final=False) # 3.7s pull + d.start_task('g', 'configure', 'g'); d.finish_task('g', ok=True, final=False) # instant configure + d.start_task('g', 'build', 'g', detail='J8'); clk.tick(0.5) # now building + line = squeeze(d._task_line(d._tasks['g'], clk(), 120)) + assert 'git 3.7s' in line and 'cfg 0.0s' in line and 'bld 0.5s' in line # every step shown, even the instant cfg + + +def test_phase_tags_collapse_git_loads_and_label_each_source(): + tag = BuildDisplay._tag + assert tag('check') == tag('clone') == tag('pulling') == 'git' # all git loads share one tag + assert tag('local') == 'loc' and tag('artifactory') == 'art' + assert tag('configure') == 'cfg' and tag('build') == 'bld' + + +def test_non_tty_verbose_dumps_full_output(): + d, out, clk = _disp(isatty=False, verbose=True) + d.start_task(1, 'build', 'bar'); d.feed(1, 'compiling x.cpp'); d.feed(1, 'linking') + clk.tick(0.2); d.finish_task(1, ok=True) # past reveal: a real build that emitted output isn't instant + assert 'compiling x.cpp' in out.getvalue() and 'linking' in out.getvalue() + + +def test_non_tty_failure_dumps_output_without_verbose(): + d, out, _ = _disp(isatty=False, verbose=False) + d.start_task(1, 'build', 'bar'); d.feed(1, 'error: boom'); d.finish_task(1, ok=False) + text = out.getvalue() + assert 'error: boom' in text # failed output dumped even without verbose + assert 'x build' in strip(text) # fail icon in the summary + + +def test_tty_region_shows_running_task(): + d, out, clk = _disp(isatty=True) + d.start_task(1, 'configure', 'foo'); clk.tick(0.2); d.feed(1, 'Checking compiler') + plain = strip(out.getvalue()) + assert 'configure' in plain and 'foo' in plain and 'Checking compiler' in plain + + +def test_tty_finish_commits_summary_and_empties_region(): + d, out, clk = _disp(isatty=True) + d.start_task(1, 'build', 'foo'); clk.tick(0.5); d.render(force=True) # draw the live line first + assert d._drawn == 1 + clk.tick(2.5); d.finish_task(1, ok=True) # total 3.0s + assert d._drawn == 0 # only task done -> region empty + assert '\x1b[1A' in out.getvalue() # the drawn line was cleared via cursor-up + plain = strip(out.getvalue()) + assert 'build' in plain and 'foo' in plain and '3.0s' in plain + + +def test_tty_caps_region_to_height_with_more_summary(): + d, _, _ = _disp(isatty=True, rows=4) # cap = rows - margin(1) = 3 + for i in range(5): d.start_task(i, 'build', f't{i}') + lines = d._region_lines(1.0) # now=1.0 so all 5 are past the reveal delay + assert len(lines) == 3 and '+3 more' in strip(lines[-1]) + + +def test_tty_truncates_long_preview_to_width(): + d, _, _ = _disp(isatty=True, cols=20) + d.start_task(1, 'build', 'x'); d.feed(1, 'y' * 100) + assert len(strip(d._task_line(d._tasks[1], 0.0, 20))) <= 19 + + +def test_tty_preview_strips_ansi_but_buffer_keeps_it(): + d, _, _ = _disp(isatty=True) + colored = '\x1b[31mred error\x1b[0m' + d.start_task(1, 'build', 'x'); d.feed(1, colored) + line = d._task_line(d._tasks[1], 0.0, 80) + assert '\x1b[31m' not in line and 'red error' in line + assert d._tasks[1].lines == [colored] # raw output preserved for replay + + +def test_build_detail_shows_core_count_after_kind(): + d, _, clk = _disp(isatty=True) + d.start_task(1, 'build', 'compression', detail='J16'); clk.tick(0.5) + assert 'build J16' in strip(d._task_line(d._tasks[1], clk(), 80)) + d.finish_task(1, ok=True) + assert 'build J16' in strip(d._summary_line(d._tasks[1])) + + +def test_cpu_sampling_updates_task_and_renders_percent(): + d, _, clk = _disp(isatty=True, cpu_sampler=lambda snap: {t: 597.0 for t in snap}, sample_interval=999) + d.start_task(1, 'build', 'compression', detail='J16') + d.attach_pid(1, 4242) + d._sample_once() + assert d._tasks[1].cpu == 597.0 + assert 'build J16 cpu:597%' in strip(d._task_line(d._tasks[1], clk(), 120)) + d.detach_pid(1, 4242) + assert d._tasks[1].cpu == 0.0 # subprocess gone -> CPU cleared, not left stale + d.close() + + +def test_late_cpu_sample_does_not_resurrect_a_detached_task(): + # The sampler runs off-lock; if the task detaches during that window, the stale CPU it computed + # must NOT be written back - else a dead subprocess shows as busy until the task finishes. + d, _, _ = _disp(isatty=True, sample_interval=999) + d.start_task(1, 'build', 'x'); d.attach_pid(1, 7) + def sampler(snap): + d.detach_pid(1, 7) # subprocess exits mid-scan: tid dropped, cpu zeroed + return {1: 888.0} # a stale reading computed just before the detach + d._cpu_sampler = sampler + d._sample_once() + assert d._tasks[1].cpu == 0.0 # stale 888% dropped, not resurrected + d.close() + + +def test_sampler_backoff_caps_cost_at_tenth_of_walltime(): + d, _, _ = _disp(isatty=True, sample_interval=1.5) + assert d._next_wait(0.01) == 1.5 # cheap sample -> base interval + assert d._next_wait(5.7) == 5.7 * 9 # a 5.7s sample -> wait ~51s, so it stays ~10% of wall-time + d.close() + + +def test_report_subprocess_attaches_pid_to_current_task(): + d, _, _ = _disp(isatty=True, cpu_sampler=lambda snap: {t: 100.0 for t in snap}, sample_interval=999) + tid = ('x', 'build'); d.start_task(tid, 'build', 'x') + with system.capture_to(lambda line: None, d, tid): + system.report_subprocess(999, True) + assert d._pids[tid] == {999} + system.report_subprocess(999, False) + assert tid not in d._pids + d.close() + + +def test_attach_pid_only_samples_build_tasks(): + d, _, _ = _disp(isatty=True, cpu_sampler=lambda snap: {}, sample_interval=999) + d.start_task(('c', 'configure'), 'configure', 'c'); d.attach_pid(('c', 'configure'), 11) + d.start_task(('l', 'load'), 'clone', 'l'); d.attach_pid(('l', 'load'), 12) + d.start_task(('b', 'build'), 'build', 'b'); d.attach_pid(('b', 'build'), 13) + assert d._pids == {('b', 'build'): {13}} # configure/clone not sampled, only build + d.close() + + +def test_replay_dumps_raw_colored_buffer(): + d, out, _ = _disp(isatty=True) + d.start_task(1, 'build', 'x'); d.feed(1, '\x1b[31mboom\x1b[0m') + out.truncate(0); out.seek(0) + d.replay(1) + assert '\x1b[31mboom\x1b[0m' in out.getvalue() + + +def test_render_throttle_skips_within_min_interval(): + d, out, clk = _disp(isatty=True, min_interval=0.1) + d.start_task(1, 'build', 'x') # forced render at t=0 + n = len(out.getvalue()) + d.feed(1, 'line2') # same tick -> throttled, no draw + assert len(out.getvalue()) == n + clk.tick(0.2); d.feed(1, 'line3') # past interval -> draws + assert len(out.getvalue()) > n + + +def test_set_pending_shows_then_clears_the_blocked_task_line(): + d, _, _ = _disp(isatty=True, rows=24) + d.start_task(1, 'build', 'netlib', detail='J12') + d.set_pending(('geo', 'cpu 92% >= 85%')) + region = strip('\n'.join(d._region_lines(1.0))) + assert 'pending' in region and 'geo' in region and 'cpu 92%' in region + d.set_pending(None) + assert 'pending' not in strip('\n'.join(d._region_lines(1.0))) + d.close() + + +def test_render_skips_when_another_thread_is_drawing(): + # A non-forced render must not block while another thread holds the render lock (that block would + # stall the subprocess reader -> fill the pipe -> stall the compiler). It skips; a later draw covers it. + d, out, clk = _disp(isatty=True) + d.start_task(1, 'build', 'x'); clk.tick(0.2) + d._render_lock.acquire() + try: + before = len(out.getvalue()) + d.render() # busy -> skip, no draw, no block + assert len(out.getvalue()) == before + finally: + d._render_lock.release() + d.render(force=True) # free -> draws the running task + assert len(out.getvalue()) > before + + +def test_close_clears_region(): + d, _, clk = _disp(isatty=True) + d.start_task(1, 'build', 'x'); clk.tick(0.2); d.render(force=True) # past reveal -> 1 line drawn + assert d._drawn == 1 + d.close() + assert d._drawn == 0 + + +def test_build_barrier_is_noop_without_scheduler_else_uses_slot(): + import contextlib + with system.build_barrier(8): # no active scheduler -> null context, never blocks + pass + calls = [] + slot = lambda w: contextlib.nullcontext(calls.append(w)) # records the requested weight + with system.capture_to(lambda l: None, build_slot=slot): + with system.build_barrier(5): + pass + assert calls == [5] # routed the compile's weight to the scheduler slot + + +def test_multiline_console_reaches_the_sink_as_separate_lines(): + # A multi-line message smuggled through as ONE line shifts the terminal cursor and desyncs the + # live region's cursor-up math, stranding running task lines in the scrollback. + sink = [] + with system.capture_to(sink.append): + system.console('\n\n#### CMakeBuild myapp') + assert sink == ['', '', '#### CMakeBuild myapp'] + assert not any('\n' in line for line in sink) + + +def test_multiline_console_reaches_print_above_as_separate_lines(): + lines = [] + system.set_active_display(SimpleNamespace(print_above=lines.append)) + try: + system.console('first\nsecond') + finally: + system.set_active_display(None) + assert lines == ['first', 'second'] + + +def test_region_line_never_contains_a_newline(): + d, _, clk = _disp(isatty=True) + d.start_task(1, 'build', 'x'); clk.tick(0.5) + d.feed(1, 'preview with\nan embedded newline') # even if one slips in, the region must stay 1 line + line = d._task_line(d._tasks[1], clk(), 200) + assert '\n' not in line and '\r' not in line + + +def test_capture_to_routes_console_to_sink_and_restores(): + outer, inner = [], [] + with system.capture_to(outer.append): + system.console('a') + with system.capture_to(inner.append): # nested job sink + system.console('b') + system.console('c') # back to the outer sink after the nested block + assert 'a' in outer[0] and 'c' in outer[1] and inner == ['b'] + assert getattr(system._capture, 'sink', None) is None # fully restored + + +def test_progress_redraw_feeds_sink_as_clean_line(capsys): + sink = [] + with system.capture_to(sink.append): + system.progress('cloning 42%') # \r-progress while a job owns the screen + assert sink == ['cloning 42%'] # clean preview line: \r stripped, nothing to stdout + assert capsys.readouterr().out == '' + + +def test_active_display_routes_status_above_region_and_drops_bare_redraw(capsys): + calls = [] + system.set_active_display(SimpleNamespace(print_above=calls.append)) + try: + system.console('status line') # ownerless line-complete -> above the region + system.progress('downloading 10%') # ownerless mid-progress redraw -> dropped, not stdout + system.progress('done', final=True) # final commit -> above the region, \r stripped + finally: + system.set_active_display(None) + assert calls == ['status line', 'done'] + assert capsys.readouterr().out == '' # display owns the screen: no direct stdout writes + + +def test_task_feed_tracks_current_and_full_buffer(): + t = Task(1, 'build', 'x', 0.0) + t.feed('a'); t.feed(' '); t.feed('b') + assert t.current == 'b' # blank line did not overwrite the live preview + assert t.lines == ['a', ' ', 'b'] + + +def test_task_feed_collapses_git_progress_flood(): + t = Task(1, 'build', 'x', 0.0) + t.feed('remote: Counting objects: 0% (1/290)') + t.feed('remote: Counting objects: 50% (145/290)') + t.feed('remote: Counting objects: 100% (290/290), done.') + t.feed('linking x') # a real line ends the progress run and is appended + assert t.lines == ['remote: Counting objects: 100% (290/290), done.', 'linking x'] + assert t.current == 'linking x' + + +def test_task_feed_collapses_download_progress_bar(): + t = Task(1, 'build', 'x', 0.0) + for p in (0, 25, 50, 75, 100): # a custom build's own downloader, per-percent frames + t.feed(f'x264 {p}% [{"="*(p//10)}>] 3.4MB/s') + t.feed('unpacking x264') + assert t.lines == ['x264 100% [==========>] 3.4MB/s', 'unpacking x264'] # collapsed to the final frame + + +def test_scan_diagnostics_picks_msvc_and_gcc_errors_first_and_dedups(): + diags, n_err, n_warn = scan_diagnostics([ + 'foo.cpp(12): warning C4996: deprecated', + "bar.cpp:5:3: error: expected ';'", + '\x1b[31mbaz.cpp:1:1: warning: unused variable\x1b[0m', # ansi stripped before matching + 'foo.cpp(12): warning C4996: deprecated', # duplicate -> collapsed + 'obj.obj : error LNK2019: unresolved external', 'linking...', 'note: in expansion']) + assert n_err == 2 and n_warn == 2 + assert diags[0][0] == 'error' and diags[1][0] == 'error' # errors before warnings + assert ('warning', 'baz.cpp:1:1: warning: unused variable') in diags # ansi stripped in the stored text + + +def test_scan_diagnostics_caps_and_counts_full_totals(): + diags, n_err, n_warn = scan_diagnostics([f'f{i}.cpp:1:1: warning: w{i}' for i in range(12)], limit=8) + assert len(diags) == 8 and n_warn == 12 and n_err == 0 # capped list, but the count reflects all 12 + + +def test_scan_diagnostics_catches_cmakes_error_at_form(): + # 'CMake Error at ' has no colon after the severity, so the generic form missed it entirely - + # which is how a failing target's real error went absent from the end-of-build summary. + diags, n_err, n_warn = scan_diagnostics([ + 'CMake Error at /usr/share/cmake-3.28/Modules/FindOpenSSL.cmake:668 (find_package):', + 'CMake Warning at CMakeLists.txt:5 (message):']) + assert n_err == 1 and n_warn == 1 + assert diags[0][0] == 'error' and 'FindOpenSSL' in diags[0][1] + + +def test_scan_diagnostics_ignores_non_diagnostic_error_words(): + diags, n_err, n_warn = scan_diagnostics(['-Werror is set', '0 errors, 0 warnings', 'std::error_code x;']) + assert diags == [] and n_err == 0 and n_warn == 0 + + +def test_diagnostics_reads_a_finished_task_buffer(): + d, _, _ = _disp(isatty=False) + d.start_task('t', 'build', 't'); d.feed('t', 'a.cpp:1:1: warning: oops'); d.finish_task('t', ok=True) + assert d.diagnostics('t') == ([('warning', 'a.cpp:1:1: warning: oops')], 0, 1) + + +class _Log: + def __init__(self): self.text = '' + def write(self, s): self.text += s + def close(self): pass + + +def test_log_writes_full_per_target_block_even_when_hidden(): + log = _Log() + d, _, _ = _disp(isatty=False, log=log) # instant build -> hidden from the display, but still logged + d.start_task('geo', 'configure', 'geo'); d.feed('geo', 'checking compiler'); d.finish_task('geo', ok=True, final=False) + d.start_task('geo', 'build', 'geo'); d.feed('geo', 'compiling x.cpp'); d.finish_task('geo', ok=True, final=True) + assert 'checking compiler' in log.text and 'compiling x.cpp' in log.text # full buffer across phases + assert 'geo' in log.text and '====' in log.text # one delimited per-target block + + +def test_log_writes_permanent_lines_and_closes(): + log = _Log() + d, _, _ = _disp(isatty=False, log=log) + d.print_above('Built 3 target(s)') + d.close() + assert 'Built 3 target(s)' in log.text + + +def test_region_line_has_no_trailing_padding_before_a_preview_arrives(): + d, _, clk = _disp(isatty=True) + d.start_task('t', 'configure', 'rpclib') + line = d._task_line(d._tasks['t'], clk(), 200) + assert line == line.rstrip() # trailing pad shows up as stray spaces in the region + d.feed('t', 'compiling foo.cpp') + assert d._task_line(d._tasks['t'], clk(), 200).endswith('compiling foo.cpp') + + +def test_replay_shows_only_the_failing_phase_not_the_whole_dep_history(): + d, out, clk = _disp(isatty=True) + d.start_task('t', 'check', 'rpcservice') + d.feed('t', '- Target rpcservice BUILD [not built yet]') # load-phase status, minutes old + clk.tick(); d.finish_task('t', ok=True, final=False) + d.start_task('t', 'build', 'rpcservice') + d.feed('t', 'error: undefined reference to foo()') + clk.tick(); d.finish_task('t', ok=False) + out.truncate(0); out.seek(0) + d.replay('t') + text = strip(out.getvalue()) + assert 'undefined reference' in text and 'not built yet' not in text + + +CMAKE_WARN = [ + 'CMake Warning:', + ' Manually-specified variables were not used by the project:', + '', + ' BUILD_APPS', + ' BUILD_TESTING', + '', + '', + '-- Configuring done (0.2s)', +] + + +def test_a_cmake_block_keeps_its_body_not_just_the_header(): + diags, n_err, n_warn = scan_diagnostics(CMAKE_WARN) + assert (n_err, n_warn) == (0, 1) + text = diags[0][1] + assert text.startswith('CMake Warning:') + assert 'Manually-specified variables' in text and 'BUILD_APPS' in text # the body used to be dropped + assert 'Configuring done' not in text # the blank pair ends the block + + +def test_a_cmake_block_ends_at_unindented_output(): + diags, _, _ = scan_diagnostics(['CMake Warning at foo.cmake:3 (message):', ' careful', 'ninja: build stopped']) + assert 'careful' in diags[0][1] and 'ninja' not in diags[0][1] + + +def test_a_single_line_compiler_diagnostic_is_unchanged(): + diags, n_err, _ = scan_diagnostics(['foo.cpp(12): error C2065: undeclared identifier', ' some other output']) + assert n_err == 1 and diags[0][1] == 'foo.cpp(12): error C2065: undeclared identifier' + + +def test_a_runaway_cmake_block_is_capped_and_says_so(): + lines = ['CMake Warning:'] + [f' line {i}' for i in range(40)] + diags, _, n_warn = scan_diagnostics(lines) + text = diags[0][1] + assert len(text.split('\n')) == 10 # header + _MAX_BODY + the truncation note + assert '(+32 more lines)' in text # capped, but not silently + assert n_warn == 1 # body lines are not re-scanned as their own diagnostics + + +def test_a_render_after_close_cannot_redraw_over_the_final_output(): + # the CPU sampler can outlive close()'s 1s join (a slow process scan) and would otherwise redraw + # the region below the build summary, with _drawn already reset to 0 + d, out, clk = _disp(isatty=True) + d.start_task('t', 'build', 'protobuf'); clk.tick(2.0) + d.close() + out.truncate(0); out.seek(0) + d.render(force=True) + assert out.getvalue() == '' + + +def test_print_above_after_close_still_reaches_the_terminal(): + # console() can race between display.close() and set_active_display(None); the line must not vanish + d, out, clk = _disp(isatty=True) + d.close() + out.truncate(0); out.seek(0) + d.print_above(' - Target foo OK') + assert 'Target foo' in strip(out.getvalue()) diff --git a/tests/test_build_insights/test_build_insights.py b/tests/test_build_insights/test_build_insights.py new file mode 100644 index 0000000..148669f --- /dev/null +++ b/tests/test_build_insights/test_build_insights.py @@ -0,0 +1,216 @@ +"""Pins Stage 2 build-insights: timetrace tree rebuild, frontend/backend/link aggregation, header +costs, target scoping, and the vcperf session start/stop wiring.""" +import json +import pytest +from types import SimpleNamespace +from mama import build_insights as bi +from testutils import strip_ansi + +def _b(name, ts, args=None): return {'ph': 'B', 'name': name, 'ts': ts, **({'args': args} if args else {})} +def _e(ts): return {'ph': 'E', 'ts': ts} +def _x(name, ts, dur): return {'ph': 'X', 'name': name, 'ts': ts, 'dur': dur} + +# Real vcperf shape: CL Invocation -> FrontEndPass -> C1DLL -> (+ nested includes); BackEndPass -> +# C2DLL -> codegen leaves. CL0 (a.cpp -> C:\proj): frontend 100us (incl big.h 60us), backend 60us (someFunc). +# CL1 (b.cpp -> C:\other): frontend 60us, re-includes big.h 20us, no backend. Link 0 -> C:\proj, 50us. +def _fe(src, ts, dur, inc=None): # one FrontEndPass: C1DLL -> source, optional nested include (name, ts, dur) + body = [_b('C1DLL', ts), _b(src, ts)] + ([_x(inc[0], inc[1], inc[2])] if inc else []) + [_e(ts + dur), _e(ts + dur)] + return [_b('FrontEndPass', ts), *body, _e(ts + dur)] +def _be(sym, ts, dur, sym_dur): # one BackEndPass: C2DLL -> one codegen leaf + return [_b('BackEndPass', ts), _b('C2DLL', ts), _x(sym, ts, sym_dur), _e(ts + dur), _e(ts + dur)] + +_TRACE = {'traceEvents': [ + _b('CL Invocation 0', 0, {'File Input': r'C:\proj\src\a.cpp', 'File Output': r'C:\proj\build\a.obj'}), + *_fe(r'C:\proj\src\a.cpp', 0, 100, inc=(r'C:\proj\inc\big.h', 10, 60)), + *_be('someFunc', 100, 60, 40), + _e(160), + _b('CL Invocation 1', 200, {'File Input': r'C:\other\src\b.cpp', 'File Output': r'C:\other\build\b.obj'}), + *_fe(r'C:\other\src\b.cpp', 200, 60, inc=(r'C:\proj\inc\big.h', 210, 20)), + _e(260), + _b('Link Invocation 0', 260, {'File Output': r'C:\proj\build\app.exe', 'File Input': r'C:\proj\build\a.obj'}), + _e(310), +]} + +US = 1e-6 + + +def test_build_tree_reconstructs_passes_and_durations(): + roots = bi._build_tree(_TRACE['traceEvents']) + assert [r.name for r in roots] == ['CL Invocation 0', 'CL Invocation 1', 'Link Invocation 0'] + assert roots[0].dur == 160 + assert [c.name for c in roots[0].children] == ['FrontEndPass', 'BackEndPass'] + assert roots[0].children[0].dur == 100 # FrontEndPass B/E span + + +def test_root_totals_sum_passes_not_invocation_wall(): + s = bi.parse_timetrace(_TRACE) + assert s.n_tu == 2 # one per FrontEndPass, not per cl.exe + assert s.frontend_s == (100 + 60) * US and s.backend_s == 60 * US + assert s.compile_s == s.frontend_s + s.backend_s and s.link_s == 50 * US # compile is aggregate, never clamped + + +def test_header_costs_count_includes_not_tu_sources(): + files = dict((b, (sec, n)) for b, sec, n in bi.parse_timetrace(_TRACE).files) + assert files['big.h'] == (80 * US, 2) # included by both TUs -> summed, counted twice + assert 'a.cpp' not in files and 'b.cpp' not in files # a TU's own source is not a header + + +def test_codegen_symbols_exclude_structural_and_paths(): + syms = [name for name, _ in bi.parse_timetrace(_TRACE).symbols] + assert syms == ['someFunc'] # the passes (FrontEndPass/C1DLL/...) and file paths are not codegen symbols + + +def test_scope_filters_to_one_packages_tus(): + s = bi.parse_timetrace(_TRACE, scope_paths=[r'C:\proj']) + assert s.n_tu == 1 and s.link_s == 50 * US # a.cpp + its link, not b.cpp under C:\other + assert [t[0] for t in s.tus] == ['a.cpp'] + assert dict((b, n) for b, _, n in s.files)['big.h'] == 1 # only a.cpp's parse of it + + +def test_is_path_separates_files_from_symbols(): + assert bi._is_path(r'C:\x\y.h') and bi._is_path('/usr/include/vector') + assert not bi._is_path('std::vector::push_back') and not bi._is_path('someFunc') + + +def test_empty_trace_reports_no_activity(capsys): + s = bi.parse_timetrace({'traceEvents': []}) + assert s.empty + bi.print_buildstats_deep(s, 'root') + assert 'no compiler activity captured' in strip_ansi(capsys.readouterr().out) + + +def test_deep_report_renders_all_sections(capsys): + bi.print_buildstats_deep(bi.parse_timetrace(_TRACE), 'root') + out = strip_ansi(capsys.readouterr().out) + for token in ['Build Insights (root)', 'frontend', 'backend', 'link', 'translation units', 'a.cpp', + 'costliest headers', 'big.h']: + assert token in out + + +def test_demangle_decodes_msvc_and_passes_through_plain(): + assert bi._demangle('main') == 'main' # not mangled -> unchanged + assert bi._demangle('mocs_compilation.cpp') == 'mocs_compilation.cpp' + if bi.System.windows: + assert bi._demangle('?_buildMap@QGCPalette@@CAXXZ') == 'QGCPalette::_buildMap' # dbghelp NAME_ONLY + + +def test_short_truncates_long_symbols_to_one_line(): + assert bi._short('x' * 200).endswith('...') and len(bi._short('x' * 200)) == bi._SYM_WIDTH + assert bi._short('rfl::parsing::to_single_error_message') == 'rfl::parsing::to_single_error_message' + + +def test_timetrace_path_is_in_the_build_dir(tmp_path): + assert bi.timetrace_path(str(tmp_path / 'pkg' / 'windows')).endswith('/pkg/windows/mama_timetrace.json') + + +def test_deep_report_demangles_codegen_symbols(capsys): + if not bi.System.windows: return # demangling is a no-op off Windows (dbghelp is Windows-only) + tr = {'traceEvents': [ + _b('CL Invocation 0', 0, {'File Input': r'C:\p\a.cpp', 'File Output': r'C:\p\b\a.obj'}), + *_fe(r'C:\p\a.cpp', 0, 1_000_000), + *_be('?_buildMap@QGCPalette@@CAXXZ', 1_000_000, 5_000_000, 5_000_000), + _e(6_000_000)]} + bi.print_buildstats_deep(bi.parse_timetrace(tr), 'root') + out = strip_ansi(capsys.readouterr().out) + assert 'QGCPalette::_buildMap' in out and '?_buildMap' not in out # raw mangled name never shown + + +def test_is_header_excludes_source_files(): + assert bi._is_header('/usr/include/c++/vector') and bi._is_header('foo.h') # STL (no ext) + .h + assert not bi._is_header('/proj/a.cpp') and not bi._is_header('b.cc') # the TU's own source + + +def test_parse_clang_traces_aggregates_across_tus(tmp_path): + ev = lambda n, d, detail=None: {'ph': 'X', 'name': n, 'dur': d, **({'args': {'detail': detail}} if detail else {})} + def w(name, evs): (tmp_path / name).write_text(json.dumps({'traceEvents': evs})) + w('a.cpp.json', [ev('Frontend', 800000), ev('Backend', 200000), ev('Source', 500000, '/u/vector'), + ev('Source', 300000, '/p/a.cpp'), ev('InstantiateClass', 150000, 'std::vector')]) + w('b.cpp.json', [ev('Frontend', 600000), ev('Backend', 100000), ev('Source', 400000, '/u/vector'), + ev('OptFunction', 90000, 'rpp::recv()')]) + st = bi.parse_clang_traces([str(tmp_path / 'a.cpp.json'), str(tmp_path / 'b.cpp.json')], wall_s=1.0) + assert st.n_tu == 2 and st.link_s == 0.0 and st.wall_s == 1.0 + assert st.frontend_s == pytest.approx(1.4) and st.backend_s == pytest.approx(0.3) + assert [t[0] for t in st.tus] == ['a.cpp', 'b.cpp'] # slowest first, .json + source basename stripped + files = dict((b, (s, n)) for b, s, n in st.files) + assert files['vector'] == (pytest.approx(0.9), 2) and 'a.cpp' not in files # header summed; the .cpp isn't a header + assert dict(st.symbols)['std::vector'] == pytest.approx(0.15) # clang details already readable + + +def test_parse_clang_traces_skips_unreadable_and_non_trace_json(tmp_path): + (tmp_path / 'bad.json').write_text('{ not json') + (tmp_path / 'compile_commands.json').write_text('[{"file": "a.cpp"}]') # a list, not a trace dict + files = [str(tmp_path / n) for n in ('bad.json', 'compile_commands.json', 'missing.json')] + assert bi.parse_clang_traces(files).empty + + +def test_collect_clang_traces_filters_by_mtime(tmp_path): + import os + old = tmp_path / 'old.json'; old.write_text('{}'); os.utime(old, (1, 1)) # ancient + new = tmp_path / 'sub' / 'new.json'; new.parent.mkdir(); new.write_text('{}') # recursive + fresh + got = bi.collect_clang_traces(str(tmp_path), since=1000) + assert str(new) in got and str(old) not in got + + +def test_find_vcperf_prefers_env_override(monkeypatch, tmp_path): + exe = tmp_path / 'vcperf.exe'; exe.write_text('') + monkeypatch.setenv('VCPERF', str(exe)) + assert bi.find_vcperf(SimpleNamespace()) == str(exe) + + +def test_find_vcperf_returns_empty_when_absent(monkeypatch): + monkeypatch.delenv('VCPERF', raising=False) + monkeypatch.setattr(bi.util, 'find_executable_from_system', lambda name: '') + def _raise(): raise EnvironmentError('no VS') + cfg = SimpleNamespace(get_msvc_bin64=_raise, get_visualstudio_path=_raise) + assert bi.find_vcperf(cfg) == '' + + +def test_session_issues_start_noadmin_nocpusampling_and_stop_timetrace(monkeypatch, tmp_path): + cmds, out = [], tmp_path / 'tt.json' + def _run(args, **k): cmds.append(args); out.write_text('{}'); return 0 # /stop writes the trace + monkeypatch.setattr(bi.SubProcess, 'run', staticmethod(_run)) + with bi.VcPerfSession('vcperf.exe', str(out)) as s: + assert s.ok + assert s.ok # judged by the written file, not the exit code + flat = [' '.join(c) for c in cmds] + assert any('/start /noadmin /nocpusampling /level3 mama_buildstats' in c for c in flat) + assert any(f'/stop mama_buildstats /timetrace {out}' in c for c in flat) + + +def test_session_missing_trace_file_degrades_to_noop(monkeypatch, tmp_path): + # /start succeeds, /stop exits nonzero and writes nothing -> we trust the absent file, not the code. + monkeypatch.setattr(bi.SubProcess, 'run', staticmethod(lambda args, **k: 0 if '/start' in args else 5)) + with bi.VcPerfSession('vcperf.exe', str(tmp_path / 'nope.json')) as s: + assert s.ok # /start reported success + assert not s.ok # /stop produced no file -> no-op + + +def test_start_auto_runs_grantusercontrol_then_retries(monkeypatch, tmp_path): + calls, out = [], tmp_path / 'tt.json' + def _run(args, io_func=None, **k): + calls.append(args); joined = ' '.join(args) + if 'grantusercontrol' in joined: return 0 # elevated grant accepted + if '/start' in args: + if sum('/start' in c for c in calls) == 1: # first start: not yet granted + if io_func: io_func(None, 'requires /grantusercontrol with admin') + return 1 + return 0 # retry after grant succeeds + out.write_text('{}'); return 0 # /stop writes the trace + monkeypatch.setattr(bi.SubProcess, 'run', staticmethod(_run)) + monkeypatch.setattr(bi.System, 'windows', True) + with bi.VcPerfSession('vcperf.exe', str(out)) as s: + assert s.ok + joined = [' '.join(c) for c in calls] + assert any('grantusercontrol' in c for c in joined) and sum('/start' in c for c in joined) == 2 + + +def test_grantusercontrol_hint_is_concise_when_grant_declined(monkeypatch, capsys): + def _run(args, io_func=None, **k): + if io_func: io_func(None, 'requires to run vcperf with the `/grantusercontrol` flag with admin') + return 1 # every command (incl. the declined elevated grant) fails + monkeypatch.setattr(bi.SubProcess, 'run', staticmethod(_run)) + monkeypatch.setattr(bi.System, 'windows', True) + with bi.VcPerfSession('vcperf.exe', 'out.json') as s: + pass + out = strip_ansi(capsys.readouterr().out) + assert not s.ok and '/grantusercontrol' in out and 'Failed to start trace' not in out # the blurb is collapsed diff --git a/tests/test_build_scheduler/test_build_scheduler.py b/tests/test_build_scheduler/test_build_scheduler.py new file mode 100644 index 0000000..b325feb --- /dev/null +++ b/tests/test_build_scheduler/test_build_scheduler.py @@ -0,0 +1,344 @@ +"""Pins the parallel scheduler: dep ordering, cycle detection, configure/build governors, fail-fast.""" +import threading, time +from types import SimpleNamespace +import pytest +from mama.build_scheduler import Job, Scheduler, build_dep_jobs, assign_priorities, BuildInterrupted, LOAD, CONFIGURE, BUILD + + +def _wait_until(pred, timeout=2.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + if pred(): return True + time.sleep(0.005) + return False + + +_probes, _threads = [], [] # tracked so _drain() can release + join them even if an assertion bailed early + +@pytest.fixture(autouse=True) +def _drain(): + yield + for p in _probes: p.gate.set() # release any job bodies still parked at their gate + for t in _threads: t.join(3.0) # join so no scheduler thread leaks into the next test or to exit + _probes.clear(); _threads.clear() + + +class Probe: + """Counts concurrent job bodies and holds them at a gate until released.""" + def __init__(self): + self.lock = threading.Lock(); self.cur = 0; self.max = 0 + self.gate = threading.Event(); _probes.append(self) + def body(self): + with self.lock: + self.cur += 1; self.max = max(self.max, self.cur) + self.gate.wait(2.0) + with self.lock: self.cur -= 1 + + +def _run_bg(sched, jobs): + out = {} + t = threading.Thread(target=lambda: out.__setitem__('r', sched.run(jobs)), daemon=True) + t.start(); _threads.append(t) + return t, out + + +def _sched(**kw): + kw.setdefault('max_configure', 8); kw.setdefault('core_budget', 8) + kw.setdefault('poll_interval', 0.02) + return Scheduler(**kw) + + +def test_runs_all_jobs(): + ran = [] + jobs = [Job(i, BUILD, (lambda i=i: ran.append(i)), weight=1) for i in range(5)] + assert _sched().run(jobs) is None + assert sorted(ran) == [0, 1, 2, 3, 4] + + +def test_cycle_detection_raises(): + a = Job('a', BUILD, lambda: None); b = Job('b', BUILD, lambda: None) + a.deps.add(b); b.deps.add(a) + with pytest.raises(RuntimeError, match='Cyclical'): + _sched().run([a, b]) + + +def test_configure_governor_caps_concurrency(): + p = Probe() + jobs = [Job(i, CONFIGURE, p.body) for i in range(5)] + sched = _sched(max_configure=2) + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 2) + time.sleep(0.05) # give any erroneous 3rd a chance to start + assert p.cur == 2 # never exceeds the cap + p.gate.set(); t.join(2.0) + assert p.max == 2 + + +def test_build_governor_high_load_blocks_overprovision(): + p = Probe() + jobs = [Job(i, BUILD, p.body, weight=4) for i in range(4)] # each fills half the budget + sched = _sched(cpu_sampler=lambda: 100.0, core_budget=4, overprovision=2.0) # busy + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 1) # one fills the budget; busy CPU blocks over-provisioning + time.sleep(0.05) + assert p.cur == 1 + p.gate.set(); t.join(2.0) + assert p.max == 1 + + +def test_build_governor_low_load_overprovisions_past_budget(): + p = Probe() + jobs = [Job(i, BUILD, p.body, weight=4) for i in range(4)] + sched = _sched(cpu_sampler=lambda: 0.0, core_budget=4, overprovision=2.0) # idle -> overprovision + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 2) # 4+4 = budget*2; a third (12) exceeds even that + time.sleep(0.05) + assert p.cur == 2 + p.gate.set(); t.join(2.0) + assert p.max == 2 + + +def test_build_governor_low_load_runs_many(): + p = Probe() + jobs = [Job(i, BUILD, p.body, weight=1) for i in range(4)] + sched = _sched(cpu_sampler=lambda: 0.0, core_budget=8) + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 4) + p.gate.set(); t.join(2.0) + assert p.max == 4 + + +def test_build_governor_respects_core_budget(): + p = Probe() + jobs = [Job(i, BUILD, p.body, weight=4) for i in range(4)] # 4 cores each + sched = _sched(cpu_sampler=lambda: 0.0, core_budget=8, overprovision=1.0) + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 2) # 4+4 = budget; a third (12) won't fit + time.sleep(0.05) + assert p.cur == 2 + p.gate.set(); t.join(2.0) + assert p.max == 2 + + +def test_ungated_build_bypasses_cpu_and_budget_gate(): + # The root build is ungated: it launches even when the budget is full and CPU is pegged (it runs + # alone after all deps), gated only by its own deps being done. + sched = _sched(core_budget=8, overprovision=1.0, cpu_sampler=lambda: 100.0) + sched._reserved = 8; sched._cpu_now = 100.0 # budget exhausted, CPU maxed + assert sched._can_launch(Job('leaf', BUILD, lambda: None, weight=8)) is False # normal build held back + assert sched._can_launch(Job('root', BUILD, lambda: None, weight=8, ungated=True)) is True + + +def test_pending_hint_reports_blocked_build_then_waiting_dep(): + sched = _sched(core_budget=8, overprovision=1.0) + sched._reserved = 8; sched._cpu_now = 100.0 # budget full + CPU pegged + blocked = Job('b', BUILD, lambda: None, weight=8, node=SimpleNamespace(name='geo')) + name, reason = sched._pending_hint([blocked]) + assert name == 'geo' and 'cpu' in reason # a governor-held build -> CPU reason + undone = Job('d', BUILD, lambda: None, node=SimpleNamespace(name='ReCpp')) # not done + waiter = Job('w', BUILD, lambda: None, deps=[undone], node=SimpleNamespace(name='app')) + sched._pending = [waiter] + assert sched._pending_hint([]) == ('app', 'waiting for ReCpp') # nothing gated -> waiting on a dep + sched._pending = [] + assert sched._pending_hint([]) is None # nothing waiting -> no hint + + +def test_pending_hint_lists_up_to_three_waiting_deps(): + mk = lambda n: Job(n, BUILD, lambda: None, node=SimpleNamespace(name=n)) + waiter = lambda deps: Job('w', BUILD, lambda: None, deps=deps, node=SimpleNamespace(name='app')) + sched = _sched() + sched._pending = [waiter([mk(n) for n in 'dbeac'])] # 5 unfinished deps + assert sched._pending_hint([]) == ('app', 'waiting for a, b, c (+2)') # first 3 sorted + overflow + sched._pending = [waiter([mk('y'), mk('x')])] # <=3 deps -> no (+N) suffix + assert sched._pending_hint([]) == ('app', 'waiting for x, y') + + +def test_build_dep_jobs_marks_root_build_ungated(): + leaf = _Dep('leaf'); root = _Dep('root'); root.is_root = True + jobs = build_dep_jobs([leaf, root], configure_fn=lambda d: None, build_fn=lambda d: None) + builds = {j.node: j for j in jobs if j.kind == BUILD} + assert builds[root].ungated and not builds[leaf].ungated + + +def test_many_small_leaf_builds_launch_in_parallel_under_busy_cpu(): + # a wide project with ~20 small leaf deps; with the old CPU gate they ran one-at-a-time. Small TU + # weights must fill the core budget concurrently even while the sampler reads saturated. + p = Probe() + jobs = [Job(i, BUILD, p.body, weight=2) for i in range(12)] + sched = _sched(cpu_sampler=lambda: 99.0, core_budget=16) # 16/2 = 8 fit at once + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 8) + p.gate.set(); t.join(2.0) + assert p.max == 8 + + +def test_build_slot_barrier_blocks_until_budget_frees(): + sched = _sched(core_budget=8, overprovision=1.0, cpu_sampler=lambda: 100.0) + p = Probe() + hog = Job('hog', BUILD, p.body, weight=8) + acquired = [] + def runner(): + with sched.build_slot(8): # needs the whole budget; blocked while hog holds it + acquired.append(time.monotonic()) + t, _ = _run_bg(sched, [hog, Job('runner', BUILD, runner, weight=0)]) + assert _wait_until(lambda: p.cur == 1) # hog running, holds budget=8 + time.sleep(0.05) + assert not acquired # the slot is blocked behind the hog + p.gate.set(); t.join(2.0) + assert acquired # released once the hog freed the budget + + +def test_resolve_weight_handles_int_and_callable(): + assert Scheduler._resolve_weight(Job('a', BUILD, lambda: None, weight=lambda: 4)) == 4 + assert Scheduler._resolve_weight(Job('a', BUILD, lambda: None, weight=3)) == 3 + assert Scheduler._resolve_weight(Job('a', BUILD, lambda: None, weight=0)) == 0 # unsizable -> no reserve + + +def test_assign_priorities_is_the_critical_path_depth(): + # a feeds b (a -> b); c is independent. a's trunk depth (itself + b) beats c, so the trunk feeder runs first. + a, b, c = Job('a', BUILD, lambda: None), Job('b', BUILD, lambda: None), Job('c', BUILD, lambda: None) + b.deps.add(a) + assign_priorities([a, b, c]) + assert (a.priority, b.priority, c.priority) == (2, 1, 1) and a.priority > c.priority + + +def test_scheduler_launches_highest_priority_ready_job_first(): + order, gate = [], threading.Event() + def body(n): order.append(n); gate.wait(2.0) + lo = Job('lo', BUILD, lambda: body('lo'), weight=8); lo.priority = 1.0 + hi = Job('hi', BUILD, lambda: body('hi'), weight=8); hi.priority = 100.0 + sched = _sched(core_budget=8, overprovision=1.0, cpu_sampler=lambda: 100.0) # budget fits one weight-8 build + t, _ = _run_bg(sched, [lo, hi]) # pending order is lo, hi - but priority must win + assert _wait_until(lambda: order == ['hi']) # only hi launched; lo waits though it's also ready + gate.set(); t.join(3.0) + assert order == ['hi', 'lo'] + + +def test_fail_fast_returns_failed_job_and_blocks_dependents(): + def boom(): raise RuntimeError('kaboom') + a = Job('a', BUILD, boom) + b = Job('b', BUILD, lambda: None, deps=[a]) # depends on the failing job + c = Job('c', BUILD, lambda: None) # independent + failed = _sched().run([a, b, c]) + assert failed is a and isinstance(a.error, RuntimeError) + assert not b.started and not b.done # never released after a failed + assert c.done # independent job still completed + + +def test_failure_fires_abort_hook_once_to_kill_in_flight(): + # Fail-fast: the first failure fires the child-killer so in-flight compiles are killed, not drained. + hook = [] + def boom(): raise RuntimeError('kaboom') + failed = _sched(abort_hook=lambda: hook.append(1)).run([Job('a', BUILD, boom), Job('b', BUILD, boom)]) + assert failed is not None and len(hook) == 1 # exactly once - only the first failure aborts + + +def test_load_governor_caps_concurrency(): + p = Probe() + jobs = [Job(i, LOAD, p.body) for i in range(5)] + sched = _sched(max_load=2) + t, _ = _run_bg(sched, jobs) + assert _wait_until(lambda: p.cur == 2) + time.sleep(0.05) + assert p.cur == 2 # capped at max_load + p.gate.set(); t.join(2.0) + assert p.max == 2 + + +def test_dynamic_grow_runs_child_jobs_after_parent_load(): + log, lock = [], threading.Lock() + def rec(x): + with lock: log.append(x) + sched = _sched() + parent_cfg = Job(('p', 'c'), CONFIGURE, lambda: rec('cfg-p')) + parent_bld = Job(('p', 'b'), BUILD, lambda: rec('bld-p'), deps={parent_cfg}) + def parent_load(): + rec('load-p') + def grow(): + cl = Job(('c', 'L'), LOAD, lambda: rec('load-c')) + cc = Job(('c', 'c'), CONFIGURE, lambda: rec('cfg-c'), deps={cl}) + cb = Job(('c', 'b'), BUILD, lambda: rec('bld-c'), deps={cc}) + parent_cfg.deps.add(cb) # parent configure must now wait for the discovered child's build + return [cl, cc, cb] + sched.grow(grow) + pl = Job(('p', 'L'), LOAD, parent_load) + parent_cfg.deps.add(pl) + assert sched.run([pl, parent_cfg, parent_bld]) is None + assert set(log) == {'load-p', 'load-c', 'cfg-c', 'bld-c', 'cfg-p', 'bld-p'} + assert log.index('load-c') > log.index('load-p') # child discovered during parent load + assert log.index('cfg-p') > log.index('bld-c') # parent configure waited for the child build + assert log.index('bld-p') > log.index('cfg-p') + + +def test_unsatisfiable_dep_is_reported_not_hung(): + a = Job('a', BUILD, lambda: None) + a.deps.add(Job('missing', BUILD, lambda: None)) # dep never added to the run set + failed = _sched(poll_interval=0.02).run([a]) + assert failed is a # deadlock guard returns it instead of looping forever + + +class _Dep: + def __init__(self, name, children=()): + self.name = name; self._children = list(children); self.is_root = False + def get_children(self): return self._children + + +def test_build_dep_jobs_orders_parent_configure_after_child_build(): + log, lock = [], threading.Lock() + def rec(kind, dep): + with lock: log.append((kind, dep.name)) + child = _Dep('child'); parent = _Dep('parent', [child]) + jobs = build_dep_jobs([child, parent], + configure_fn=lambda d: rec('cfg', d), build_fn=lambda d: rec('bld', d)) + assert _sched().run(jobs) is None + assert log.index(('bld', 'child')) < log.index(('cfg', 'parent')) # child built before parent configures + assert log.index(('cfg', 'parent')) < log.index(('bld', 'parent')) # own configure before own build + + +def test_build_dep_jobs_resolves_weight_lazily_per_dep(): + d = _Dep('x') + jobs = build_dep_jobs([d], configure_fn=lambda d: None, build_fn=lambda d: None, weight_fn=lambda d: 7) + build = next(j for j in jobs if j.kind == BUILD) + assert Scheduler._resolve_weight(build) == 7 + + +def test_keyboard_interrupt_aborts_build_kills_children_and_returns_interrupted(): + # Ctrl+C lands in the scheduler loop: abort_hook fires (kills children), the in-flight job is + # released, and run() returns a synthetic KeyboardInterrupt job so the caller fails the build. + released, hook, n = threading.Event(), [], {'i': 0} + def sampler(): + n['i'] += 1 + if n['i'] >= 2: raise KeyboardInterrupt # 2nd pass = user hits Ctrl+C + return 0.0 + sched = _sched(cpu_sampler=sampler, abort_hook=lambda: (hook.append(1), released.set())) + failed = sched.run([Job('blocker', BUILD, lambda: released.wait(2.0), weight=0)]) + assert hook == [1] and sched._aborted + assert failed is not None and isinstance(failed.error, KeyboardInterrupt) + assert released.is_set() # the worker was unblocked -> the pool drained + + +def test_build_slot_bails_when_build_already_failing(): + # A custom build()'s barrier must not start a new compile once the build is aborting/failing. + sched = _sched() + sched._error = Job('boom', BUILD, lambda: None) + with pytest.raises(BuildInterrupted): + with sched.build_slot(4): pass + + +def test_pending_log_is_called_without_holding_the_scheduler_lock(): + # the display writes to the terminal from this callback; holding _cond across it stalls every + # job launch and completion behind a frame write + held = [] + def probe(hint): + got = [None] + def grab(): # acquire AND release on the same thread: an RLock can't be released by another + got[0] = sched._cond.acquire(timeout=0.2) + if got[0]: sched._cond.release() + t = threading.Thread(target=grab); t.start(); t.join(1.0) + held.append(not got[0]) # True = the scheduler still held the lock + sched = _sched(pending_log=probe) + done = threading.Event() + jobs = [Job('a', BUILD, lambda: done.wait(0.3), node=SimpleNamespace(name='a')), + Job('b', BUILD, lambda: None, deps=[], node=SimpleNamespace(name='b'))] + sched.run(jobs) + assert held and not any(held), 'pending_log ran while the scheduler lock was held' diff --git a/tests/test_buildstats/test_buildstats.py b/tests/test_buildstats/test_buildstats.py new file mode 100644 index 0000000..de79ba2 --- /dev/null +++ b/tests/test_buildstats/test_buildstats.py @@ -0,0 +1,59 @@ +"""Pins the buildstats report: normalized segmented bars, slowest-first, encoding-safe glyphs.""" +import contextlib +from types import SimpleNamespace +from mama import dependency_chain as dc +from testutils import strip_ansi + +def _dep(name, **pt): return SimpleNamespace(name=name, phase_times=pt) + + +def test_bar_normalizes_to_slowest_and_pads_to_full_width(): + full = strip_ansi(dc._buildstats_bar({'build': 100.0}, 100.0, 100.0, dc._GLYPHS_ASCII)) + half = strip_ansi(dc._buildstats_bar({'build': 50.0}, 50.0, 100.0, dc._GLYPHS_ASCII)) + assert len(full) == len(half) == dc._BAR_FILL # both padded to one fixed width -> totals align + assert full.count('#') == dc._BAR_FILL # the slowest dep fills the whole bar + assert half.count('#') == round(dc._BAR_FILL / 2) # half the time -> half the filled length + + +def test_bar_segments_are_proportional_in_load_cfg_build_order(): + bar = strip_ansi(dc._buildstats_bar({'load': 1.0, 'configure': 1.0, 'build': 2.0}, 4.0, 4.0, dc._GLYPHS_ASCII)) + assert bar == '-' * 10 + '=' * 10 + '#' * 20 # 25/25/50% of 40, ordered load(-) cfg(=) build(#), no gaps + + +def test_blocks_fall_back_to_ascii_on_a_legacy_code_page(): + assert not dc._can_encode_blocks('cp1252') # Windows legacy code page can't encode ░▒▓ -> ASCII + assert dc._can_encode_blocks('utf-8') + + +def test_bar_glyphs_is_computed_once_and_cached(): + assert dc._bar_glyphs() is dc._bar_glyphs() # same object -> not recomputed per call + assert dc._bar_glyphs() in (dc._GLYPHS_SHADE, dc._GLYPHS_ASCII) + + +def test_report_sorts_slowest_first_and_omits_noops_and_sub_floor(capsys): + deps = [_dep('fast', build=2.0), _dep('slow', load=1.0, configure=5.0, build=60.0), + _dep('cached'), _dep('blink', build=0.2)] # 0.2s < 0.33s floor + dc.print_buildstats(deps) + out = strip_ansi(capsys.readouterr().out) + assert 'cached' not in out and 'blink' not in out # no-op and sub-floor packages dropped + assert out.index('slow') < out.index('fast') # slowest package first + assert '1m 6s' in out and '2.0s' in out # totals via the shared get_time_str + + +def test_legend_aligns_above_the_bars(capsys): + g = dc._bar_glyphs() + dc.print_buildstats([_dep('alpha', load=1.0, build=1.0), _dep('beta', build=2.0)]) + lines = strip_ansi(capsys.readouterr().out).splitlines() + header = next(l for l in lines if 'Build times' in l) + row = next(l for l in lines if 'alpha' in l) + assert header.index(g[0]) == row.index(g[0]) # legend's first glyph sits directly over the bar start + + +def test_run_phase_accumulates_phase_time(monkeypatch): + monkeypatch.setattr(dc.system, 'capture_to', lambda *a, **k: contextlib.nullcontext()) + disp = SimpleNamespace(start_task=lambda *a: None, feed=lambda *a: None, + finish_task=lambda *a: None, relabel=lambda *a: None) + dep = SimpleNamespace(name='x', config=SimpleNamespace(verbose=False), phase_times={}, + load_action='check', get_children=lambda: [], is_root=False) + dc._run_phase(disp, dep, 'build', lambda s: None, None, final=True) + assert 'build' in dep.phase_times and dep.phase_times['build'] >= 0 diff --git a/tests/test_clang_stdlib/test_clang_stdlib.py b/tests/test_clang_stdlib/test_clang_stdlib.py new file mode 100644 index 0000000..1c40b7d --- /dev/null +++ b/tests/test_clang_stdlib/test_clang_stdlib.py @@ -0,0 +1,26 @@ +"""Pins clang's -stdlib selection: libc++ by default, libstdc++ after use_gcc_stdlib_for_clang().""" +from testutils import make_mock_local_dep +from mama.build_config import BuildConfig +from mama import cmake_configure as cc + + +def _clang_target(tmp_path, monkeypatch, gcc_stdlib=False): + cfg = BuildConfig([]) + cfg.msvc = cfg.macos = cfg.ios = cfg.android = cfg.raspi = False + cfg.mips = cfg.oclea = cfg.xilinx = cfg.imx8mp = cfg.yocto_linux = None + cfg.linux = True; cfg.clang = True; cfg.gcc = False; cfg.arch = 'x64' + if gcc_stdlib: cfg.use_gcc_stdlib_for_clang() # root mamafile opts in, to link GNU-built prebuilts like Qt + monkeypatch.setattr(cfg, 'get_gcc_linux_march', lambda: 'x86-64') + monkeypatch.setattr(cc, '_set_compiler_paths', lambda t, o: None) + target = make_mock_local_dep(tmp_path, src_dir=tmp_path).target + target.config = cfg + cc._default_options(target) + return target.cmake_cxxflags.get('-stdlib', '') + + +def test_clang_defaults_to_libcxx(tmp_path, monkeypatch): + assert _clang_target(tmp_path, monkeypatch) == 'libc++' + + +def test_use_gcc_stdlib_switches_to_libstdcxx(tmp_path, monkeypatch): + assert _clang_target(tmp_path, monkeypatch, gcc_stdlib=True) == 'libstdc++' diff --git a/tests/test_clean_only/test_clean_only.py b/tests/test_clean_only/test_clean_only.py new file mode 100644 index 0000000..d5f8dc8 --- /dev/null +++ b/tests/test_clean_only/test_clean_only.py @@ -0,0 +1,65 @@ +"""Pins `mama clean` / `clean all`: cleans during load, then stops - no configure/build/package after.""" +from unittest.mock import Mock, patch +from mama.main import mamabuild + + +def _run(args, tmp_path): + (tmp_path / 'CMakeLists.txt').write_text('project(dummy)\n') # mamabuild refuses a dir with neither file + with patch('mama.main.load_dependency_chain') as load, \ + patch('mama.main.execute_task_chain') as serial, \ + patch('mama.main.execute_task_chain_parallel') as parallel, \ + patch('mama.main.execute_unified') as unified, \ + patch('mama.main._init_platform_compilers'): + mamabuild(args, source_dir=str(tmp_path)) + return load.called, (serial.called or parallel.called or unified.called) + + +def test_clean_all_stops_after_cleaning(tmp_path): + loaded, executed = _run(['clean', 'all'], tmp_path) + assert loaded and not executed # package() over a wiped dir dies in mamafile asserts + + +def test_plain_clean_stops_after_cleaning(tmp_path): + assert not _run(['clean'], tmp_path)[1] + + +def test_rebuild_still_runs_the_chain(tmp_path): + assert _run(['rebuild', 'all'], tmp_path)[1] # rebuild = clean + build, the build half must survive + + +def test_build_still_runs_the_chain(tmp_path): + assert _run(['build', 'all'], tmp_path)[1] + + +def _git_dep(tmp_path, **cfg): + """A git dep with no source on disk - the shape a previous clean leaves behind (its shim marker + lived in the build dir that clean deleted).""" + from testutils import make_mock_dep + dep = make_mock_dep(tmp_path, **cfg) + dep.config.clean_only.return_value = cfg.get('clean', False) and not cfg.get('build', False) + return dep + + +def _fetched_during_load(dep): + """Did loading this dep reach out to git? Returns the checkout call count.""" + def load_target(self): + self.target = Mock(args='', build_products=[], name=self.name) + return self.target + with patch.object(type(dep), '_git_checkout_if_needed', return_value=False) as checkout, \ + patch.object(type(dep), '_try_artifactory_shim', return_value=False), \ + patch.object(type(dep), '_try_artifactory_load', return_value=False), \ + patch.object(type(dep), '_load_target', load_target), \ + patch.object(type(dep), 'clean'): + dep._load() + return checkout.call_count + + +def test_clean_does_not_clone_missing_sources(tmp_path): + # `mama clean all` used to spend minutes cloning deps whose shim marker an earlier clean removed, + # only to delete the directory it just filled. + assert _fetched_during_load(_git_dep(tmp_path, clean=True, build=False)) == 0 + + +def test_a_build_still_fetches(tmp_path): + assert _fetched_during_load(_git_dep(tmp_path, clean=False, build=True)) == 1 + assert _fetched_during_load(_git_dep(tmp_path, clean=True, build=True)) == 1 # rebuild = clean + build diff --git a/tests/test_clean_sweep/test_clean_sweep.py b/tests/test_clean_sweep/test_clean_sweep.py new file mode 100644 index 0000000..46c6c26 --- /dev/null +++ b/tests/test_clean_sweep/test_clean_sweep.py @@ -0,0 +1,35 @@ +"""Pins `clean all`'s disk sweep: build dirs the tree walk can't reach are still cleaned, non-mama dirs aren't.""" +from types import SimpleNamespace +from mama.dependency_chain import sweep_orphaned_build_dirs + + +def _workspace(tmp_path): + ws = tmp_path / 'packages' + for name, files in (('protobuf', ['CMakeCache.txt']), ('SDL', ['mama_shim']), ('zlib', ['mama_exported_libs'])): + d = ws / name / 'linux'; d.mkdir(parents=True) + for f in files: (d / f).write_text('') + (ws / 'protobuf' / 'protobuf').mkdir() # a source tree, not a build dir + (ws / 'notmine' / 'linux').mkdir(parents=True) # a dir with no mama marker + (ws / 'protobuf' / 'windows').mkdir() # another platform: out of scope + (ws / 'protobuf' / 'windows' / 'CMakeCache.txt').write_text('') + root = SimpleNamespace(dep_dir=str(ws / 'root')) + return ws, root, SimpleNamespace(print=False, platform_build_dir_name=lambda: 'linux') + + +def test_sweep_removes_marked_build_dirs_for_this_platform(tmp_path): + ws, root, config = _workspace(tmp_path) + assert sweep_orphaned_build_dirs(root, config) == 3 + for name in ('protobuf', 'SDL', 'zlib'): assert not (ws / name / 'linux').exists() + + +def test_sweep_never_touches_unmarked_or_other_platform_dirs(tmp_path): + ws, root, config = _workspace(tmp_path) + sweep_orphaned_build_dirs(root, config) + assert (ws / 'notmine' / 'linux').exists() # no mama marker: not ours to delete + assert (ws / 'protobuf' / 'protobuf').exists() # source tree survives + assert (ws / 'protobuf' / 'windows').exists() # a different platform's build dir survives + + +def test_sweep_on_a_missing_workspace_is_a_noop(tmp_path): + config = SimpleNamespace(print=False, platform_build_dir_name=lambda: 'linux') + assert sweep_orphaned_build_dirs(SimpleNamespace(dep_dir=str(tmp_path / 'gone' / 'x')), config) == 0 diff --git a/tests/test_clone_timing/test_clone_timing.py b/tests/test_clone_timing/test_clone_timing.py index 3b3d169..b51bdad 100644 --- a/tests/test_clone_timing/test_clone_timing.py +++ b/tests/test_clone_timing/test_clone_timing.py @@ -5,13 +5,15 @@ @pytest.mark.parametrize('seconds,expected', [ - # sub-second: milliseconds, integer-truncated + # below 0.1s: milliseconds (keeps truly-instant ops from rendering a meaningless 0.0s) (0, '0ms'), (0.001, '1ms'), - (0.5, '500ms'), - (0.999, '999ms'), + (0.099, '99ms'), - # under a minute: one decimal place + # 0.1s and up under a minute: one decimal place (0.2s reads better than 200ms) + (0.1, '0.1s'), + (0.2, '0.2s'), + (0.5, '0.5s'), (1, '1.0s'), (1.5, '1.5s'), (42, '42.0s'), diff --git a/tests/test_cmake_cache_repair/test_cmake_cache_repair.py b/tests/test_cmake_cache_repair/test_cmake_cache_repair.py new file mode 100644 index 0000000..ef46e21 --- /dev/null +++ b/tests/test_cmake_cache_repair/test_cmake_cache_repair.py @@ -0,0 +1,129 @@ +"""Pins recovery from a build dir left half-configured by a killed configure (Ctrl+C, fail-fast teardown): +detect the truncated cache or the stage-1 compiler module, wipe it, reconfigure - instead of trusting it.""" +import os, pytest +from unittest.mock import patch + +from testutils import make_mock_local_dep +from mama import cmake_configure as cc + +COMPLETE = 'CMAKE_GENERATOR:INTERNAL=Unix Makefiles\nCMAKE_BUILD_TYPE:STRING=Release\n' +NINJA = 'CMAKE_GENERATOR:INTERNAL=Ninja\nCMAKE_BUILD_TYPE:STRING=Release\n' +TRUNCATED = '# This is the CMakeCache file.\nCMAKE_BUILD_TYPE:STRING=Release\n' # killed before the generator line + + +def _target(tmp_path): + sub = tmp_path / 'pkg'; sub.mkdir() + dep = make_mock_local_dep(tmp_path, src_dir=sub, jobs=8, coverage=False, clang_tidy=False) + dep.config.get_preferred_compiler_paths.return_value = ('/usr/bin/gcc', '/usr/bin/g++', '13.3') + return dep.target, dep + + +def _write_cache(build_dir, text): + os.makedirs(build_dir, exist_ok=True) + with open(os.path.join(build_dir, 'CMakeCache.txt'), 'w', encoding='utf-8') as f: f.write(text) + + +def _write_build_file(build_dir, name='build.ninja'): + with open(os.path.join(build_dir, name), 'w', encoding='utf-8') as f: f.write('# generated\n') + + +def test_is_cmake_cache_valid(tmp_path): + d = str(tmp_path / 'b') + assert not cc.is_cmake_cache_valid(d) # no cache at all + _write_cache(d, TRUNCATED); assert not cc.is_cmake_cache_valid(d) # interrupted configure + _write_cache(d, COMPLETE) + assert not cc.is_cmake_cache_valid(d) # complete cache but configure died before emitting the Makefile + _write_build_file(d, 'Makefile'); assert cc.is_cmake_cache_valid(d) # a configure that ran to completion + + +def test_cache_generator_reads_the_exact_key(): + assert cc.cache_generator(NINJA) == 'Ninja' + assert cc.cache_generator(COMPLETE) == 'Unix Makefiles' + # the _PLATFORM/_TOOLSET siblings must not be mistaken for the generator itself + assert cc.cache_generator('CMAKE_GENERATOR_PLATFORM:STRING=x64\n') == '' + assert cc.cache_generator(TRUNCATED) == '' + + +def test_a_stale_other_build_system_file_does_not_count(tmp_path): + # Targets pick their own build system: a leftover Makefile must NOT make a Ninja-configured dir + # look complete, or `cmake --build` dies on the missing build.ninja every time. + d = str(tmp_path / 'b') + _write_cache(d, NINJA); _write_build_file(d, 'Makefile') + assert not cc.is_cmake_cache_valid(d) + _write_build_file(d, 'build.ninja'); assert cc.is_cmake_cache_valid(d) + + +def test_unknown_generator_is_trusted_not_wiped(tmp_path): + d = str(tmp_path / 'b') + _write_cache(d, 'CMAKE_GENERATOR:INTERNAL=Green Hills MULTI\n') + assert cc.is_cmake_cache_valid(d) # we don't know its build file - let cmake decide, don't wipe blindly + + +def test_cache_without_generated_build_file_is_repaired(tmp_path): + # A find_package failure leaves a COMPLETE cache but no build.ninja; skipping the reconfigure then + # dies with "ninja: error: loading 'build.ninja'" on every later build until it's wiped. + t, dep = _target(tmp_path) + _write_cache(t.build_dir(), COMPLETE) + assert _run_config_recording(t, dep) == ['conf'] + assert not os.path.exists(os.path.join(t.build_dir(), 'CMakeCache.txt')) + + +def _run_config_recording(t, dep): + """run_config with the cmake call + seed coordinator stubbed; returns the recorded conf calls.""" + calls = [] + with patch('mama.cmake_configure._rerunnable_cmake_conf', side_effect=lambda *a, **k: calls.append('conf')), \ + patch('mama.cmake_configure.compute_env', return_value={}), \ + patch('mama.cmake_configure._seed_coordinator') as coord, \ + patch.object(dep, 'get_enabled_sanitizers', return_value=''): + coord.return_value.prepare.return_value = 'none' + coord.return_value.status.return_value = ('fp', False) + cc.run_config(t) + return calls + + +def test_truncated_cache_is_wiped_and_reconfigured(tmp_path): + t, dep = _target(tmp_path) + _write_cache(t.build_dir(), TRUNCATED) + assert _run_config_recording(t, dep) == ['conf'] # did NOT skip on a cache that merely exists + assert not os.path.exists(os.path.join(t.build_dir(), 'CMakeCache.txt')) # the bad cache was dropped + + +def test_complete_configure_still_skips_the_reconfigure(tmp_path): + t, dep = _target(tmp_path) + _write_cache(t.build_dir(), COMPLETE); _write_build_file(t.build_dir(), 'Makefile') + assert _run_config_recording(t, dep) == [] # nothing broken -> no needless reconfigure + assert os.path.exists(os.path.join(t.build_dir(), 'CMakeCache.txt')) + + +def _write_compiler_module(build_dir, ver='4.3.1', abi_done=True): + """CMakeFiles//CMakeCXXCompiler.cmake; without the ABI line it's the stage-1 module a + configure killed mid-detection leaves behind.""" + d = os.path.join(build_dir, 'CMakeFiles', ver); os.makedirs(d, exist_ok=True) + text = 'set(CMAKE_CXX_COMPILER "/usr/bin/g++")\n' + ('set(CMAKE_CXX_ABI_COMPILED TRUE)\n' if abi_done else '') + with open(os.path.join(d, 'CMakeCXXCompiler.cmake'), 'w', encoding='utf-8') as f: f.write(text) + return d + + +@pytest.mark.parametrize('with_cache', [True, False]) # a kill mid-detection often saves no cache at all +def test_killed_detection_is_wiped_and_reconfigured(tmp_path, with_cache): + t, dep = _target(tmp_path) + if with_cache: _write_cache(t.build_dir(), NINJA); _write_build_file(t.build_dir(), 'build.ninja') + _write_compiler_module(t.build_dir(), abi_done=False) + with patch('mama.cmake_configure._cmake_version_number', return_value='4.3.1'): # no `cmake --version` shell-out + assert _run_config_recording(t, dep) == ['conf'] # the dir looks complete but cmake would trust + assert not os.path.exists(os.path.join(t.build_dir(), 'CMakeFiles')) # the stage-1 module: wipe, redetect + + +def test_a_completed_detection_is_left_alone(tmp_path): + t, dep = _target(tmp_path) + _write_cache(t.build_dir(), NINJA); _write_build_file(t.build_dir(), 'build.ninja') + _write_compiler_module(t.build_dir(), abi_done=True) + with patch('mama.cmake_configure._cmake_version_number', return_value='4.3.1'): + assert _run_config_recording(t, dep) == [] # nothing broken -> no needless reconfigure + + +def test_rerunnable_error_covers_every_broken_build_dir_flavour(): + assert cc.is_rerunnable_error('Error: could not find CMAKE_GENERATOR in Cache') # truncated cache + assert cc.is_rerunnable_error('make: *** Makefile: No such file or directory') # missing makefile + assert cc.is_rerunnable_error("ninja: error: loading 'build.ninja': No such file or directory") + assert not cc.is_rerunnable_error('error: undefined reference to `foo()`') # a real build error diff --git a/tests/test_compile_commands/test_compile_commands.py b/tests/test_compile_commands/test_compile_commands.py index 5815d00..a2fee9c 100644 --- a/tests/test_compile_commands/test_compile_commands.py +++ b/tests/test_compile_commands/test_compile_commands.py @@ -1,8 +1,9 @@ """Pins that sanitized/coverage builds don't repoint c_cpp_properties.json compileCommands.""" import json, os from pathlib import Path +from types import SimpleNamespace from unittest.mock import Mock -from mama.dependency_chain import _save_vscode_compile_commands +from mama.dependency_chain import _save_vscode_compile_commands, _find_matching_platform_config def _make_vscode_dep(tmp_path, sanitize=None, coverage=None): @@ -41,3 +42,20 @@ def test_coverage_build_leaves_compile_commands_untouched(tmp_path): dep, props_path = _make_vscode_dep(tmp_path, coverage='gcov') _save_vscode_compile_commands(dep) assert _commands(props_path) == 'ORIGINAL' + + +def _cfg_dep(clang: bool): + cfg = SimpleNamespace(clang=clang, gcc=not clang, arch='x64', name=lambda: 'linux') + return SimpleNamespace(config=cfg) + + +def test_a_config_named_for_the_other_compiler_is_never_picked(): + confs = [{'name': 'Linux GCC x64'}, {'name': 'Linux Clang x64'}] + assert _find_matching_platform_config(_cfg_dep(clang=True), confs)['name'] == 'Linux Clang x64' + assert _find_matching_platform_config(_cfg_dep(clang=False), confs)['name'] == 'Linux GCC x64' + + +def test_a_compiler_agnostic_config_still_matches(): + confs = [{'name': 'Linux x64'}] + assert _find_matching_platform_config(_cfg_dep(clang=True), confs)['name'] == 'Linux x64' + assert _find_matching_platform_config(_cfg_dep(clang=True), [{'name': 'Windows x64'}]) is None diff --git a/tests/test_compiler_cache/test_compiler_cache.py b/tests/test_compiler_cache/test_compiler_cache.py new file mode 100644 index 0000000..1f5d890 --- /dev/null +++ b/tests/test_compiler_cache/test_compiler_cache.py @@ -0,0 +1,418 @@ +"""Pins cmake_compiler_cache: fingerprint, publish/load/inject round-trip, TTL, purge, and the +probe-seeded Coordinator.""" +import contextlib, os, threading, time +from types import SimpleNamespace +from mama import cmake_compiler_cache as cc +from mama.util import normalized_path, path_join + + +def _fake_build_files(d, langs=('C', 'CXX', 'RC'), vs=True, partial=()): + """A `CMakeFiles/` dir as cmake leaves it post-detection; `partial` langs stop at stage 1 (no ABI + probe), as a killed configure leaves them.""" + os.makedirs(d, exist_ok=True) + open(os.path.join(d, 'CMakeSystem.cmake'), 'w').write('set(CMAKE_SYSTEM Windows)\n') + for lang in langs: + mod, abi = cc._LANG_FILES[lang] + done = lang not in partial + text = f'set(CMAKE_{lang}_COMPILER "C:/bin/{lang.lower()}.exe")\n' + if abi and done: text += f'set(CMAKE_{lang}_ABI_COMPILED TRUE)\n' + open(os.path.join(d, mod), 'w').write(text) + if abi and done: open(os.path.join(d, abi), 'wb').write(b'\x00abi') + if vs: open(os.path.join(d, 'VCTargetsPath.txt'), 'w').write('C:/VCTargets\n') + return d + + +def test_fingerprint_stable_and_sensitive(): + a = {'cmake': '4.2.3', 'gen': 'VS18', 'cc': {'mtime': 1}} + assert cc.compute_fingerprint(a) == cc.compute_fingerprint(dict(a)) # order-independent, stable + assert cc.compute_fingerprint(a) != cc.compute_fingerprint({**a, 'cmake': '4.3.0'}) + assert cc.compute_fingerprint(a) != cc.compute_fingerprint({**a, 'cc': {'mtime': 2}}) # compiler updated + + +def test_compiler_stat_reports_size_mtime_or_bare_path(tmp_path): + f = tmp_path / 'cl.exe'; f.write_bytes(b'x' * 10) + s = cc.compiler_stat(str(f)) + assert s['size'] == 10 and 'mtime' in s and s['path'].endswith('cl.exe') + assert cc.compiler_stat(str(tmp_path / 'nope')) == {'path': str(tmp_path / 'nope')} + + +def test_detected_langs_from_files(tmp_path): + d = _fake_build_files(str(tmp_path / '4.2.3'), langs=('CXX',), vs=False) + assert cc.detected_langs(d) == ['CXX'] + + +def test_publish_then_inject_reproduces_warm_state(tmp_path): + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3')) + seed = str(tmp_path / 'seed') + assert cc.publish(seed, bf, clock=lambda: 1000) + m = cc.load(seed, clock=lambda: 1000) + assert m and set(m['langs']) == {'C', 'CXX', 'RC'} and m['cmake_files_ver'] == '4.2.3' + + build = str(tmp_path / 'B'); bfd = os.path.join(build, 'CMakeFiles', '4.2.3') + src = str(tmp_path / 'proj_src') + cc.inject(seed, build, bfd, src_dir=src) + for f in ('CMakeCXXCompiler.cmake', 'CMakeDetermineCompilerABI_CXX.bin', 'CMakeSystem.cmake', 'VCTargetsPath.txt'): + assert os.path.exists(os.path.join(bfd, f)) # toolchain files copied into CMakeFiles/ + cache = open(os.path.join(build, 'CMakeCache.txt')).read() + assert 'CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1' in cache # the marker that skips detection + assert f'CMAKE_HOME_DIRECTORY:INTERNAL={normalized_path(src)}' in cache # must match the configured source + + +def test_inject_writes_only_toolchain_markers_never_project_settings(tmp_path): + # Regression: injected cache is toolchain markers only (no ABI facts captured here), no project flags. + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3')) + seed = str(tmp_path / 'seed'); cc.publish(seed, bf) + build = str(tmp_path / 'B'); bfd = os.path.join(build, 'CMakeFiles', '4.2.3') + src = str(tmp_path / 'b') + cc.inject(seed, build, bfd, src_dir=src) + lines = [l.strip() for l in open(os.path.join(build, 'CMakeCache.txt')) if l.strip()] + assert lines == ['CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1', f'CMAKE_HOME_DIRECTORY:INTERNAL={normalized_path(src)}'] + assert 'CMakeCache.txt' not in os.listdir(seed) # we never capture a project's cache + + +def _write_cache(build_dir, extra=''): + os.makedirs(build_dir, exist_ok=True) + text = 'CMAKE_GENERATOR:INTERNAL=Ninja\nCMAKE_EXECUTABLE_FORMAT:INTERNAL=ELF\nBUILD_TESTS:BOOL=ON\n' + extra + open(os.path.join(build_dir, 'CMakeCache.txt'), 'w').write(text) + + +def test_seeded_cache_replays_the_abi_facts_the_probe_would_have_set(tmp_path): + # no CMAKE_EXECUTABLE_FORMAT -> seeded configure dies on every install-RPATH add_executable + build = str(tmp_path / 'A') + bf = _fake_build_files(os.path.join(build, 'CMakeFiles', '4.2.3')) + _write_cache(build, 'CMAKE_LIBRARY_ARCHITECTURE:INTERNAL=x86_64-linux-gnu\n') + seed = str(tmp_path / 'seed') + assert cc.publish(seed, bf, build_dir=build) + + dst = str(tmp_path / 'B') + cc.inject(seed, dst, os.path.join(dst, 'CMakeFiles', '4.2.3'), src_dir=str(tmp_path / 'src')) + cache = open(os.path.join(dst, 'CMakeCache.txt')).read() + assert 'CMAKE_EXECUTABLE_FORMAT:INTERNAL=ELF' in cache + assert 'CMAKE_LIBRARY_ARCHITECTURE:INTERNAL=x86_64-linux-gnu' in cache + assert 'BUILD_TESTS' not in cache # toolchain facts only, no project settings leak + + +def test_publish_returns_false_without_compiler_files(tmp_path): + empty = str(tmp_path / 'x' / '4.2.3'); os.makedirs(empty) + assert cc.publish(str(tmp_path / 'seed'), empty) is False + + +def test_detection_is_partial_spots_a_killed_detection(tmp_path): + assert not cc.detection_is_partial(_fake_build_files(str(tmp_path / 'ok' / '4.2.3'))) + assert cc.detection_is_partial(_fake_build_files(str(tmp_path / 'bad' / '4.2.3'), partial=('CXX',))) + # RC has no ABI probe: a module without one is complete, not partial + assert not cc.detection_is_partial(_fake_build_files(str(tmp_path / 'rc' / '4.2.3'), langs=('RC',))) + + +def test_publish_refuses_a_half_detected_toolchain(tmp_path): + # else one interrupted configure poisons every project via the shared seed + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3'), partial=('CXX',)) + seed = str(tmp_path / 'seed') + assert cc.publish(seed, bf) is False + assert cc.load(seed) is None + + +def test_publish_refuses_a_seed_missing_a_core_language(tmp_path): + # backstop for the seeding invariant: a seed missing a core language would let a project that + # enables it skip detection and die on 'CMAKE__COMPILER not set, after EnableLanguage'. + for langs in (('C',), ('CXX',), ('C', 'RC')): + bf = _fake_build_files(str(tmp_path / '_'.join(langs) / '4.2.3'), langs=langs) + seed = str(tmp_path / ('seed_' + '_'.join(langs))) + assert cc.publish(seed, bf) is False, f'{langs} must not publish' + assert cc.load(seed) is None + # both core languages present -> publishable + bf = _fake_build_files(str(tmp_path / 'full' / '4.2.3'), langs=('C', 'CXX')) + assert cc.publish(str(tmp_path / 'seed_full'), bf) is True + + +def test_is_valid_rejects_a_single_language_seed(tmp_path): + # _try_use purges what is_valid rejects, so an on-disk C-only seed self-heals instead of poisoning + assert not cc.is_valid({'fingerprint': 'FP', 'langs': ['C']}, 'FP') + assert not cc.is_valid({'fingerprint': 'FP', 'langs': ['CXX']}, 'FP') + assert not cc.is_valid({'fingerprint': 'FP', 'langs': []}, 'FP') + assert cc.is_valid({'fingerprint': 'FP', 'langs': ['C', 'CXX']}, 'FP') + assert not cc.is_valid({'fingerprint': 'FP'}, 'FP') # no langs record -> can't prove it covers C+CXX + + +def test_inject_writes_no_marker_when_seed_has_no_files(tmp_path): + # A vanished/empty seed must NOT leave a PLATFORM_INFO marker with zero compiler files - cmake + # would then trust detection that isn't there. inject bails (False); the caller redetects. + seed = str(tmp_path / 'seed'); os.makedirs(seed) + open(os.path.join(seed, cc._MANIFEST), 'w').write('{"files": [], "langs": []}') + build = str(tmp_path / 'B') + assert cc.inject(seed, build, os.path.join(build, 'CMakeFiles', '4.2.3'), 'S:/src') is False + assert not os.path.exists(os.path.join(build, 'CMakeCache.txt')) + + +def test_load_honors_backstop_ttl(tmp_path): + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3')) + seed = str(tmp_path / 'seed') + cc.publish(seed, bf, clock=lambda: 1000) + assert cc.load(seed, ttl=100, clock=lambda: 1050) is not None # within TTL + assert cc.load(seed, ttl=100, clock=lambda: 2000) is None # past TTL + + +def test_purge_removes_seed(tmp_path): + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3')) + seed = str(tmp_path / 'seed') + cc.publish(seed, bf) + cc.purge(seed) + assert cc.load(seed) is None + cc.purge(seed) # idempotent, never raises + + +def test_is_valid_requires_matching_fingerprint_and_live_probe(tmp_path): + cl = tmp_path / 'cl.exe'; cl.write_text('') + m = {'fingerprint': 'FP', 'probe': str(cl), 'langs': ['C', 'CXX']} + assert cc.is_valid(m, 'FP') + assert not cc.is_valid(m, 'OTHER') # fingerprint changed + assert not cc.is_valid({'fingerprint': 'FP', 'probe': str(tmp_path / 'gone.exe'), + 'langs': ['C', 'CXX']}, 'FP') # compiler removed + assert not cc.is_valid(None, 'FP') and not cc.is_valid({}, 'FP') + assert cc.is_valid({'fingerprint': 'FP', 'langs': ['C', 'CXX']}, 'FP') # no probe -> fingerprint alone gates + + +def test_publish_records_fingerprint_and_probe_so_a_fresh_seed_validates(tmp_path): + bf = _fake_build_files(str(tmp_path / 'A' / '4.2.3')) + cl = tmp_path / 'cl.exe'; cl.write_text('') + seed = str(tmp_path / 'seed') + cc.publish(seed, bf, fingerprint='FP', probe=str(cl)) + m = cc.load(seed) + assert m['fingerprint'] == 'FP' and m['probe'] == str(cl) and cc.is_valid(m, 'FP') + + +def test_gc_stale_drops_legacy_and_dead_probe_but_keeps_live(tmp_path): + root = str(tmp_path / 'cache') + cl = tmp_path / 'cl.exe'; cl.write_text('') + bf = _fake_build_files(str(tmp_path / 'bf' / '4.2.3')) + cc.publish(path_join(root, 'live'), bf, fingerprint='L', probe=str(cl)) # toolchain present + cc.publish(path_join(root, 'dead'), bf, fingerprint='D', probe=str(tmp_path / 'x')) # compiler gone + legacy = path_join(root, 'legacy'); os.makedirs(legacy) + open(path_join(legacy, cc._MANIFEST), 'w').write('{"files": [], "langs": [], "created": 0}') + cc.gc_stale(root) + assert set(os.listdir(root)) == {'live'} # dead-probe and legacy (no fingerprint) seeds purged + + +def test_begin_session_sweeps_and_logs_once(tmp_path): + root = str(tmp_path / 'cache') + legacy = path_join(root, 'old'); os.makedirs(legacy) + open(path_join(legacy, cc._MANIFEST), 'w').write('{"files": [], "langs": []}') # no fingerprint -> stale + logs = [] + co = cc.Coordinator(root, fp_fn=lambda t: 'FP', paths_fn=lambda t: None, log_fn=logs.append) + co.begin_session(); co.begin_session() # second call is a no-op + assert not os.path.exists(legacy) # legacy seed swept + assert sum('compiler-seed cache:' in m for m in logs) == 1 # root logged exactly once + assert any('drop stale seed old' in m for m in logs) + + +def test_begin_session_announces_disabled_cache(tmp_path): + logs = [] + cc.Coordinator(str(tmp_path / 'c'), fp_fn=lambda t: 'FP', paths_fn=lambda t: None, + enabled=False, log_fn=logs.append).begin_session() + assert any('disabled' in m for m in logs) + + +class _T: + def __init__(self, build_dir, src='S:/src'): + self.build_dir = build_dir + self.bfd = os.path.join(build_dir, 'CMakeFiles', '4.2.3') + self.src = src + + +def _probe(tmp_path, langs=('C', 'CXX'), calls=None, fail=False, delay=0.0): + """Stand-in for the synthetic `project(mama_seed C CXX)` configure: yields a finished probe dir.""" + runs = [] + @contextlib.contextmanager + def run(target): + runs.append(target) + if calls is not None: calls.append(target) + if delay: time.sleep(delay) + if fail: yield None; return + d = str(tmp_path / f'probe{len(runs)}') + bfd = _fake_build_files(os.path.join(d, 'CMakeFiles', '4.2.3'), langs=langs, vs=False) + _write_cache(d) + yield (d, bfd) + return run + + +def _coord(tmp_path, **kw): + kw.setdefault('seed_fn', _probe(tmp_path)) + return cc.Coordinator(str(tmp_path / 'cache'), fp_fn=lambda t: 'FP', + paths_fn=lambda t: (t.build_dir, t.bfd, t.src), **kw) + + +def test_probe_seeds_the_first_caller_and_every_later_one(tmp_path): + calls = [] + co = _coord(tmp_path, seed_fn=_probe(tmp_path, calls=calls)) + assert co.prepare(_T(str(tmp_path / 'A'))) == 'use' # the caller that ran the probe is seeded too + proj = str(tmp_path / 'proj') + assert co.prepare(_T(str(tmp_path / 'B'), src=proj)) == 'use' + assert len(calls) == 1 # probe ran once for the fingerprint + assert os.path.exists(os.path.join(str(tmp_path / 'B'), 'CMakeFiles', '4.2.3', 'CMakeCXXCompiler.cmake')) + cache = open(os.path.join(str(tmp_path / 'B'), 'CMakeCache.txt')).read() + assert f'CMAKE_HOME_DIRECTORY:INTERNAL={normalized_path(proj)}' in cache # B's own source, not the probe's + + +def test_a_cxx_only_project_is_served_by_the_probe(tmp_path): + # the bug this design fixes: a C-only or CXX-only project used to decide the seed's languages + co = _coord(tmp_path) + assert co.prepare(_T(str(tmp_path / 'cxx_only'))) == 'use' + assert cc.load(co.seed_dir(_T(str(tmp_path / 'x'))))['langs'] == ['C', 'CXX'] + + +def test_a_probe_missing_a_core_language_is_never_published(tmp_path): + for i, langs in enumerate((('C',), ('CXX',))): + sub = tmp_path / f'case{i}' + co = _coord(sub, seed_fn=_probe(sub, langs=langs)) + assert co.prepare(_T(str(tmp_path / 'A'))) == 'none' # detect normally rather than inject a gap + assert cc.load(co.seed_dir(_T(str(tmp_path / 'A')))) is None + + +def test_waiter_blocks_until_the_probe_finishes(tmp_path): + co = _coord(tmp_path, seed_fn=_probe(tmp_path, delay=0.4)) + out = {} + first = threading.Thread(target=lambda: co.prepare(_T(str(tmp_path / 'A')))) + first.start(); time.sleep(0.05) + w = threading.Thread(target=lambda: out.__setitem__('role', co.prepare(_T(str(tmp_path / 'B'))))) + w.start(); w.join(0.1); assert w.is_alive() # blocked while the probe runs + first.join(3.0); w.join(3.0) + assert out['role'] == 'use' + + +def test_probe_runs_exactly_once_under_race(tmp_path): + calls = [] + co = _coord(tmp_path, wait_timeout=5.0, seed_fn=_probe(tmp_path, calls=calls, delay=0.05)) + n = 12 + targets = [_T(str(tmp_path / f't{i}')) for i in range(n)] + barrier = threading.Barrier(n); roles = [None] * n + def work(i): + barrier.wait() + roles[i] = co.prepare(targets[i]) + ts = [threading.Thread(target=work, args=(i,)) for i in range(n)] + for t in ts: t.start() + for t in ts: t.join(8.0) + assert len(calls) == 1 and roles.count('use') == n + + +def test_failed_probe_lets_a_later_target_retry(tmp_path): + calls = [] + co = _coord(tmp_path, seed_fn=_probe(tmp_path, calls=calls, fail=True)) + assert co.prepare(_T(str(tmp_path / 'A'))) == 'none' + assert co.prepare(_T(str(tmp_path / 'B'))) == 'none' + assert len(calls) == 2 # re-elected, not stuck waiting on a dead fingerprint forever + + +def test_a_raising_probe_wakes_waiters(tmp_path): + # a probe that throws (disk full) must release blocked waiters, not hang them for wait_timeout + @contextlib.contextmanager + def boom(target): raise OSError('disk full') + co = _coord(tmp_path, seed_fn=boom) + out = {} + w = threading.Thread(target=lambda: out.__setitem__('role', co.prepare(_T(str(tmp_path / 'B'))))) + w.start(); w.join(3.0) + assert not w.is_alive() and out['role'] == 'none' + + +def test_status_reports_fingerprint_and_presence(tmp_path): + co = _coord(tmp_path) + a = _T(str(tmp_path / 'A')) + assert co.status(a) == ('FP', False) # nothing published yet + co.prepare(a) + assert co.status(a) == ('FP', True) + + +def test_new_fingerprint_reruns_the_probe(tmp_path): + fp = {'v': 'FP1'}; calls = [] + co = cc.Coordinator(str(tmp_path / 'cache'), fp_fn=lambda t: fp['v'], seed_fn=_probe(tmp_path, calls=calls), + paths_fn=lambda t: (t.build_dir, t.bfd, t.src)) + assert co.prepare(_T(str(tmp_path / 'A'))) == 'use' + fp['v'] = 'FP2' # toolchain (or stdlib) changed + assert co.prepare(_T(str(tmp_path / 'B'))) == 'use' + assert len(calls) == 2 # the old seed is not reused across fingerprints + + +def test_a_dead_compiler_invalidates_the_seed(tmp_path): + cl = tmp_path / 'cl.exe'; cl.write_text('') + calls = [] + co = cc.Coordinator(str(tmp_path / 'cache'), fp_fn=lambda t: 'FP', probe_fn=lambda t: str(cl), + seed_fn=_probe(tmp_path, calls=calls), + paths_fn=lambda t: (t.build_dir, t.bfd, t.src)) + assert co.prepare(_T(str(tmp_path / 'A'))) == 'use' + assert co.prepare(_T(str(tmp_path / 'B'))) == 'use' and len(calls) == 1 # probe alive -> reuse + cl.unlink() # toolset removed + assert co.prepare(_T(str(tmp_path / 'C'))) == 'none' # never hand out a dead seed + assert cc.load(co.seed_dir(_T(str(tmp_path / 'C')))) is None # ...and purge it + + +def test_heal_purges_seed(tmp_path): + co = _coord(tmp_path) + a = _T(str(tmp_path / 'A')) + co.prepare(a) + assert cc.load(co.seed_dir(a)) is not None + co.heal(a) + assert cc.load(co.seed_dir(a)) is None + + +def test_disabled_is_noop(tmp_path): + calls = [] + co = _coord(tmp_path, enabled=False, seed_fn=_probe(tmp_path, calls=calls)) + assert co.prepare(_T(str(tmp_path / 'x'))) == 'none' and not calls + + +def test_reprobes_when_seed_files_vanished(tmp_path): + # A concurrent heal can remove the toolchain files while the manifest lingers; prepare must never + # return a doomed 'use' - with a probe it just rebuilds the seed. + calls = [] + co = _coord(tmp_path, seed_fn=_probe(tmp_path, calls=calls)) + a = _T(str(tmp_path / 'A')) + co.prepare(a) + sd = co.seed_dir(a) + for f in os.listdir(sd): + if f != cc._MANIFEST: os.remove(os.path.join(sd, f)) # files gone, manifest stays + assert co.prepare(_T(str(tmp_path / 'B'))) == 'use' and len(calls) == 2 + + +def test_abi_flags_reach_the_probe_and_the_fingerprint(): + # the probe must detect with the same ABI inputs the real targets use, or its implicit link libs + # (libc++ vs libstdc++, a sanitizer runtime) describe a toolchain nobody is actually building with + from mama.cmake_configure import _abi_flags + cfg = SimpleNamespace(linux=True, clang=True, msvc=False, clang_stdlib='libstdc++', sanitize=None) + assert _abi_flags(cfg) == ('', '-stdlib=libstdc++') # -stdlib is C++-only: clang warns on C + cfg.sanitize = 'address' + assert _abi_flags(cfg) == ('-fsanitize=address', '-fsanitize=address -stdlib=libstdc++') + assert _abi_flags(SimpleNamespace(linux=True, clang=False, msvc=False, clang_stdlib='libc++', + sanitize=None)) == ('', '') # gcc: stdlib isn't a choice + + +def test_probe_cmd_carries_the_cross_toolchain(tmp_path, monkeypatch): + # Yocto/raspi inject the sysroot + cross binutils via the platform opts, NOT via the obvious + # CMAKE_*_COMPILER keys. A probe without them detects the HOST toolchain and publishes it for a + # cross fingerprint - every seeded cross target then links against host libs. + from mama import cmake_configure as cfg + platform = ['CMAKE_SYSROOT=/opt/sdk/sysroot', 'CMAKE_SYSTEM_NAME=Linux', 'CMAKE_AR=/opt/sdk/bin/aarch64-ar'] + monkeypatch.setattr(cfg, '_platform_opts', lambda t: list(platform)) + monkeypatch.setattr(cfg, '_set_compiler_paths', lambda t, o: o.append('CMAKE_C_COMPILER=/opt/sdk/bin/aarch64-gcc')) + monkeypatch.setattr(cfg, '_generator', lambda t: '-G "Ninja"') + monkeypatch.setattr(cfg, '_cmake_version_number', lambda c: '4.2.3') + seen = {} + def fake_run(cmd, cwd, env=None, io_func=None): seen['cmd'] = cmd; return 1 # fail: we only want the cmd + monkeypatch.setattr(cfg.SubProcess, 'run', staticmethod(fake_run)) + target = SimpleNamespace(cmake_command='cmake', config=SimpleNamespace( + verbose=False, linux=True, clang=False, msvc=False, clang_stdlib='libc++', sanitize=None)) + monkeypatch.setattr(cfg, 'compute_env', lambda t: {}) + with cfg._probe_toolchain(target) as paths: + assert paths is None + for opt in platform + ['CMAKE_C_COMPILER=/opt/sdk/bin/aarch64-gcc']: + assert f'-D{opt}' in seen['cmd'], f'probe dropped {opt}' + + +def test_an_sdk_move_changes_the_fingerprint(tmp_path, monkeypatch): + # Yocto SDKs keep the compiler path stable across upgrades but move the sysroot; if that doesn't + # reach the fingerprint, a cross build reuses a seed detected against the previous sysroot. + from mama import cmake_configure as cfg + opts = ['CMAKE_SYSTEM_NAME=Linux', 'CMAKE_SYSROOT=/opt/sdk-1.0/sysroot'] + monkeypatch.setattr(cfg, '_platform_opts', lambda t: list(opts)) + target = SimpleNamespace(cmake_opts=[]) + before = cc.compute_fingerprint(cfg._toolchain_inputs(target)) + opts[1] = 'CMAKE_SYSROOT=/opt/sdk-2.0/sysroot' + assert cc.compute_fingerprint(cfg._toolchain_inputs(target)) != before diff --git a/tests/test_compiler_cache/test_flag_isolation.py b/tests/test_compiler_cache/test_flag_isolation.py new file mode 100644 index 0000000..ab25b62 --- /dev/null +++ b/tests/test_compiler_cache/test_flag_isolation.py @@ -0,0 +1,71 @@ +"""Regression (real cmake): the synthetic C+CXX probe seeds single-language projects without poisoning +them - it transplants compiler detection only, never project flags. Skipped without cmake.""" +import os, glob, shutil, subprocess +import pytest +from mama import cmake_compiler_cache as cc +from mama.cmake_configure import _SEED_PROJECT + +pytestmark = pytest.mark.skipif(not shutil.which('cmake'), reason='needs a real cmake') + + +def _proj(d, name, langs='CXX'): + os.makedirs(d, exist_ok=True) + ext = 'cpp' if 'CXX' in langs else 'c' + open(os.path.join(d, 'CMakeLists.txt'), 'w').write( + f'cmake_minimum_required(VERSION 3.15)\nproject({name} {langs})\nadd_library({name} STATIC s.{ext})\n') + open(os.path.join(d, f's.{ext}'), 'w').write(f'int {name}(){{return 0;}}\n') + return d + + +def _configure(src, build, flags=''): + args = ['cmake'] + ([f'-DCMAKE_CXX_FLAGS={flags}'] if flags else []) + ['-S', src, '-B', build] + return subprocess.run(args, capture_output=True, text=True) + + +def _ver_dir(build, lang='CXX'): + hits = glob.glob(os.path.join(build, 'CMakeFiles', '*', f'CMake{lang}Compiler.cmake')) + return os.path.dirname(hits[0]) if hits else None + + +def _probe_seed(tmp_path): + """What _build_seed_probe does in production: configure a synthetic C+CXX project, publish it.""" + src = str(tmp_path / 'probe_src'); build = str(tmp_path / 'probe_build') + os.makedirs(src, exist_ok=True) + open(os.path.join(src, 'CMakeLists.txt'), 'w').write(_SEED_PROJECT) + r = _configure(src, build) + assert r.returncode == 0, r.stderr + ver = _ver_dir(build) + seed = str(tmp_path / 'seed') + assert cc.publish(seed, ver, build_dir=build), 'a C+CXX probe must be publishable' + return seed, os.path.basename(ver) + + +def _consume(seed, ver, src, build, flags=''): + cc.inject(seed, build, os.path.join(build, 'CMakeFiles', ver), src) + return _configure(src, build, flags) + + +def test_a_single_language_project_configures_from_the_probe_seed(tmp_path): + # The bug this design fixes: seeding used to copy whichever languages the first real target + # detected, so a C-only target seeding first left C++ projects with 'CMAKE_CXX_COMPILER not set'. + seed, ver = _probe_seed(tmp_path) + for name, langs in (('cxxonly', 'CXX'), ('conly', 'C')): + src = _proj(str(tmp_path / name), name, langs) + build = str(tmp_path / f'{name}_build') + r = _consume(seed, ver, src, build) + assert r.returncode == 0, f'{langs}-only project failed to configure: {r.stderr}' + assert 'identification is' not in r.stdout, 'seed should have skipped compiler detection' + assert subprocess.run(['cmake', '--build', build], capture_output=True).returncode == 0 + + +def test_the_seed_carries_no_project_flags(tmp_path): + seed, ver = _probe_seed(tmp_path) + cxx = open(os.path.join(seed, 'CMakeCXXCompiler.cmake')).read() + assert 'CMAKE_CXX_FLAGS' not in cxx # only toolchain detection is transplanted + + src = _proj(str(tmp_path / 'b'), 'bbb'); build = str(tmp_path / 'b_build') + r = _consume(seed, ver, src, build, flags='-DMARKER_B_ONLY') + assert r.returncode == 0, r.stderr + cache = open(os.path.join(build, 'CMakeCache.txt')).read() + assert 'MARKER_B_ONLY' in cache # B keeps its own flags + assert 'CMAKE_EXECUTABLE_FORMAT' in cache # ...and inherits the ABI facts the probe skipped detecting diff --git a/tests/test_compiler_scoped_dirs/test_compiler_mismatch_warning.py b/tests/test_compiler_scoped_dirs/test_compiler_mismatch_warning.py new file mode 100644 index 0000000..e423c25 --- /dev/null +++ b/tests/test_compiler_scoped_dirs/test_compiler_mismatch_warning.py @@ -0,0 +1,21 @@ +"""Pins the papa.txt compiler stamp check: warn on a foreign-compiler package, stay quiet otherwise.""" +from unittest.mock import Mock, patch +from mama import artifactory + + +def _mismatch_warnings(papa_compiler, current='gcc14.3'): + target = Mock(); target.name = 'libfoo' + target.config.compiler_version.return_value = current + warnings = [] + with patch('mama.artifactory.warning', side_effect=warnings.append): + artifactory._warn_on_compiler_mismatch(target, Mock(compiler=papa_compiler)) + return warnings + + +def test_a_package_built_by_another_compiler_warns(): + assert 'clang18.1' in _mismatch_warnings('clang18.1')[0] + + +def test_matching_or_unstamped_packages_stay_quiet(): + assert not _mismatch_warnings('gcc14.3') + assert not _mismatch_warnings(None) # pre-change package: unknown, allow it diff --git a/tests/test_compiler_scoped_dirs/test_compiler_scoped_dirs.py b/tests/test_compiler_scoped_dirs/test_compiler_scoped_dirs.py new file mode 100644 index 0000000..f2e09cc --- /dev/null +++ b/tests/test_compiler_scoped_dirs/test_compiler_scoped_dirs.py @@ -0,0 +1,37 @@ +"""Pins compiler-flip ordering: root locks + re-resolves, so no run scatters deps across linux/ and linux-clang/.""" +from mama.build_config import BuildConfig +from testutils import make_mock_dep + + +def _linux_cfg(): + c = BuildConfig([]) + c.msvc = c.macos = c.ios = c.android = c.raspi = False + c.mips = c.oclea = c.xilinx = c.imx8mp = c.yocto_linux = None + c.linux = True; c.arch = 'x64'; c.print = False + return c + + +def test_a_late_prefer_clang_cannot_flip_a_locked_compiler(): + c = _linux_cfg() + c.lock_compiler() + c.prefer_clang('some_dep') # dep settings() runs after the root decided + assert not c.clang + + +def test_the_root_may_still_pick_the_compiler_before_the_lock(): + c = _linux_cfg() + c.compiler_cmd = False # no explicit clang/gcc on the cmdline + c.prefer_clang('root') + c.lock_compiler() + assert c.clang and c.platform_build_dir_name() == 'linux-clang' + + +def test_dirs_re_resolve_after_the_compiler_flips(tmp_path): + # build_dir is computed at BuildTarget construction, before settings() reaches prefer_clang + dep = make_mock_dep(tmp_path) + dep.config.platform_build_dir_name.return_value = 'linux' + dep._update_dep_name_and_dirs(dep.name) + assert dep.build_dir.endswith('/linux') + dep.config.platform_build_dir_name.return_value = 'linux-clang' + dep._update_dep_name_and_dirs(dep.name) + assert dep.build_dir.endswith('/linux-clang') diff --git a/tests/test_configure_build_split/test_configure_build_split.py b/tests/test_configure_build_split/test_configure_build_split.py new file mode 100644 index 0000000..f1bd343 --- /dev/null +++ b/tests/test_configure_build_split/test_configure_build_split.py @@ -0,0 +1,218 @@ +"""Pins the configure/build split: phase ordering, no-op packaging, custom-build collapse, +thread-safe env, per-target -j, and generator-agnostic TU counting.""" +import os, sys, contextlib, shutil, subprocess +from types import SimpleNamespace +import pytest +from unittest.mock import patch +from testutils import make_mock_local_dep +from mama import cmake_configure as cc + + +def _target(tmp_path, **cfg): + sub = tmp_path / 'pkg'; sub.mkdir() + dep = make_mock_local_dep(tmp_path, src_dir=sub, jobs=8, **cfg) + dep.should_rebuild = True; dep.nothing_to_build = False; dep.from_artifactory = False + return dep.target, dep + + +def _wire(t, dep): + """Patch the cmake/package boundary + dep hooks to record call order in `ev`.""" + es = contextlib.ExitStack(); ev = [] + rec = lambda name: (lambda *a, **k: ev.append(name)) + es.enter_context(patch('mama.cmake_configure.run_config', side_effect=rec('run_config'))) + es.enter_context(patch('mama.cmake_configure.run_build', side_effect=rec('run_build'))) + es.enter_context(patch('mama.cmake_configure.inject_env', side_effect=rec('inject_env'))) + es.enter_context(patch('mama.package.clean_intermediate_files', side_effect=rec('clean'))) + es.enter_context(patch.object(t, 'try_automatic_artifactory_fetch', return_value=None)) + es.enter_context(patch.object(t, '_run_packaging', side_effect=rec('package'))) + es.enter_context(patch.object(dep, 'ensure_cmakelists_exists')) + es.enter_context(patch.object(dep, 'successful_build', side_effect=rec('successful_build'))) + return es, ev + + +def test_default_build_runs_configure_in_configure_phase_build_in_build_phase(tmp_path): + t, dep = _target(tmp_path) + es, ev = _wire(t, dep) + with es: + t.configure_phase() + assert ev == ['inject_env', 'run_config'] # configure half only + t.build_phase() + assert ev == ['inject_env', 'run_config', 'run_build', 'successful_build', 'clean', 'package'] + + +def test_noop_node_skips_cmake_but_still_packages(tmp_path): + t, dep = _target(tmp_path) + dep.nothing_to_build = True # header-only / no work + es, ev = _wire(t, dep) + with es: + t.configure_phase(); t.build_phase() + assert ev == ['package'] # packaging still runs, in dependency order + + +def test_custom_build_collapses_into_build_phase(tmp_path): + t, dep = _target(tmp_path) + es, ev = _wire(t, dep) + es.enter_context(patch.object(t, '_has_custom_build', return_value=True)) + es.enter_context(patch.object(t, 'build', side_effect=lambda: ev.append('user_build'))) + with es: + t.configure_phase() + assert ev == [] # custom build owns its own configure; configure_phase is a no-op + t.build_phase() + assert ev == ['user_build', 'successful_build', 'clean', 'package'] + assert 'run_config' not in ev and 'run_build' not in ev + + +def test_configure_runs_once_across_phases(tmp_path): + t, dep = _target(tmp_path) + es, _ = _wire(t, dep) + calls = [] + es.enter_context(patch.object(t, 'configure', side_effect=lambda: calls.append(1))) + with es: + t.configure_phase(); t.build_phase() # normal build configures in configure_phase + t._run_configure_once() # guard blocks any further configure() + assert calls == [1] + + +def test_custom_build_configures_exactly_once_in_build_phase(tmp_path): + # Custom build(): configure_phase is a no-op, so the once-guard must let build_phase's + # _run_configure_once() call configure() exactly once (and block any later call). + t, dep = _target(tmp_path) + es, _ = _wire(t, dep) + es.enter_context(patch.object(t, '_has_custom_build', return_value=True)) + es.enter_context(patch.object(t, 'build', side_effect=lambda: None)) + calls = [] + es.enter_context(patch.object(t, 'configure', side_effect=lambda: calls.append(1))) + with es: + t.configure_phase(); assert calls == [] # custom build must NOT configure in configure_phase + t.build_phase(); t._run_configure_once() + assert calls == [1] + + +def test_compute_env_strips_cc_cxx_without_mutating_global(tmp_path, monkeypatch): + t, _ = _target(tmp_path) + t.config.get_preferred_compiler_paths = lambda: ('gcc', 'g++', '11') + monkeypatch.setenv('CC', 'x'); monkeypatch.setenv('CXX', 'y') + env = cc.compute_env(t) + assert 'CC' not in env and 'CXX' not in env + assert os.environ['CC'] == 'x' and os.environ['CXX'] == 'y' # global env untouched (thread-safe) + + +def test_per_target_jobs_flow_into_j_flag_without_touching_config(tmp_path): + t, dep = _target(tmp_path) # config.jobs = 8, linux + t._build_jobs = 3 + assert cc._mp_flags(t) == '-j3' and t.config.jobs == 8 + t._build_jobs = None + assert cc._mp_flags(t) == '-j8' # falls back to config.jobs + dep.is_root = True; t._build_jobs = 3 + assert cc._mp_flags(t) == '-j8' # root ignores the per-target TU sizing, runs at full config.jobs + + +def test_configure_phase_sizes_build_weight_from_tu_count(tmp_path): + # Regression: configure_phase must set _build_jobs from the TU probe; left None every build + # would reserve the whole budget and run one-at-a-time. + t, dep = _target(tmp_path) # config.jobs = 8 + with open(t.build_dir('compile_commands.json'), 'w') as f: + f.write('[{"file":"a"},{"file":"b"},{"file":"c"}]') + es, _ = _wire(t, dep) + with es: + t.configure_phase() + assert t._build_jobs == 3 # small package -> small weight -> many such builds run concurrently + + +def test_probe_build_jobs_counts_tus_across_generators_and_falls_back(tmp_path): + t, _ = _target(tmp_path) # config.jobs = 8, source tree empty + ccj, vcx = t.build_dir('compile_commands.json'), t.build_dir('app.vcxproj') + with open(ccj, 'w') as f: f.write('[{"file":"a"},{"file":"b"},{"file":"c"}]') + assert t._probe_build_jobs() == 3 # Ninja/Make: compile_commands.json + with open(ccj, 'w') as f: f.write('"file"' * 100) + assert t._probe_build_jobs() == 8 # capped at config.jobs + os.remove(ccj) + with open(vcx, 'w') as f: + f.write('\n\nsettings') + assert t._probe_build_jobs() == 2 # Visual Studio: .vcxproj (settings block not counted) + os.remove(vcx) + di = t.build_dir('CMakeFiles/t.dir'); os.makedirs(di) # Unix Makefiles: DependInfo.cmake, one object per TU + with open(os.path.join(di, 'DependInfo.cmake'), 'w') as f: + f.write('"/s/a.cpp" "CMakeFiles/t.dir/a.cpp.o" "gcc" "CMakeFiles/t.dir/a.cpp.o.d"\n' + '"/s/b.cpp" "CMakeFiles/t.dir/b.cpp.o" "gcc" "CMakeFiles/t.dir/b.cpp.o.d"\n') + assert t._probe_build_jobs() == 2 # .o.d depfile entries must NOT be double-counted + shutil.rmtree(t.build_dir('CMakeFiles')) + for rel in ('a.cpp', 'b.cc', 'h.hpp', 'build/gen.cpp'): # cross-platform fallback: count C/C++ sources + p = os.path.join(t.source_dir(), rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').close() + assert t._probe_build_jobs() == 2 # header + build/ tree skipped, only a.cpp + b.cc + for rel in ('a.cpp', 'b.cc'): os.remove(os.path.join(t.source_dir(), rel)) + assert t._probe_build_jobs() == 0 # nothing countable -> 0 reserve (no budget slots) + + +def test_reserved_cores_is_full_build_jobs_capped_at_total(tmp_path): + t, _ = _target(tmp_path) # config.jobs = 8 + t._build_jobs = 40; assert t._reserved_cores() == 8 # heavy build reserves the FULL pool (== its -j) + t._build_jobs = 3; assert t._reserved_cores() == 3 # small build keeps its -j + t._build_jobs = 0; assert t._reserved_cores() == 0 # unsizable -> reserves nothing + t._build_jobs = None # unset (serial path / sched_debug): probe once + memoize + with open(t.build_dir('compile_commands.json'), 'w') as f: f.write('[{"file":"a"},{"file":"b"}]') + assert t._reserved_cores() == 2 and t._build_jobs == 2 + + +def test_toolchain_inputs_cover_the_cross_setup_but_never_project_flags(tmp_path, monkeypatch): + # The fingerprint must invalidate on a toolchain/sysroot change (an SDK move keeps the compiler path + # but changes CMAKE_SYSROOT) yet NOT on project flags - else a flag tweak redoes detection, or a + # sysroot swap reuses a seed detected against the old one. + tc = tmp_path / 'arm.toolchain.cmake'; tc.write_bytes(b'set(SYSROOT /opt/arm)\n') + platform = [f'CMAKE_TOOLCHAIN_FILE="{tc}"', 'CMAKE_SYSTEM_NAME=Linux', 'CMAKE_SYSROOT=/opt/sdk/sysroot', + 'CMAKE_AR=/opt/sdk/bin/aarch64-ar'] + monkeypatch.setattr(cc, '_platform_opts', lambda t: list(platform)) + target = SimpleNamespace(cmake_opts=['CMAKE_CXX_FLAGS="-DX"', 'FOO=bar']) + out = cc._toolchain_inputs(target) + assert out['CMAKE_SYSTEM_NAME'] == 'Linux' + assert out['CMAKE_SYSROOT'] == '/opt/sdk/sysroot' # an SDK move must change the fingerprint + assert out['CMAKE_AR'] == '/opt/sdk/bin/aarch64-ar' # cross binutils shape detection too + assert out['CMAKE_TOOLCHAIN_FILE']['size'] == tc.stat().st_size # stat'd: an edit in place invalidates + assert 'CMAKE_CXX_FLAGS' not in out and 'FOO' not in out # project flags never enter the fingerprint + + +def _cmake_tu_count(t, generator): + """Generate a real 3-TU CMake project with `generator` (no export -> no compile_commands.json) + so the probe must use the generator's native artifacts (.vcxproj / DependInfo.cmake).""" + src, bld = t.source_dir(), t.build_dir() + with open(os.path.join(src, 'CMakeLists.txt'), 'w') as f: + f.write('cmake_minimum_required(VERSION 3.16)\nproject(t CXX)\nadd_library(t a.cpp b.cpp c.cpp)\n') + for n in ('a', 'b', 'c'): open(os.path.join(src, f'{n}.cpp'), 'w').write(f'int {n}(){{return 0;}}\n') + gen = [] if generator is None else ['-G', generator] + subprocess.run(['cmake', '-S', src, '-B', bld, *gen], check=True, capture_output=True, timeout=180) + return t._probe_build_jobs() + + +@pytest.mark.skipif(not shutil.which('cmake'), reason='needs cmake') +@pytest.mark.skipif(sys.platform != 'linux', reason='Unix Makefiles is the Linux default generator') +def test_probe_counts_real_unix_makefiles_tus(tmp_path): + assert _cmake_tu_count(_target(tmp_path)[0], 'Unix Makefiles') == 3 # via DependInfo.cmake + + +@pytest.mark.skipif(not shutil.which('cmake'), reason='needs cmake') +@pytest.mark.skipif(sys.platform != 'win32', reason='Visual Studio generator is Windows-only') +def test_probe_counts_real_visualstudio_tus(tmp_path): + assert _cmake_tu_count(_target(tmp_path)[0], None) == 3 # default Windows generator (VS) -> .vcxproj + + +def test_cmake_defines_survive_a_windows_path(tmp_path): + # SubProcess shlex-splits the command, which eats backslashes: a mamafile passing a raw Windows + # path through add_cmake_options() used to arrive as C:ProjectsGCSpackagesprotobufwindowsbinprotoc.exe + import shlex + opt = r'PROTOC_EXECUTABLE=C:/proj/packages/protobuf\windows\bin\protoc.exe' + defines = cc._opts_to_defines([opt]) + assert defines.strip() == '-DPROTOC_EXECUTABLE=C:/proj/packages/protobuf/windows/bin/protoc.exe' + assert shlex.split(f'cmake {defines}')[1].endswith('/bin/protoc.exe') # survives the split intact + + +def test_cmake_defines_leave_msvc_flags_alone(): + assert cc._opts_to_defines(['CMAKE_CXX_FLAGS=/EHsc /MP']).strip() == '-DCMAKE_CXX_FLAGS=/EHsc /MP' + + +def test_cmake_defines_only_rewrite_path_shaped_backslashes(): + # a literal separator or an escaped quote is not a path: rewriting those changes what the compiler sees + assert cc._opts_to_defines([r'SEP=\\']).strip() == r'-DSEP=\\' + assert cc._opts_to_defines([r'NAME=\"q\"']).strip() == r'-DNAME=\"q\"' + assert cc._opts_to_defines([r'P=C:\a\b.exe']).strip() == '-DP=C:/a/b.exe' diff --git a/tests/test_git_local_mods/test_git_local_mods.py b/tests/test_git_local_mods/test_git_local_mods.py new file mode 100644 index 0000000..727abe3 --- /dev/null +++ b/tests/test_git_local_mods/test_git_local_mods.py @@ -0,0 +1,31 @@ +"""Pins the update guard: a dirty working tree fails `mama update` loudly (marked `x`) even when +upstream is unchanged, instead of a swallowed pull error leaving the dep silently un-updated.""" +from unittest.mock import patch + +import pytest +from testutils import make_mock_dep + +from mama.build_dependency import BuildDependency +from mama.types.git import Git + + +def test_ensure_no_local_modifications_raises_actionable(tmp_path): + dep = make_mock_dep(tmp_path) + with patch.object(Git, '_has_local_modifications', return_value=True), \ + patch.object(Git, 'run_git') as run_git: # the `git status --porcelain` it shows the user + with pytest.raises(RuntimeError, match='mama wipe'): + dep.dep_source._ensure_no_local_modifications(dep) + run_git.assert_called_once() + + +def test_update_fails_on_dirty_tree_even_without_upstream_change(tmp_path): + dep = make_mock_dep(tmp_path, update=True) + dep.config.target_matches.return_value = True + with patch.object(BuildDependency, 'is_real_clone', return_value=True), \ + patch.object(Git, '_sync_remote_url'), \ + patch.object(Git, '_has_local_modifications', return_value=True), \ + patch.object(Git, 'run_git'), \ + patch.object(Git, 'check_status') as check_status: + with pytest.raises(RuntimeError, match='local modifications'): + dep.dep_source.dependency_checkout(dep) + check_status.assert_not_called() # failed BEFORE the pull whose error the fetch fallback would swallow diff --git a/tests/test_git_pinning/test_git_pinning.py b/tests/test_git_pinning/test_git_pinning.py index 34bb3ed..275bf00 100644 --- a/tests/test_git_pinning/test_git_pinning.py +++ b/tests/test_git_pinning/test_git_pinning.py @@ -6,7 +6,7 @@ def remote_file_contains(dep_name, text): # Make sure different git pinning methods work def test_git_pinning(): init(__file__, clean_dirs=['packages']) - mama_exec(['clean']) + mama_exec(['update']) # clone the pinned deps; `clean` deliberately fetches nothing # https://github.com/BatteredBunny/MamaExampleRemote repo has different commits that either do or dont have the REMOTE_VERSION line assert not remote_file_contains('ExampleRemote', 'REMOTE_VERSION'), "Tag pinning went wrong" diff --git a/tests/test_git_url_override/test_git_url_override.py b/tests/test_git_url_override/test_git_url_override.py index 1e8da02..afb16b4 100644 --- a/tests/test_git_url_override/test_git_url_override.py +++ b/tests/test_git_url_override/test_git_url_override.py @@ -1,14 +1,16 @@ -"""Pins `https-override` / `ssh-override`: ssh<->https url rewriting and that a -protocol-only override is not treated as a url change (no spurious wipe).""" -from unittest.mock import patch +"""Pins git.py helpers: ssh<->https url rewriting (protocol-only override is not a url change, no +spurious wipe) and the update-output noise filter.""" +import contextlib +from unittest.mock import Mock, patch import pytest from testutils import make_mock_dep -from mama.types.git import Git, convert_git_url, same_git_remote +from mama.types.git import Git, convert_git_url, same_git_remote, _is_git_status_noise +from mama.util import git_progress_status -GH_SSH = 'git@github.com:KrattWorks/mavlink-headers.git' -GH_HTTPS = 'https://github.com/KrattWorks/mavlink-headers.git' +GH_SSH = 'git@github.com:example/mavlink-headers.git' +GH_HTTPS = 'https://github.com/example/mavlink-headers.git' @pytest.mark.parametrize('url,expected', [ @@ -38,7 +40,7 @@ def test_to_ssh(url, expected): def test_same_remote_ignores_protocol_creds_and_suffix(): assert same_git_remote(GH_SSH, GH_HTTPS) assert same_git_remote('https://token@github.com/x/y', 'git@github.com:x/y.git') - assert not same_git_remote(GH_HTTPS, 'https://github.com/KrattWorks/other.git') + assert not same_git_remote(GH_HTTPS, 'https://github.com/example/other.git') def test_apply_url_override_rewrites_dep_url(tmp_path): @@ -53,6 +55,58 @@ def test_no_override_leaves_url(tmp_path): assert not dep.dep_source.url_overridden +@pytest.mark.parametrize('line', [ + "Reset branch 'main'", "branch 'main' set up to track 'origin/main'.", + "Your branch is up to date with 'origin/main'.", 'Already up to date.', "Switched to branch 'main'", + 'HEAD is now at 98f23d8 QCoro 0.13.0', # post reset/checkout chatter from a parallel-mode git checkout + "Your configuration specifies to merge with the ref 'refs/heads/x'", 'from the remote, but no such ref was fetched.']) +def test_update_noise_is_filtered(line): + assert _is_git_status_noise(line) + + +@pytest.mark.parametrize('line', [ + 'error: pathspec broke', 'remote: Enumerating objects: 12, done.', "fatal: couldn't find remote ref x"]) +def test_real_git_output_is_kept(line): + assert not _is_git_status_noise(line) + + +def test_git_progress_status_classifies_transfer_lines(): + assert git_progress_status('Receiving objects: 42% (5/12)') == ('receiving objects ', 42) + assert git_progress_status('remote: Counting objects: 100% (30/30), done.')[1] == 100 + assert git_progress_status(' * [new branch] main -> origin/main') is None # a real ref line, not progress + assert git_progress_status('From https://github.com/RedFox20/ReCpp') is None + + +def test_is_progress_line_matches_git_and_download_bars(): + from mama.util import is_progress_line + assert is_progress_line('remote: Counting objects: 10% (29/290)') # git clone output + assert is_progress_line(' ReCpp receiving objects 42%') # mama's collapsed git redraw + assert is_progress_line(' |=====<-----| 42% (1.2s)') # mama's artifactory download bar + assert is_progress_line('x264 45% [====> ] 3.4MB/s') # a custom downloader's bar + assert not is_progress_line('remote: Enumerating objects: 290, done.') # no %, kept verbatim + assert not is_progress_line('[3/74] Building CXX object foo.cpp.o') # a real build line + + +def test_run_git_collapses_progress_flood_but_keeps_real_lines(tmp_path): + # The regression: run_git printed every per-percent progress line raw; now it collapses them. + dep = make_mock_dep(tmp_path, print=True) + flood = [f'Receiving objects: {p}% ({p}/100)' for p in range(101)] + real = ['From https://github.com/RedFox20/ReCpp', ' * [new branch] main -> origin/main'] + consoled, progressed = [], [] + def fake_run(cmd, io_func=None, **kw): + for ln in flood + real: io_func(Mock(), ln) + return 0 + with patch('mama.types.git.SubProcess.run', side_effect=fake_run), \ + patch('mama.types.git.console', side_effect=lambda t, **k: consoled.append(t)), \ + patch('mama.types.git.progress', side_effect=lambda t, **k: progressed.append(t)), \ + patch('mama.types.git.ssh_multiplex.ensure_master_for_url'), \ + patch('mama.types.git.ssh_multiplex.fetch_slot', side_effect=lambda: contextlib.nullcontext()): + dep.dep_source.run_git(dep, 'fetch --unshallow') + assert not any('Receiving objects' in c for c in consoled) # no raw per-percent flood on the console + assert len(progressed) <= 5 # collapsed into a few throttled redraws + assert any('new branch' in c for c in consoled) # real ref output still shown + + def test_check_status_override_is_not_url_change(tmp_path): """Stored ssh url vs overridden https url is the same repo -> no wipe.""" dep = make_mock_dep(tmp_path, url=GH_SSH, git_url_override='https') diff --git a/tests/test_hierarchical_libs/test_hierarchical_libs.py b/tests/test_hierarchical_libs/test_hierarchical_libs.py new file mode 100644 index 0000000..a473e4a --- /dev/null +++ b/tests/test_hierarchical_libs/test_hierarchical_libs.py @@ -0,0 +1,144 @@ +"""Pins _get_hierarchical_libs: a shared dep contributes its libs ONCE (not once per path through the +graph) and keeps Unix link order - every lib appears after everything that references it.""" +from types import SimpleNamespace + +from mama import dependency_chain as dc + + +def _dep(name, libs=(), syslibs=(), children=()): + target = SimpleNamespace(exported_libs=list(libs), exported_syslibs=list(syslibs), + android=False, linux=True, raspi=False, mips=False, yocto_linux=False, + msvc=False, macos=False, ios=False) + return SimpleNamespace(name=name, target=target, get_children=lambda: list(children)) + + +def test_diamond_dependency_contributes_its_libs_once(): + c = _dep('C', libs=['libC.a']) + a = _dep('A', libs=['libA.a'], children=[c]) + b = _dep('B', libs=['libB.a'], children=[c]) + root = _dep('R', libs=['libR.a'], children=[a, b]) + libs = dc._get_hierarchical_libs(root) + assert libs == ['libR.a', 'libA.a', 'libB.a', 'libC.a'] + assert libs.index('libC.a') > libs.index('libA.a') # shared dep sinks BELOW its users: Unix link order + + +def test_shared_leaf_is_not_repeated_once_per_path(): + # a dep reachable through many parents: a raw per-path DFS emitted it once + # per path - 85 copies on one real link line. + leaf = _dep('ReCpp', libs=['libReCpp.a']) + mids = [_dep(f'M{i}', libs=[f'libM{i}.a'], children=[leaf]) for i in range(5)] + root = _dep('root', libs=['libroot.a'], children=mids) + libs = dc._get_hierarchical_libs(root) + assert libs.count('libReCpp.a') == 1 + assert libs[-1] == 'libReCpp.a' # last, because every M references it + + +def test_syslibs_follow_the_static_libs(): + c = _dep('C', libs=['libC.a'], syslibs=['pthread']) + root = _dep('R', libs=['libR.a'], syslibs=['dl'], children=[c]) + assert dc._get_hierarchical_libs(root) == ['libR.a', 'libC.a', 'dl', 'pthread'] + + +def test_deeper_shared_dep_sinks_below_every_user(): + # A -> B -> D and A -> C -> D: D must trail both branches, not sit where it was first seen. + d = _dep('D', libs=['libD.a']) + b = _dep('B', libs=['libB.a'], children=[d]) + c = _dep('C', libs=['libC.a'], children=[d]) + root = _dep('R', libs=['libR.a'], children=[b, c]) + libs = dc._get_hierarchical_libs(root) + assert libs == ['libR.a', 'libB.a', 'libC.a', 'libD.a'] + + +def test_non_library_exports_are_still_filtered_out(): + # _get_exported_libs keeps only linkable artifacts on linux-like targets + root = _dep('R', libs=['libR.a', 'notes.txt', 'libR.so', 'libR.so.1.2.3']) + assert dc._get_hierarchical_libs(root) == ['libR.a', 'libR.so', 'libR.so.1.2.3'] + + +def test_dep_shared_by_exe_and_a_lib_lands_after_both(): + # The classic Unix trap: exe -> {libz, lib1} and lib1 -> libz. Emitting libz where it was FIRST + # seen gives 'libz lib1'; ld then drops libz members lib1 needs -> undefined references. Keep-last + # ordering emits 'lib1 libz', resolving exe's AND lib1's libz symbols with ONE copy of libz. + libz = _dep('libz', libs=['libz.a']) + lib1 = _dep('lib1', libs=['lib1.a'], children=[libz]) + exe = _dep('exe', libs=['exe.a'], children=[libz, lib1]) # libz declared FIRST: the risky order + assert dc._get_hierarchical_libs(exe) == ['exe.a', 'lib1.a', 'libz.a'] + + +def test_link_order_is_independent_of_declaration_order(): + libz = _dep('libz', libs=['libz.a']) + lib1 = _dep('lib1', libs=['lib1.a'], children=[libz]) + exe = _dep('exe', libs=['exe.a'], children=[lib1, libz]) # libz declared LAST + assert dc._get_hierarchical_libs(exe) == ['exe.a', 'lib1.a', 'libz.a'] + + +# -- real-toolchain validation ------------------------------------------------ +# The unit tests above assert an ORDER; these prove that order is the one GNU ld actually needs, by +# building real static archives and linking them. +import shutil, subprocess # noqa: E402 +import pytest # noqa: E402 + +_CC = shutil.which('cc') or shutil.which('gcc') +_needs_toolchain = pytest.mark.skipif(not (_CC and shutil.which('ar')), + reason='needs a C toolchain (cc/gcc + ar) to build real archives') + +# z_func and z_shared MUST be separate translation units: an archive member is pulled only when it +# resolves a currently-undefined symbol, and that granularity is the whole point of the ordering rule. +# Both splits return 0 from main(), so a successful link is also semantically verifiable by running it. +_SYMBOL_SPLITS = [('z_shared', 'z_func'), # main pulls z_shared, lib1 pulls z_func + ('z_func', 'z_shared')] # crossed: swapping WHICH symbol each consumer uses + + +def _sources(main_calls, lib1_calls): + return { + 'z_func.c': 'int z_func(void) { return 1; }\n', + 'z_shared.c': 'int z_shared(void) { return 2; }\n', + 'one.c': f'int {lib1_calls}(void);\nint one_func(void) {{ return {lib1_calls}() + 10; }}\n', + 'main.c': f'int one_func(void);\nint {main_calls}(void);\n' + f'int main(void) {{ return one_func() + {main_calls}() - 13; }}\n', + } + + +def _build_archives(d, main_calls, lib1_calls): + """exe -> {libz, lib1} and lib1 -> libz, as real .a files.""" + def run(*args): + r = subprocess.run(args, cwd=str(d), capture_output=True, text=True) + assert r.returncode == 0, r.stderr + sources = _sources(main_calls, lib1_calls) + for name, src in sources.items(): (d / name).write_text(src) + for name in sources: run(_CC, '-c', name) + run('ar', 'rcs', 'libz.a', 'z_func.o', 'z_shared.o') + run('ar', 'rcs', 'lib1.a', 'one.o') + + +def _link(d, libs): + return subprocess.run([_CC, 'main.o', *libs, '-o', 'app'], cwd=str(d), capture_output=True, text=True) + + +def _exe_graph(): + libz = _dep('libz', libs=['libz.a']) + lib1 = _dep('lib1', libs=['lib1.a'], children=[libz]) + return _dep('exe', children=[libz, lib1]) # an executable exports no libs of its own + + +@_needs_toolchain +@pytest.mark.parametrize('main_calls,lib1_calls', _SYMBOL_SPLITS) +def test_real_link_fails_with_the_naive_first_seen_order(tmp_path, main_calls, lib1_calls): + _build_archives(tmp_path, main_calls, lib1_calls) + r = _link(tmp_path, ['libz.a', 'lib1.a']) # libz emitted where it was FIRST seen + assert r.returncode != 0 + # ld had already passed libz.a when lib1 introduced its need, so the unresolved symbol is always + # whatever LIB1 wanted - main's own need was satisfied by that early scan. Which symbol that is + # doesn't change the outcome: the rule is about dependency direction, not symbol distribution. + assert lib1_calls in r.stderr + + +@_needs_toolchain +@pytest.mark.parametrize('main_calls,lib1_calls', _SYMBOL_SPLITS) +def test_real_link_succeeds_with_the_order_mama_emits(tmp_path, main_calls, lib1_calls): + _build_archives(tmp_path, main_calls, lib1_calls) + order = dc._get_hierarchical_libs(_exe_graph()) # not hardcoded: whatever mama actually computes + assert order == ['lib1.a', 'libz.a'] + r = _link(tmp_path, order) + assert r.returncode == 0, r.stderr # ONE copy of libz satisfies exe AND lib1 + assert subprocess.run([str(tmp_path / 'app')]).returncode == 0 # ...and resolved to the RIGHT defs diff --git a/tests/test_log_writer/test_log_writer.py b/tests/test_log_writer/test_log_writer.py new file mode 100644 index 0000000..9ff8a98 --- /dev/null +++ b/tests/test_log_writer/test_log_writer.py @@ -0,0 +1,40 @@ +"""Pins AsyncLogWriter: queued writes reach the stream in order, ANSI stripped, amortized idle-flush, +close() drains.""" +import time +from mama.utils.log_writer import AsyncLogWriter + + +class _Cap: + def __init__(self): self.data = []; self.flushes = 0; self.closed = False + def write(self, s): self.data.append(s) + def flush(self): self.flushes += 1 + def close(self): self.closed = True + + +def test_drains_in_order_strips_ansi_and_closes(): + cap = _Cap() + w = AsyncLogWriter(cap) + w.write('\x1b[31mred error\x1b[0m\n'); w.write('plain\n') + w.close() # enqueues the sentinel, joins the drain thread, flushes+closes the stream + assert ''.join(cap.data) == 'red error\nplain\n' # colours stripped, order preserved + assert cap.closed + + +def test_write_never_raises_on_a_broken_stream(): + class _Broken: + def write(self, s): raise OSError('disk full') + def flush(self): pass + def close(self): pass + w = AsyncLogWriter(_Broken()) + w.write('x\n'); w.close() # the drain swallows the OSError; the build must not crash + + +def test_flushes_on_idle_without_waiting_for_close(): + cap = _Cap() + w = AsyncLogWriter(cap, flush_interval=0.02) + w.write('confirmed sequential output\n') # not close()d yet + end = time.monotonic() + 2.0 + while cap.flushes == 0 and time.monotonic() < end: time.sleep(0.01) + assert cap.flushes >= 1 # amortized: flushed on the idle lull, not only at close + assert 'confirmed sequential output' in ''.join(cap.data) + w.close() diff --git a/tests/test_main_dispatch/test_main_dispatch.py b/tests/test_main_dispatch/test_main_dispatch.py new file mode 100644 index 0000000..b2da806 --- /dev/null +++ b/tests/test_main_dispatch/test_main_dispatch.py @@ -0,0 +1,30 @@ +"""Pins which execution path mamabuild picks, and that both paths leave the shared locals defined.""" +from types import SimpleNamespace +import pytest +from mama.main import _can_unify + + +def _cfg(**over): + c = SimpleNamespace(serial_load=False, build=True, update=False, list=False, deps_only=False, + dirty=False, mama_init=False, target=None) + c.no_specific_target = lambda: c.target in (None, 'all') + for k, v in over.items(): setattr(c, k, v) + return c + + +def test_a_plain_full_build_unifies(): + assert _can_unify(_cfg()) + assert _can_unify(_cfg(target='all')) # `all` is not a specific target + assert _can_unify(_cfg(build=False, update=True)) + + +@pytest.mark.parametrize('flags', [{'list': True}, {'deps_only': True}, {'dirty': True}, + {'mama_init': True}, {'serial_load': True}, {'target': 'ReCpp'}]) +def test_paths_that_need_the_loaded_tree_do_not_unify(flags): + # each of these reads the fully-resolved tree (target lookup, filtering, listing) that only the + # classic path builds up front + assert not _can_unify(_cfg(**flags)) + + +def test_nothing_to_do_does_not_unify(): + assert not _can_unify(_cfg(build=False, update=False)) diff --git a/tests/test_msvc_toolset/test_msvc_toolset.py b/tests/test_msvc_toolset/test_msvc_toolset.py new file mode 100644 index 0000000..ff832b1 --- /dev/null +++ b/tests/test_msvc_toolset/test_msvc_toolset.py @@ -0,0 +1,31 @@ +"""Pins MSVC toolset selection: newest version with a live cl.exe, not os.listdir order.""" +from mama.build_config import BuildConfig + +def _toolset(root, ver, with_cl=True): + d = root / ver / 'bin' / 'Hostx64' / 'x64'; d.mkdir(parents=True) + if with_cl: (d / 'cl.exe').write_text('') + + +def test_picks_newest_version_numerically(tmp_path): + for v in ('14.44.35207', '14.51.36231', '14.9.0'): # 14.51 > 14.9 numerically (lexically 14.9 would win) + _toolset(tmp_path, v) + assert BuildConfig._latest_msvc_toolset(str(tmp_path)).endswith('14.51.36231') + + +def test_skips_newest_when_its_cl_was_removed_by_an_upgrade(tmp_path): + _toolset(tmp_path, '14.51.36231', with_cl=False) # dir left behind without binaries + _toolset(tmp_path, '14.44.35207') + assert BuildConfig._latest_msvc_toolset(str(tmp_path)).endswith('14.44.35207') + + +def test_empty_or_missing_root_returns_empty(tmp_path): + assert BuildConfig._latest_msvc_toolset(str(tmp_path / 'nope')) == '' + (tmp_path / 'empty').mkdir() + assert BuildConfig._latest_msvc_toolset(str(tmp_path / 'empty')) == '' + + +def test_a_toolset_without_cl_is_rejected_not_returned(tmp_path): + # every get_msvc_* path is bin/Hostx64/x64, so handing back a dir without cl.exe only moves the + # failure somewhere more confusing - get_msvc_tools_path() raises 'Could not detect MSVC Tools' + (tmp_path / '14.51.36112' / 'bin' / 'Hostx86' / 'x86').mkdir(parents=True) + assert BuildConfig._latest_msvc_toolset(str(tmp_path)) == '' diff --git a/tests/test_papa_parse/test_papa_parse.py b/tests/test_papa_parse/test_papa_parse.py index a72319d..5a7af57 100644 --- a/tests/test_papa_parse/test_papa_parse.py +++ b/tests/test_papa_parse/test_papa_parse.py @@ -27,3 +27,17 @@ def test_papa_parse(): assert len(papa.syslibs) == 0 assert len(papa.assets) == 0 + + +def test_compiler_record_round_trips(tmp_path): + papa = tmp_path / 'papa.txt' + papa.write_text('P Example\nC gcc14.3\nI include\n') + assert PapaFileInfo(str(papa)).compiler == 'gcc14.3' + + +def test_a_package_without_a_compiler_record_still_loads(tmp_path): + # pre-change packages have no C record: unknown must not read as mismatch + papa = tmp_path / 'papa.txt' + papa.write_text('P Example\nI include\n') + info = PapaFileInfo(str(papa)) + assert info.compiler is None and info.project_name == 'Example' diff --git a/tests/test_parallel_load/test_parallel_load.py b/tests/test_parallel_load/test_parallel_load.py new file mode 100644 index 0000000..e0783a8 --- /dev/null +++ b/tests/test_parallel_load/test_parallel_load.py @@ -0,0 +1,51 @@ +"""Pins thread-safety of parallel dependency loading: concurrent add_child dedups a shared (diamond) +dep to one instance; concurrent load() runs the dep's body exactly once.""" +import threading, time +from testutils import make_mock_config, make_mock_dep, make_mock_local_dep +from mama.build_dependency import BuildDependency +from mama.types.local_source import LocalSource +from mama.types.git import Git + + +def _root(config, tmp_path, name): + sub = tmp_path / name; sub.mkdir() + return BuildDependency(None, config, 'packages', LocalSource(name, str(sub), None, False, [])) + + +def test_concurrent_add_child_dedups_diamond_dep(tmp_path): + config = make_mock_config(tmp_path) + p1 = _root(config, tmp_path, 'p1'); p2 = _root(config, tmp_path, 'p2') + barrier = threading.Barrier(2); got = {} + def add(p, key): + barrier.wait() + got[key] = p.add_child(Git('shared', 'https://x/shared.git', 'main', '', None, True, [])) + ts = [threading.Thread(target=add, args=(p, k)) for p, k in ((p1, 1), (p2, 2))] + for t in ts: t.start() + for t in ts: t.join(5) + assert len(config.loaded_dependencies) == 1 # one shared instance; no race-created duplicate + assert got[1] is got[2] and p1.children[0] is p2.children[0] + + +def test_concurrent_load_runs_body_once(tmp_path, monkeypatch): + dep = _root(make_mock_config(tmp_path), tmp_path, 'x') + calls = [] + def fake_load(): + calls.append(1); time.sleep(0.05) + dep.already_loaded = True; dep.should_rebuild = False + return False + monkeypatch.setattr(dep, '_load', fake_load) + barrier = threading.Barrier(3) + def go(): barrier.wait(); dep.load() + ts = [threading.Thread(target=go) for _ in range(3)] + for t in ts: t.start() + for t in ts: t.join(5) + assert len(calls) == 1 # 3 concurrent load() calls -> _load body ran exactly once + + +def test_display_load_action_labels_by_source(tmp_path): + git = make_mock_dep(tmp_path, name='gitdep'); git.load_action = 'pulling' + assert git._display_load_action(loaded_from_pkg=False) == 'pulling' # git keeps its action -> G + assert git._display_load_action(loaded_from_pkg=True) == 'artifactory' # served from artifactory -> A + src = tmp_path / 'localsrc'; src.mkdir() + local = make_mock_local_dep(tmp_path, str(src), name='localdep') + assert local._display_load_action(loaded_from_pkg=False) == 'local' # local source -> L diff --git a/tests/test_parallel_runner/test_parallel_runner.py b/tests/test_parallel_runner/test_parallel_runner.py new file mode 100644 index 0000000..7d01b9e --- /dev/null +++ b/tests/test_parallel_runner/test_parallel_runner.py @@ -0,0 +1,216 @@ +"""Pins execute_task_chain_parallel: child builds before parent configures, serial deploy/run +post-pass, and fail-fast that stops dependents and exits.""" +import threading +from types import SimpleNamespace +import pytest +from testutils import FakeBuildTarget, strip_ansi +from mama import dependency_chain as dc + + +class _T(FakeBuildTarget): + def __init__(self, dep, ev, lock, fail=False): + self.dep = dep; self.ev = ev; self.lock = lock; self.fail = fail + def _rec(self, name): + with self.lock: self.ev.append(name) + def configure_phase(self, out=None): self._rec(('configure', self.dep.name)) + def build_phase(self, out=None): + self._rec(('build', self.dep.name)) + if getattr(self, 'warn', None) and out: out(self.warn) # emit a captured diagnostic, then maybe fail + if self.fail: raise RuntimeError('boom ' + self.dep.name) + def _execute_deploy_tasks(self): self._rec(('deploy', self.dep.name)) + def _execute_run_tasks(self): self._rec(('run', self.dep.name)) + + +class _D: + def __init__(self, name, config, children=()): + self.name = name; self.config = config; self._children = list(children); self.already_executed = False + self.is_root = False; self.load_action = 'check' + self.phase_times = {}; self.should_rebuild = False; self.from_artifactory = False; self.nothing_to_build = False + def get_children(self): return self._children + def is_root_or_config_target(self): return False + + +def _graph(monkeypatch, fail_child=False): + monkeypatch.setattr(dc, '_save_mama_cmake_and_dependencies_cmake', lambda d: None) + monkeypatch.setattr(dc, '_save_vscode_compile_commands', lambda d: None) + cfg = SimpleNamespace(jobs=2, verbose=False, test=False, workspaces_root=None, buildstats=False) + ev, lock = [], threading.Lock() + child = _D('child', cfg); parent = _D('parent', cfg, [child]) + for d in (child, parent): d.target = _T(d, ev, lock) + child.target.fail = fail_child + return [child, parent], ev # flat_deps_reverse is leaves-first + + +def test_parallel_runner_orders_and_runs_post_pass(monkeypatch): + deps, ev = _graph(monkeypatch) + dc.execute_task_chain_parallel(deps) + assert ev.index(('build', 'child')) < ev.index(('configure', 'parent')) # child built before parent configures + assert ev.index(('configure', 'parent')) < ev.index(('build', 'parent')) + assert ('deploy', 'child') in ev and ('run', 'parent') in ev # serial post-pass ran + assert all(d.already_executed for d in deps) + + +def test_parallel_runner_fails_fast_and_blocks_dependents(monkeypatch, capsys): + deps, ev = _graph(monkeypatch, fail_child=True) + with pytest.raises(SystemExit): + dc.execute_task_chain_parallel(deps) + out = capsys.readouterr().out + assert 'BUILD FAILED' in out and 'child' in out + assert ('build', 'parent') not in ev # parent build depends on failed child, never released + + +def test_parallel_runner_prints_diagnostics_summary_on_failure(monkeypatch, capsys): + deps, _ = _graph(monkeypatch, fail_child=True) + deps[0].target.warn = 'a.cpp:1:1: warning: leaky' # child emits a warning, then fails + with pytest.raises(SystemExit): + dc.execute_task_chain_parallel(deps) + out = strip_ansi(capsys.readouterr().out) + assert 'BUILD FAILED' in out # failure still reported + replayed + assert 'Compiler diagnostics' in out and 'child: 1 warning(s)' in out and 'leaky' in out # ...+ aggregate summary + + +def test_node_marker_root_leaf_trunk(): + mk = lambda root, kids: SimpleNamespace(is_root=root, get_children=lambda: kids) + assert dc._node_marker(mk(False, [])) == '[L]' # no deps of its own + assert dc._node_marker(mk(False, [1])) == '[T]' # has deps + assert dc._node_marker(mk(True, [1])) == '[R]' # root wins regardless of children + + +def test_build_detail_is_fixed_width_j_cores(): + d = lambda cores, root=False, jobs=0: SimpleNamespace(is_root=root, config=SimpleNamespace(jobs=jobs), + target=SimpleNamespace(_reserved_cores=lambda: cores)) + assert dc._build_detail(d(4)) == 'J4 ' and dc._build_detail(d(12)) == 'J12' # capped cores, same width + assert dc._build_detail(d(4, root=True, jobs=64)) == 'J64' # root shows full jobs, not the cap + + +def test_run_phase_shows_tree_marker_only_in_verbose(monkeypatch): + import contextlib + monkeypatch.setattr(dc, '_phase_label', lambda d, k: 'build') + monkeypatch.setattr(dc.system, 'capture_to', lambda *a, **k: contextlib.nullcontext()) + seen = {} + disp = SimpleNamespace(start_task=lambda tid, label, name, detail: seen.__setitem__('n', name), + feed=lambda *a: None, finish_task=lambda *a: None) + dep = SimpleNamespace(name='ReCpp', is_root=False, get_children=lambda: [], phase_times={}, + config=SimpleNamespace(verbose=False)) + dc._run_phase(disp, dep, 'build', lambda s: None, None) + assert seen['n'] == 'ReCpp' # no [L]/[T]/[R] noise in normal output + dep.config.verbose = True + dc._run_phase(disp, dep, 'build', lambda s: None, None) + assert seen['n'] == '[L] ReCpp' # markers only in verbose + + +def test_stable_cpu_sampler_re_measures_only_per_window(): + clk = {'t': 0.0}; reads = iter([10.0, 90.0]) + s = dc._stable_cpu_sampler(lambda: next(reads), lambda: clk['t'], window=0.5) + assert s() == 0.0 # within the first window: primed 0.0, no measure yet + clk['t'] = 0.6; assert s() == 10.0 # window elapsed -> measures + assert s() == 10.0 # same instant -> cached, no spiky re-read + clk['t'] = 1.2; assert s() == 90.0 # next window -> re-measures + + +def test_phase_label_load_opens_clone_when_fresh_else_check(): + fresh = SimpleNamespace(is_real_clone=lambda: False) + existing = SimpleNamespace(is_real_clone=lambda: True) + assert dc._phase_label(fresh, 'load') == 'clone' and dc._phase_label(existing, 'load') == 'check' + assert dc._phase_label(fresh, 'configure') == 'configure' # non-load kinds verbatim + + +def test_run_phase_relabels_load_to_actual_action(monkeypatch): + import contextlib + monkeypatch.setattr(dc, '_phase_label', lambda d, k: 'clone') # optimistic opening label + monkeypatch.setattr(dc.system, 'capture_to', lambda *a, **k: contextlib.nullcontext()) + seen = {} + disp = SimpleNamespace(start_task=lambda *a: None, feed=lambda *a: None, finish_task=lambda *a: None, + relabel=lambda tid, kind: seen.__setitem__('k', kind)) + dep = SimpleNamespace(name='ReCpp', is_root=False, get_children=lambda: [], phase_times={}, + config=SimpleNamespace(verbose=False), load_action='pulling') + dc._run_phase(disp, dep, 'load', lambda s: None, None) + assert seen['k'] == 'pulling' # relabeled to what load() actually did + seen.clear() + dc._run_phase(disp, dep, 'build', lambda s: None, None) + assert 'k' not in seen # only 'load' is relabeled, not other phases + + +def test_reserve_weight_is_zero_for_custom_build_root_else_reserved_cores(): + def mk(custom, cores, root=False): + t = SimpleNamespace(_has_custom_build=lambda: custom, _reserved_cores=lambda: cores) + return SimpleNamespace(target=t, is_root=root) + assert dc._reserve_weight(mk(custom=False, cores=12)) == 12 # default build reserves at launch + assert dc._reserve_weight(mk(custom=True, cores=12)) == 0 # custom build self-reserves via the barrier + assert dc._reserve_weight(mk(custom=False, cores=12, root=True)) == 0 # root ungated -> reserves nothing + + +def test_build_summary_counts_only_real_builds(capsys): + d = lambda **k: SimpleNamespace(**k) + deps = [d(should_rebuild=True, from_artifactory=False, nothing_to_build=False), # compiled + d(should_rebuild=True, from_artifactory=True, nothing_to_build=False), # artifactory fetch + d(should_rebuild=False, from_artifactory=False, nothing_to_build=False), # up to date + d(should_rebuild=True, from_artifactory=False, nothing_to_build=True)] # header-only no-op + dc._print_build_summary(deps, 72.0) + assert 'Built 1 target(s) in 1m 12s' in capsys.readouterr().out + + +def test_print_diagnostics_lists_warnings_per_target_and_notes_overflow(capsys): + data = {'myapp': ([('warning', 'a.cpp:1: warning: x')], 0, 1), + 'netlib': ([('error', 'b.cpp:2: error: y')], 1, 3), # 4 total, 1 shown -> +3 overflow + 'clean': ([], 0, 0)} + disp = SimpleNamespace(diagnostics=lambda name, limit=8: data[name]) + dc._print_diagnostics(disp, [SimpleNamespace(name=n) for n in data]) + out = strip_ansi(capsys.readouterr().out) + assert 'Compiler diagnostics' in out + assert 'myapp: 1 warning(s)' in out and 'a.cpp:1: warning: x' in out + assert 'netlib: 1 error(s), 3 warning(s)' in out and '... (+3 more)' in out + assert 'clean' not in out # nothing captured -> target omitted + + +def _failed(err, name='rpcservice'): + return SimpleNamespace(error=err, node=SimpleNamespace(name=name, config=SimpleNamespace(verbose=False))) + + +def _quiet_display(): + return SimpleNamespace(isatty=False, replay=lambda n: None) + + +def test_build_error_reports_cleanly_without_a_traceback(capsys): + from mama.util import BuildError + dc._handle_failure(_quiet_display(), _failed(BuildError('CMake configure failed for rpcservice (exit 1)'))) + out = strip_ansi(capsys.readouterr().out) + assert '[BUILD FAILED] rpcservice' in out + assert 'CMake configure failed for rpcservice (exit 1)' in out + assert 'Traceback' not in out # a broken user build is not a mamabuild bug + + +def test_unexpected_error_still_shows_the_traceback(capsys): + try: raise ValueError('internal oops') + except ValueError as e: err = e + dc._handle_failure(_quiet_display(), _failed(err)) + out = strip_ansi(capsys.readouterr().out) + assert 'Traceback' in out and 'internal oops' in out # a real bug keeps its stack trace + + +def test_diagnostics_lists_the_failed_target_first(capsys): + data = {'geo': ([('warning', 'CMake Warning:')], 0, 1), + 'rpcservice': ([('error', 'CMake Error at FindOpenSSL.cmake:668')], 1, 0)} + disp = SimpleNamespace(diagnostics=lambda name, limit=8: data[name]) + deps = [SimpleNamespace(name='geo'), SimpleNamespace(name='rpcservice')] + dc._print_diagnostics(disp, deps, failed_name='rpcservice') + out = strip_ansi(capsys.readouterr().out) + assert out.index('rpcservice') < out.index('geo') # the reason to fix is last on screen, not buried + assert 'BUILD FAILED HERE' in out + + +def test_make_scheduler_overprovision_is_platform_specific(monkeypatch): + from mama.utils import system + cfg = SimpleNamespace(jobs=8) + monkeypatch.setattr(system.System, 'windows', True) + assert dc._make_scheduler(cfg)._overprovision == dc._OVERPROVISION_WIN # MSVC tolerates 2x + monkeypatch.setattr(system.System, 'windows', False) + assert dc._make_scheduler(cfg)._overprovision == dc._OVERPROVISION_UNIX # Linux is memory-bound -> no overprovision + + +def test_mem_capped_budget_caps_by_ram_but_never_below_one(monkeypatch): + import psutil + ram = lambda gb: monkeypatch.setattr(psutil, 'virtual_memory', lambda: SimpleNamespace(total=int(gb * 1024**3))) + ram(8); assert dc._mem_capped_budget(32) == int(8 / dc._GB_PER_COMPILE) # RAM-limited (8/1.5 -> 5) + ram(256); assert dc._mem_capped_budget(32) == 32 # RAM plenty -> capped by jobs + ram(0.5); assert dc._mem_capped_budget(32) == 1 # tiny box -> at least one build diff --git a/tests/test_proc_cpu/test_proc_cpu.py b/tests/test_proc_cpu/test_proc_cpu.py new file mode 100644 index 0000000..a8258f7 --- /dev/null +++ b/tests/test_proc_cpu/test_proc_cpu.py @@ -0,0 +1,58 @@ +"""Pins proc_cpu: tree CPU% delta math, that it reads only each build's own tree (not every system +process, never the whole table), and the Windows kernel32 path's correctness + speed.""" +import contextlib, os, sys, time +import pytest +from mama.utils import proc_cpu as pc + + +def test_make_sampler_returns_callable_or_none(): + s = pc.make_sampler() + assert s is None or callable(s) + + +def test_accumulate_cpu_delta_first_sight_and_prune(): + state = {} + assert pc.accumulate_cpu(state, 100.0, {'t': {1: (12.0, 96.0)}}) == {'t': 300.0} # first sight: 12s/4s life + assert pc.accumulate_cpu(state, 101.0, {'t': {1: (13.0, 96.0)}}) == {'t': 100.0} # +1 cpu-s over 1s wall + assert pc.accumulate_cpu(state, 102.0, {'t': {}}) == {'t': 0.0} and state == {} # gone -> pruned + + +class _FakeProc: + def __init__(self, pid, calls): self.pid = pid; self._calls = calls + def cpu_times(self): self._calls.append(self.pid); return (12.0, 0.0) + def create_time(self): return 96.0 + def children(self, recursive=True): return [_FakeProc(2, self._calls)] if self.pid == 1 else [] + def oneshot(self): return contextlib.nullcontext() + + +def test_psutil_sampler_sums_tree_and_touches_only_that_tree(monkeypatch): + # root pid 1 -> child pid 2, each first-seen alive 4s @ 12 cpu-sec -> 300% each -> 600%; AND it must + # read cpu only for the build's own 2 procs, never enumerate the whole system + monkeypatch.setattr(pc.time, 'time', lambda: 100.0) + calls = [] + class Ps: + Error = Exception + def Process(self, pid): return _FakeProc(pid, calls) + def process_iter(self, *a): raise AssertionError('must not enumerate all processes') + assert pc.PsutilTreeCpu(Ps())({'t': {1}}) == {'t': 600.0} + assert sorted(calls) == [1, 2] # only the tree's two pids touched + + +@pytest.mark.skipif(sys.platform != 'win32', reason='kernel32 sampler is Windows-only') +def test_win_sampler_reads_real_process(): + s = pc.WinTreeCpu(); me = os.getpid() + assert me in s._ppid_map() # one toolhelp snapshot sees this process + cpu, create = s._proc_times(me) + assert cpu >= 0.0 and create > 0.0 # GetProcessTimes returns sane cpu seconds + creation time + assert s({'t': {me}})['t'] >= 0.0 # a full sample returns a CPU% for the tree + + +@pytest.mark.skipif(sys.platform != 'win32', reason='kernel32 sampler is Windows-only') +def test_win_sampler_is_fast(): + # one toolhelp snapshot + GetProcessTimes per tree pid. Generous bound (real cost ~15ms) that still + # catches the read-every-process regression, which sampled in seconds. + s = pc.WinTreeCpu(); me = os.getpid(); s({'t': {me}}) # warm up (first-sight state) + t0 = time.perf_counter() + for _ in range(5): s({'t': {me}}) + avg_ms = (time.perf_counter() - t0) / 5 * 1000 + assert avg_ms < 250, f'sampler too slow: {avg_ms:.1f} ms/sample' diff --git a/tests/test_rebuild_clean_shim/test_rebuild_clean_shim.py b/tests/test_rebuild_clean_shim/test_rebuild_clean_shim.py new file mode 100644 index 0000000..4d0cd15 --- /dev/null +++ b/tests/test_rebuild_clean_shim/test_rebuild_clean_shim.py @@ -0,0 +1,38 @@ +"""Pins rebuild/clean of a cached artifactory shim: `rebuild` drops the shim and builds from source; +a plain `clean` re-extracts the package after wiping the build dir (so dependents can still link).""" +from unittest.mock import Mock, patch + +from testutils import make_mock_shim_dep + +import mama.build_dependency as build_dependency +from mama.build_dependency import BuildDependency +from mama.types.git import Git + + +def test_rebuild_forces_source_clone_like_unshallow(tmp_path): + dep = make_mock_shim_dep(tmp_path, rebuild=True) + dep.config.target_matches.return_value = True + assert dep._force_source_clone() + dep.config.target_matches.return_value = False + assert not dep._force_source_clone() # only the rebuilt target, not every dep in a targeted run + + +def test_rebuild_target_drops_cached_shim_for_source(tmp_path): + dep = make_mock_shim_dep(tmp_path, rebuild=True) + dep.config.target_matches.return_value = True + assert dep.is_artifactory_shim() + assert dep._try_artifactory_shim() is False # cached shim NOT loaded + assert not dep.is_artifactory_shim() # marker dropped -> _git_checkout_if_needed clones source + + +def test_plain_clean_reloads_pkg_without_a_source_clone(tmp_path): + dep = make_mock_shim_dep(tmp_path, clean=True) + dep.config.target_matches.return_value = True + assert not dep._force_source_clone() # a plain clean must not force a source clone + with patch.object(BuildDependency, 'try_load_cached_shim', return_value=Mock()), \ + patch.object(Git, 'dependency_checkout') as clone_mock, \ + patch.object(build_dependency, 'artifactory_fetch_and_reconfigure', + return_value=(True, [])) as reload_mock: + dep._load() + reload_mock.assert_called_once() # clean rmtree'd the pkg libs -> re-extract from the cached zip + clone_mock.assert_not_called() # ...and never fall back to cloning source diff --git a/tests/test_requires_version/test_requires_version.py b/tests/test_requires_version/test_requires_version.py new file mode 100644 index 0000000..7e3b567 --- /dev/null +++ b/tests/test_requires_version/test_requires_version.py @@ -0,0 +1,49 @@ +"""Pins mamafile `self.requires_version(...)`: numeric-segment comparison (0.13 outranks 0.9) and an +abort carrying the pip upgrade hint when the running mamabuild is too old.""" +import pytest + +import mama.build_target as build_target +from mama.build_target import BuildTarget +from mama.util import parse_version, version_at_least + + +def _target(name='myapp'): + t = object.__new__(BuildTarget) # skip the heavy __init__; requires_version only needs .name + t.name = name + return t + + +@pytest.mark.parametrize('text,expected', [ + ('0.13.01', (0, 13, 1)), # zero-padded patch parses numerically + ('0.9.5', (0, 9, 5)), + ('1', (1,)), + ('2.0.0-rc1', (2, 0, 1)), # non-numeric junk in a segment is dropped +]) +def test_parse_version(text, expected): + assert parse_version(text) == expected + + +@pytest.mark.parametrize('current,required,ok', [ + ('0.13.01', '0.9.5', True), # the trap: a plain string compare ranks '0.13' BELOW '0.9' + ('0.13.01', '0.13.01', True), # equal satisfies the requirement + ('0.13.02', '0.13.01', True), + ('0.12.0', '0.13.01', False), + ('0.13', '0.13.01', False), # shorter side zero-pads -> 0.13.0 < 0.13.01 +]) +def test_version_at_least(current, required, ok): + assert version_at_least(current, required) is ok + + +def test_requires_version_passes_when_current_is_new_enough(monkeypatch): + monkeypatch.setattr(build_target, '__version__', '0.13.01') + _target().requires_version('0.13.01') # equal, and no raise + _target().requires_version('0.9.5') + + +def test_requires_version_aborts_with_pip_upgrade_hint(monkeypatch): + monkeypatch.setattr(build_target, '__version__', '0.12.5') + with pytest.raises(RuntimeError) as e: + _target().requires_version('0.13.01') + msg = str(e.value) + assert 'myapp' in msg and '0.13.01' in msg and '0.12.5' in msg # names target, wanted, actual + assert 'pip install --upgrade mama' in msg # tells the user how to fix it diff --git a/tests/test_ssh_multiplex/test_ssh_multiplex.py b/tests/test_ssh_multiplex/test_ssh_multiplex.py index 8dafb27..59a4967 100644 --- a/tests/test_ssh_multiplex/test_ssh_multiplex.py +++ b/tests/test_ssh_multiplex/test_ssh_multiplex.py @@ -90,7 +90,7 @@ def test_user_has_full_config(self): 'serveralivecountmax': '5', } opts, we_own = sm.options_to_add(probe) - assert opts == [], 'should add nothing when user has everything' + assert opts == sm._SAFETY_OPTS, 'only the always-on safety opts when user has everything' assert we_own is False def test_user_has_nothing(self, tmp_path, monkeypatch): @@ -162,7 +162,7 @@ def test_windows_user_configured_multiplex_respected(self, monkeypatch): } opts, we_own = sm.options_to_add(probe) assert we_own is False - assert opts == [], 'user has full config - we add nothing' + assert opts == sm._SAFETY_OPTS, 'user has full config - only the always-on safety opts' class TestMultiplexKnownBroken: @@ -366,8 +366,8 @@ def test_passthrough_when_user_has_full_config(self, monkeypatch): 'git@github.com', "git-upload-pack 'foo/bar.git'"]) prog, argv = execed assert prog == 'ssh' - # No options added - user already has everything. - assert argv == ['ssh', '-o', 'SendEnv=GIT_PROTOCOL', 'git@github.com', + # Only the always-on safety opts are added; user already has multiplex + keepalives. + assert argv == ['ssh', *sm._SAFETY_OPTS, '-o', 'SendEnv=GIT_PROTOCOL', 'git@github.com', "git-upload-pack 'foo/bar.git'"] def test_adds_multiplex_when_user_has_nothing(self, monkeypatch, tmp_path): diff --git a/tests/test_sub_process/test_sub_process.py b/tests/test_sub_process/test_sub_process.py index 60147e7..d4371e3 100644 --- a/tests/test_sub_process/test_sub_process.py +++ b/tests/test_sub_process/test_sub_process.py @@ -5,20 +5,21 @@ import pytest +from mama.utils import system from mama.utils.sub_process import SubProcess PY = sys.executable -def _py_run(code: str, io_func=None, cwd=None, env=None, timeout=None): +def _py_run(code: str, io_func=None, cwd=None, env=None, timeout=None, idle_timeout=None): """Run `python -c ""` via SubProcess. Returns (status, lines_seen).""" lines = [] if io_func is None: def collect(p, line): lines.append(line) io_func = collect status = SubProcess.run([PY, '-c', code], cwd=cwd, env=env, - io_func=io_func, timeout=timeout) + io_func=io_func, timeout=timeout, idle_timeout=idle_timeout) return status, lines @@ -48,6 +49,13 @@ def test_stderr_is_merged_into_io_func(self): _, lines = _py_run('import sys; print("out"); print("err", file=sys.stderr)') assert 'out' in lines and 'err' in lines + def test_all_output_drained_on_nonzero_exit(self): + # Regression: close() must drain the reader BEFORE shutting the pipe, or a burst of lines flushed + # right before a failing exit (e.g. the compiler error that failed the build) is lost. + status, lines = _py_run('import sys\nfor i in range(300): print("line%d" % i)\nsys.exit(1)') + assert status == 1 + assert [l for l in lines if l.startswith('line')][-1] == 'line299' # the tail is never dropped + def test_no_trailing_carriage_return_on_lines(self): _, lines = _py_run('print("plain")') assert 'plain' in lines @@ -60,6 +68,17 @@ def broken(p, line): raise RuntimeError(f'callback boom on line={line!r}') SubProcess.run([PY, '-c', 'print("hi")'], io_func=broken) +class TestCaptureContextPropagation: + def test_io_func_console_routes_to_callers_capture_sink(self): + # io_func fires on the reader thread; without the capture context carried over, its console() + # would miss the caller's sink and leak above the live region (the parallel-build checkout leak). + sink = [] + def echo(p, line): system.console(f'got:{line}') # mirrors run_git's prefixed() callback + with system.capture_to(sink.append): + SubProcess.run([PY, '-c', 'print("checkout")'], io_func=echo) + assert 'got:checkout' in sink + + class TestCwd: def test_cwd_is_honored(self, tmp_path): (tmp_path / 'sentinel.txt').write_text('found-it') @@ -86,7 +105,7 @@ def test_default_env_inherits_parent(self): class TestTimeout: def test_long_running_command_times_out(self): with pytest.raises(subprocess.TimeoutExpired): - SubProcess.run([PY, '-c', 'import time; time.sleep(30)'], + SubProcess.run([PY, '-c', 'import time; time.sleep(5)'], io_func=lambda p, line: None, timeout=0.3) def test_fast_command_does_not_time_out(self): @@ -94,6 +113,23 @@ def test_fast_command_does_not_time_out(self): io_func=lambda p, line: None, timeout=10.0) == 0 +class TestIdleTimeout: + def test_idle_timeout_kills_a_silent_child(self): + import time + t0 = time.monotonic() + with pytest.raises(subprocess.TimeoutExpired): + SubProcess.run([PY, '-c', 'import time; time.sleep(5)'], + io_func=lambda p, l: None, idle_timeout=0.4) + assert time.monotonic() - t0 < 5 # died on the 0.4s idle bound, not the child's 5s sleep + + def test_idle_timeout_spares_a_chatty_child(self): + # Streaming output keeps resetting the idle clock, so total runtime (0.6s) > idle (0.4s) is fine. + status, lines = _py_run( + 'import sys, time\nfor i in range(6): print(i); sys.stdout.flush(); time.sleep(0.1)', + idle_timeout=0.4) + assert status == 0 and lines == ['0', '1', '2', '3', '4', '5'] + + class TestStdinWrite: def test_write_delivers_to_child(self): # SSH host-key auto-accept path: clone_with_filtered_progress writes 'yes\\n' on prompt. @@ -262,6 +298,58 @@ def test_many_tiny_lines(self): assert lines == ['x'] * 5000 and d.buf == b'' +class TestCtrlCTermination: + @pytest.fixture(autouse=True) + def _disarm(self): + SubProcess.clear_abort(); yield; SubProcess.clear_abort() + + def test_terminate_all_blocks_new_spawns_then_clear_re_arms(self): + SubProcess.terminate_all() + with pytest.raises(KeyboardInterrupt): + SubProcess.run([PY, '-c', 'pass']) + SubProcess.clear_abort() + assert SubProcess.run([PY, '-c', 'pass'], io_func=lambda p, l: None) == 0 + + def test_terminate_all_kills_a_running_child(self): + import threading, time + from mama.utils import sub_process + result = {} + def run_child(): + try: result['s'] = SubProcess.run([PY, '-c', 'import time; time.sleep(5)'], io_func=lambda p, l: None) + except BaseException as e: result['exc'] = e + t = threading.Thread(target=run_child); t.start() + end = time.monotonic() + 5 + while time.monotonic() < end and not sub_process._live_procs: time.sleep(0.01) + SubProcess.terminate_all() + t.join(10) + assert not t.is_alive() # killed promptly, not blocked for the full 30s + assert result.get('s', 0) != 0 # nonzero status from the kill + + def test_kill_takes_down_the_grandchild_subtree(self, tmp_path): + # The point of tree-kill: a killed cmake's ninja/compiler grandchildren must die too, not + # just the spawned pid. Stand-in: a child that spawns a long-sleeping grandchild. + import threading, time + from mama.utils import sub_process + psutil = pytest.importorskip('psutil') + pidfile = str(tmp_path / 'gc.pid').replace('\\', '/') + code = (f'import subprocess, sys, time\n' + f'gc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])\n' + f'open("{pidfile}", "w").write(str(gc.pid)); time.sleep(60)\n') + threading.Thread(target=lambda: SubProcess.run([PY, '-c', code], io_func=lambda p, l: None), + daemon=True).start() + end = time.monotonic() + 8 + while time.monotonic() < end and not os.path.exists(pidfile): time.sleep(0.02) + gc_pid = int(open(pidfile).read()) + SubProcess.terminate_all() + end = time.monotonic() + 5 + while time.monotonic() < end and psutil.pid_exists(gc_pid): time.sleep(0.02) + alive = psutil.pid_exists(gc_pid) + if alive: + try: psutil.Process(gc_pid).kill() # don't leak a 60s sleeper if the assert fails + except Exception: pass + assert not alive # grandchild died with the tree + + class TestNoForkptyDeprecationWarning: # The whole point of the Popen+pty.openpty rewrite was to kill this warning # (Python 3.12 flags forkpty() in MT programs - real deadlock risk). @@ -272,3 +360,36 @@ def test_run_does_not_emit_forkpty_warning(self): SubProcess.run([PY, '-c', 'print("x")'], io_func=lambda p, l: None) forkpty_warnings = [w for w in caught if 'forkpty' in str(w.message).lower()] assert forkpty_warnings == [], f'forkpty deprecation came back: {forkpty_warnings}' + + +class TestExecuteEchoCapture: + # A custom build()'s self.run() -> execute_echo must feed the active display sink (not the raw + # terminal) while a build phase captures, but stay stdio-direct for interactive post-pass commands. + def test_captures_into_active_sink(self): + from mama.utils.sub_process import execute_echo + captured = [] + with system.capture_to(captured.append): + execute_echo(cwd=None, cmd=[PY, '-c', 'print("from-build-step")']) + assert any('from-build-step' in line for line in captured) + + def test_without_sink_inherits_stdio(self, capfd): + from mama.utils.sub_process import execute_echo + execute_echo(cwd=None, cmd=[PY, '-c', 'print("direct-to-terminal")']) + assert 'direct-to-terminal' in capfd.readouterr().out + + def test_quiet_suppresses_output_even_with_active_sink(self): + from mama.utils.sub_process import execute_echo + captured = [] + with system.capture_to(captured.append): + execute_echo(cwd=None, cmd=[PY, '-c', 'print("should-be-silenced")'], quiet=True) + assert not any('should-be-silenced' in line for line in captured) + + +def test_echoed_output_goes_through_console_not_raw_print(monkeypatch): + # a raw print() while a live display owns the screen tears its cursor math + from mama.utils import sub_process as sp + seen = [] + monkeypatch.setattr(sp, 'console', lambda line, **kw: seen.append(line)) + monkeypatch.setattr(sp.SubProcess, 'run', lambda cmd, cwd, env=None, io_func=None: (io_func(None, 'hello'), 0)[1]) + status, out = sp.execute_piped_echo('.', 'anything', echo=True) + assert seen == ['hello'] and out == 'hello' and status == 0 diff --git a/tests/test_target_args/test_target_args.py b/tests/test_target_args/test_target_args.py new file mode 100644 index 0000000..ea26269 --- /dev/null +++ b/tests/test_target_args/test_target_args.py @@ -0,0 +1,50 @@ +"""Pins unused-arg handling: a bare word becomes the target, an option-shaped typo fails immediately.""" +from types import SimpleNamespace +from unittest.mock import Mock, patch +import pytest +from mama.main import set_target_from_unused_args, check_config_target + + +def _mk(unused, target): + cfg = Mock() + cfg.unused_args = list(unused) + cfg.target = target + cfg.has_target = lambda: bool(cfg.target) + return cfg + + +def test_bare_word_becomes_the_target(): + cfg = _mk(['ReCpp'], None) # `mama rebuild ReCpp` + set_target_from_unused_args(cfg) + assert cfg.target == 'ReCpp' + + +def test_option_shaped_typo_fails_immediately(capsys): + for bad in ('jobz=4', '-buildstats'): + cfg = _mk([bad], None) + with pytest.raises(SystemExit): + set_target_from_unused_args(cfg) + assert f"unknown option '{bad}'" in capsys.readouterr().out + assert cfg.target is None # never silently reinterpreted as a target name + + +def test_unknown_target_error_lists_the_valid_ones(capsys): + cfg = _mk([], 'buildstatz') + cfg.targets_all = lambda: False + root = Mock() + with patch('mama.main.find_dependency', return_value=None), \ + patch('mama.main.get_flat_deps', return_value=[SimpleNamespace(name=n) for n in ('ReCpp', 'zlib')]): + with pytest.raises(SystemExit): + check_config_target(cfg, root) + out = capsys.readouterr().out + assert "target='buildstatz' not found" in out and 'ReCpp, zlib' in out + + +def test_a_retired_flag_names_its_replacement(capsys): + # `buildtimes` would otherwise be read as a target name and fail with a confusing 'target not found' + cfg = _mk(['buildtimes'], None) + with pytest.raises(SystemExit): + set_target_from_unused_args(cfg) + out = capsys.readouterr().out + assert "'buildtimes' was removed" in out and "'buildstats'" in out + assert cfg.target is None diff --git a/tests/test_target_scoped_build/test_target_scoped_build.py b/tests/test_target_scoped_build/test_target_scoped_build.py new file mode 100644 index 0000000..f8d79d7 --- /dev/null +++ b/tests/test_target_scoped_build/test_target_scoped_build.py @@ -0,0 +1,180 @@ +"""Pins `build target=X`: revive the unbuilt deps X needs, and ONLY those - never the whole workspace.""" +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch +from mama.main import mamabuild +from testutils import make_mock_dep +from mama.dependency_chain import mark_unbuilt_target_deps + + +def _dep(name, children=(), usable=True): + d = SimpleNamespace(name=name, should_rebuild=False, children=list(children)) + d.get_children = lambda d=d: d.children + d.has_usable_artifacts = lambda usable=usable: usable + return d + + +def _tree(): + # root -> X -> A(unbuilt), B(built) + # -> C(unbuilt, unrelated to X) + a, b, c = _dep('A', usable=False), _dep('B'), _dep('C', usable=False) + x = _dep('X', [a, b]) + return _dep('root', [x, c]), x, a, b, c + + +def _mark(root, target='X'): + mark_unbuilt_target_deps(root, SimpleNamespace(target=target, print=False)) + + +def test_an_unbuilt_dep_of_the_target_is_revived(): + root, _, a, _, _ = _tree() + _mark(root) + assert a.should_rebuild # else X compiles against an include dir that doesn't exist + + +def test_an_unbuilt_dep_outside_the_target_subtree_is_left_alone(): + # Regression: marking every unbuilt dep in the workspace made `mama build target=protobuf` build + # unrelated targets - and a mamafile that shells out to `mama build target=Y` then re-entered + # itself, forking until the machine died. + root, _, _, _, c = _tree() + _mark(root) + assert not c.should_rebuild + + +def test_a_dep_that_depends_on_the_target_is_never_revived(): + # The fork bomb, exactly: rpclib.configure() shells out to `mama build target=protobuf` to get a + # host protoc. If that child revives rpclib (it depends on protobuf, so it sits ABOVE it), the + # child re-enters the same configure() and spawns another child, forever. + protobuf = _dep('protobuf', usable=False) + rpc = _dep('rpclib', [protobuf], usable=False) + root = _dep('myapp', [rpc], usable=False) + _mark(root, target='protobuf') + assert not rpc.should_rebuild and not root.should_rebuild # only protobuf's own subtree is in scope + + +def test_a_dep_with_artifacts_is_not_rebuilt(): + root, _, _, b, _ = _tree() + _mark(root) + assert not b.should_rebuild # `target=X` still means build only X + + +def test_an_unknown_target_marks_nothing(): + root, _, a, _, c = _tree() + _mark(root, target='nope') + assert not a.should_rebuild and not c.should_rebuild + + +def _artifact_dep(tmp_path, **over): + dep = make_mock_dep(tmp_path) + dep.target = Mock(build_products=[], args='') + dep.nothing_to_build = False + dep.from_artifactory = False + for k, v in over.items(): setattr(dep, k, v) + return dep + + +def test_has_usable_artifacts_reads_every_shape(tmp_path): + dep = _artifact_dep(tmp_path) + assert not dep.has_usable_artifacts() # empty build dir: nothing to link against + (Path(dep.build_dir) / 'CMakeCache.txt').write_text('') + assert dep.has_usable_artifacts() # configured -> built + + lib = tmp_path / 'libfoo.a'; lib.write_text('') + custom = _artifact_dep(tmp_path) + custom.target.build_products = [str(lib)] + assert custom.has_usable_artifacts() # custom build(): exports, but no CMakeCache + custom.target.build_products = [str(tmp_path / 'gone.a')] + assert not custom.has_usable_artifacts() # a recorded export vanished + + assert _artifact_dep(tmp_path, from_artifactory=True).has_usable_artifacts() + assert _artifact_dep(tmp_path, nothing_to_build=True).has_usable_artifacts() + + +def _fake_tree_root(): + """myapp -> rpcservice -> rpclib -> protobuf, plus an unrelated leaf.""" + protobuf = _dep('protobuf') + rpc = _dep('rpclib', [protobuf]) + svc = _dep('rpcservice', [rpc]) + return _dep('myapp', [svc, _dep('gcsmanual')]), protobuf + + +def _executed_deps(args, tmp_path): + """Names handed to the task chain by `mamabuild`, with loading and execution stubbed out.""" + (tmp_path / 'CMakeLists.txt').write_text('project(dummy)\n') + root_children, _ = _fake_tree_root() + seen = {} + def capture(deps): seen['names'] = [d.name for d in deps] + with patch('mama.main.load_dependency_chain', side_effect=lambda r: setattr(r, 'children', root_children.children)), \ + patch('mama.main.execute_task_chain', side_effect=capture), \ + patch('mama.main.execute_task_chain_parallel', side_effect=capture), \ + patch('mama.main.execute_unified'), \ + patch('mama.main._init_platform_compilers'), patch('mama.main.print_build_banner'): + mamabuild(args, source_dir=str(tmp_path)) + return seen.get('names', []) + + +def test_building_one_target_does_not_execute_unrelated_targets(tmp_path): + # `mama build protobuf` used to hand every dep a job; the out-of-scope ones did no build work but + # still ran package(), which asserts on libs that were never built. + names = _executed_deps(['build', 'protobuf'], tmp_path) + assert names == ['protobuf'] + assert 'rpcservice' not in names and 'myapp' not in names + + +def test_building_a_mid_tree_target_still_includes_what_it_needs(tmp_path): + names = _executed_deps(['build', 'rpclib'], tmp_path) + assert set(names) == {'rpclib', 'protobuf'} # its own dep comes along, its dependents don't + + +def test_an_untargeted_build_still_runs_the_whole_tree(tmp_path): + names = _executed_deps(['build', 'all', 'serial'], tmp_path) # serial keeps it on the classic path + assert {'rpcservice', 'rpclib', 'protobuf', 'gcsmanual'} <= set(names) + assert len(names) == 5 # ...plus the root itself, named after the project dir + + +def _runner_used(args, tmp_path): + """Which task runner mamabuild picked - the parallel one owns the live display.""" + (tmp_path / 'CMakeLists.txt').write_text('project(dummy)\n') + root_children, _ = _fake_tree_root() + with patch('mama.main.load_dependency_chain', side_effect=lambda r: setattr(r, 'children', root_children.children)), \ + patch('mama.main.execute_task_chain') as serial, \ + patch('mama.main.execute_task_chain_parallel') as parallel, \ + patch('mama.main.execute_unified'), \ + patch('mama.main._init_platform_compilers'), patch('mama.main.print_build_banner'): + mamabuild(args, source_dir=str(tmp_path)) + return 'serial' if serial.called else ('parallel' if parallel.called else 'none') + + +def test_a_single_target_build_still_uses_the_live_display(tmp_path): + # the scheduler owns the display; the serial runner dumps raw cmake output, so a one-dep graph + # must NOT fall back to it just because there's nothing to overlap + assert _runner_used(['build', 'protobuf'], tmp_path) == 'parallel' + + +def test_serial_flag_still_opts_out(tmp_path): + assert _runner_used(['build', 'protobuf', 'serial'], tmp_path) == 'serial' + + +def test_mamabuild_actually_revives_an_unbuilt_dep(tmp_path): + # Regression: mark_unbuilt_target_deps() was imported but never called, so this whole behaviour was + # dead code - and the unit tests above passed because they call it directly. Drive mamabuild instead. + (tmp_path / 'CMakeLists.txt').write_text('project(dummy)\n') + protobuf = _dep('protobuf', usable=False) + rpc = _dep('rpclib', [protobuf]) + with patch('mama.main.load_dependency_chain', side_effect=lambda r: setattr(r, 'children', [rpc])), \ + patch('mama.main.execute_task_chain'), patch('mama.main.execute_task_chain_parallel'), \ + patch('mama.main.execute_unified'), \ + patch('mama.main._init_platform_compilers'), patch('mama.main.print_build_banner'): + mamabuild(['build', 'rpclib'], source_dir=str(tmp_path)) + assert protobuf.should_rebuild # else rpclib compiles against an include dir that isn't there + + +def test_has_usable_artifacts_survives_a_dep_whose_target_never_loaded(tmp_path): + # a dep can sit in the tree with target=None (mamafile failed to parse, clone interrupted); the + # scoping pass walks every child, so an unguarded self.target.build_products crashed the whole run + from pathlib import Path + dep = _artifact_dep(tmp_path) + dep.target = None + assert dep.has_usable_artifacts() is False + (Path(dep.build_dir) / 'CMakeCache.txt').write_text('') + assert dep.has_usable_artifacts() is True # judged by the build dir alone diff --git a/tests/test_unified_scheduler/test_unified_scheduler.py b/tests/test_unified_scheduler/test_unified_scheduler.py new file mode 100644 index 0000000..27fcafa --- /dev/null +++ b/tests/test_unified_scheduler/test_unified_scheduler.py @@ -0,0 +1,71 @@ +"""Pins execute_unified: the graph grows as fake LOADs discover children, and a parent only +configures after its children have built (leaf nodes build while deeper deps still load).""" +import threading +from types import SimpleNamespace +from unittest.mock import Mock +from testutils import FakeBuildTarget +from mama import dependency_chain as dc + + +class _Target(FakeBuildTarget): + def __init__(self, dep, ev, lock): + self.dep = dep; self._ev = ev; self._lock = lock; self._out_sink = None + def _rec(self, tag): + with self._lock: self._ev.append((tag, self.dep.name)) + def configure_phase(self, out=None): self._rec('cfg') + def build_phase(self, out=None): self._rec('bld') + def _execute_deploy_tasks(self): pass + def _execute_run_tasks(self): pass + + +class _Dep: + def __init__(self, name, config, ev, lock, child_specs=(), shared_children=None): + self.name = name; self.config = config; self._ev = ev; self._lock = lock + self.phase_times = {}; self.should_rebuild = False; self.from_artifactory = False; self.nothing_to_build = False + self._child_specs = child_specs; self._shared = shared_children # share an instance -> diamond + self._children = []; self.already_executed = False + self.is_root = False; self.load_action = 'check'; self.target = _Target(self, ev, lock) + def load(self): + with self._lock: self._ev.append(('load', self.name)) + self._children = self._shared if self._shared is not None else \ + [_Dep(n, self.config, self._ev, self._lock, cs) for n, cs in self._child_specs] # discovered now + def get_children(self): return self._children + def is_root_or_config_target(self): return False + def is_real_clone(self): return False # load label resolves to 'clone' + + +def test_unified_grows_graph_and_orders_parent_after_children(monkeypatch): + monkeypatch.setattr(dc, '_save_mama_cmake_and_dependencies_cmake', lambda d: None) + monkeypatch.setattr(dc, '_save_vscode_compile_commands', lambda d: None) + cfg = SimpleNamespace(jobs=2, parallel_max=8, verbose=False, test=False, update_stats=Mock(), + workspaces_root=None, buildstats=False) + ev, lock = [], threading.Lock() + # root -> {A (leaf), B -> {C (leaf)}} + root = _Dep('root', cfg, ev, lock, child_specs=[('A', ()), ('B', [('C', ())])]) + dc.execute_unified(root) + + names = lambda tag: [n for t, n in ev if t == tag] + assert set(names('load')) == {'root', 'A', 'B', 'C'} # whole graph discovered dynamically + assert set(names('bld')) == {'root', 'A', 'B', 'C'} # everything configured+built + idx = lambda pair: ev.index(pair) + assert idx(('load', 'A')) > idx(('load', 'root')) # children discovered after parent loads + assert idx(('load', 'C')) > idx(('load', 'B')) + assert idx(('cfg', 'B')) > idx(('bld', 'C')) # parent configures only after child builds + assert idx(('cfg', 'root')) > idx(('bld', 'A')) and idx(('cfg', 'root')) > idx(('bld', 'B')) + assert idx(('bld', 'root')) > idx(('cfg', 'root')) + assert root.already_executed + + +def test_unified_dedups_a_diamond_dependency(monkeypatch): + monkeypatch.setattr(dc, '_save_mama_cmake_and_dependencies_cmake', lambda d: None) + monkeypatch.setattr(dc, '_save_vscode_compile_commands', lambda d: None) + cfg = SimpleNamespace(jobs=2, parallel_max=8, verbose=False, test=False, update_stats=Mock(), + workspaces_root=None, buildstats=False) + ev, lock = [], threading.Lock() + d = _Dep('D', cfg, ev, lock) # one shared instance... + a = _Dep('A', cfg, ev, lock, shared_children=[d]) # ...reached via both A... + b = _Dep('B', cfg, ev, lock, shared_children=[d]) # ...and B (diamond) + dc.execute_unified(_Dep('root', cfg, ev, lock, shared_children=[a, b])) + names = lambda tag: [n for t, n in ev if t == tag] + assert names('load').count('D') == 1 # grow() dedups the shared child: cloned once, not per-parent + assert names('bld').count('D') == 1 # and built once diff --git a/tests/testutils.py b/tests/testutils.py index e96e854..cee1d5f 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -1,13 +1,26 @@ import os +import re import shutil import subprocess import sys +import threading from typing import Iterable, Optional from unittest.mock import Mock import mama import pytest +_ANSI = re.compile(r'\x1b\[[0-9;]*[A-Za-z]') # SGR colours + cursor moves +def strip_ansi(s: str) -> str: return _ANSI.sub('', s) + + +class FakeBuildTarget: + """Base for the runner-test target fakes: the build-weight stubs the parallel runners call on + every dep (configure/build phase bodies and event recording stay specialised per test).""" + _build_jobs = None + def _has_custom_build(self): return False + def _reserved_cores(self): return 4 + def make_mock_config(tmp_path, **overrides): """Mock BuildConfig pre-populated with the defaults every shim/probe/dep @@ -20,6 +33,7 @@ def make_mock_config(tmp_path, **overrides): cfg.verbose = False cfg.print = False cfg.loaded_dependencies = {} + cfg.dep_registry_lock = threading.Lock() # real lock so add_child works under the mock config cfg.target_matches.return_value = False cfg.force_artifactory = False cfg.disable_artifactory = False @@ -34,6 +48,7 @@ def make_mock_config(tmp_path, **overrides): cfg.rebuild = False cfg.run_cmake_configure = False cfg.target = None + cfg.clean_only.return_value = False # Mock methods are truthy by default cfg.list = False # platform aliases (BuildTarget.__init__ pokes these) cfg.msvc = False