Files
qbz/scripts/qt-run.sh
T
vicrodh e8f4dd4aa3 build(qt): each worktree builds, and keeps its binary, in its own target
This workstation configures ONE Cargo target outside the checkouts for every
worktree below the project directory. A build in any worktree therefore
replaced the `qbz` another worktree had just built: a parallel session's
release build in its own worktree replaced the binary under test in the
main one.

scripts/qt-target.py now decides where builds go. When the target Cargo
resolves lies outside the worktree and nothing pins it (CARGO_TARGET_DIR or
CARGO_BUILD_TARGET_DIR), the worktree builds in its own sibling directory,
`<target>-worktrees/<worktree>`; rebuilding the same worktree still replaces
its own binary. Cargo's default target inside the checkout (macOS, CI,
Windows) and explicit overrides are untouched, and no machine path is
committed. Two checkouts that share a directory name get a stable suffix
instead of one target.

qt-run.sh, the runtime gate and direct qt-cargo.py builds and tests all use
it. qt-run.sh prints the target and the binary path, and re-points
crates/target after each successful build, never over a real directory.
The aarch64 scripts build into and install from the same target, and the
qbzd acceptance script names the build command that lands its binary there.

The first build of a profile in a new worktree target is seeded from the
shared target with copy-on-write clones, skipping incremental caches:
Cargo rewrites moved build-script paths through root-output
(custom_build.rs, "rewrite paths if the target directory has been moved"),
so unchanged crates are not compiled again and no data is duplicated.
Without clone support nothing is copied and Cargo builds from scratch.

The resolution and shortcut handling start from the uncommitted draft in
the build-storage-layout worktree. Verified on the main worktree: seeded
from the shared release target, Cargo found everything fresh in two
seconds, and crates/target now points at the worktree's own target. 33
build-gate tests cover derivation, overrides, name collisions, linking,
seeding and the runner end to end.

cc
2026-09-14 11:47:07 -06:00

330 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# QBZ Qt/QML — build with cargo, then run the BINARY DIRECTLY.
#
# The Qt-track sibling of slint-run.sh. Same shape (build, learned ETA, exec the
# prebuilt binary so process monitors show `qbz-qt` instead of `cargo`), but the
# constraints are almost the opposite — read the two sections below before
# "improving" this script into a copy of slint-run.sh.
#
# ─── THERE IS NO MEMORY WALL HERE ───────────────────────────────────────────
# slint-run.sh tiers threads/codegen-units/opt-level off MemAvailable and runs
# under a cgroup cap because ONE rustc for `qbz-ui` (the ~1.6 M-line generated
# Slint module) peaks 20-30 GB and can freeze the box.
# `qbz-qt` does NOT depend on `qbz-ui` — verified: zero `qbz-ui` references in
# crates/qbz-qt/Cargo.toml, so the monster compilation unit never enters this
# graph. The UI lives in QML, which does not go through rustc at all; that is
# the entire point of the Qt track. So: no tiering, no cap, ordinary jobs.
# The box-wide "ONE build at a time" rule still applies (a Slint build running
# in another worktree WILL freeze the machine) — hence the guard below.
#
# ─── THE CACHE RULE — WHY THIS SCRIPT SETS NO RUSTFLAGS ─────────────────────
# RUSTFLAGS is part of every unit's fingerprint. This worktree's target dir was
# built on STABLE with rustflags=[] (checked in the .fingerprint json). Exporting
# anything — mold, `-Z threads`, a target-feature — invalidates the whole
# workspace and buys a from-scratch rebuild of ~200 crates to save seconds on a
# ~2-minute incremental. Same reason there is no `+nightly` here: nothing in
# qbz-qt needs it, and switching toolchains is another full rebuild.
# MOLD=1 opts in anyway (it warns first). If you use it, keep using it.
#
# ─── THE QML AUDITS RUN FIRST, BY DESIGN ────────────────────────────────────
# `cargo check` sees NOTHING of the QML: a missing component or a call to a
# bridge member that does not exist compiles clean and throws when the user
# opens that view. The five static audits are ~1s and catch exactly that, so they
# run BEFORE the build — failing in one second beats failing after two minutes,
# or worse, at the owner's smoke. NO_AUDIT=1 skips them.
#
# ─── EACH WORKTREE BUILDS IN ITS OWN TARGET ─────────────────────────────────
# scripts/qt-target.py decides where. With Cargo's default target (inside the
# checkout: macOS, CI) nothing changes. When the host configures ONE target
# outside the checkout for every worktree, this worktree builds in its own
# sibling of it, `<target>-worktrees/<worktree>`, so a build in another worktree
# can never replace the binary built here; rebuilding THIS worktree still does.
# Its first build of a profile is seeded from the shared target with
# copy-on-write clones, and crates/target is re-pointed after each successful
# build. An explicit CARGO_TARGET_DIR is used exactly as given.
#
# Usage: ./scripts/qt-run.sh [extra app args]
# DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses)
# NORUN=1 ./scripts/qt-run.sh # build only, don't exec
# TEST=1 ./scripts/qt-run.sh # also run `cargo test -p qbz-qt`
# SMOKE=1 ./scripts/qt-run.sh # offscreen gate instead of the GUI
# NO_AUDIT=1 ./scripts/qt-run.sh # skip the five QML audits
# JOBS=4 ./scripts/qt-run.sh # cargo build jobs (default: auto)
# MOLD=1 ./scripts/qt-run.sh # link with mold (forces a full rebuild)
# FORCE=1 ./scripts/qt-run.sh # build even if another cargo/rustc is up
# NO_TICKER=1 ./scripts/qt-run.sh # no live progress ticker
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
QT_CRATE="crates/qbz-qt"
# The five QML audits ship IN the repo (scripts/qml-audits, 2026-08-25) so CI
# runs the same gate as this script; AUDIT_DIR still overrides, and the docs
# checkout is the fallback for a tree that predates the move.
AUDIT_DIR="${AUDIT_DIR:-scripts/qml-audits}"
[[ -d "$AUDIT_DIR" ]] || AUDIT_DIR="$HOME/Personal/qbz/qbz-nix-docs/qt-frontend/tools"
# --- Portability shims (this runs on the Mac mini too) -----------------------
# Everything below that differs between GNU/Linux and BSD/macOS lives here, so
# the body reads the same on both. On macOS the audits also live elsewhere (the
# docs repo is not synced to the build box), so a missing AUDIT_DIR degrades to
# a warning rather than an error — see the audit block.
UNAME_S="$(uname -s)"
# Available RAM in MB. macOS has no `free`; vm_stat's "free + inactive" pages
# are the honest analogue of MemAvailable (inactive is reclaimable).
mem_avail_mb() {
if command -v free >/dev/null 2>&1; then
free -m | awk '/^Mem:/ {print $7}'
elif [[ "${UNAME_S}" == "Darwin" ]]; then
vm_stat | awk '/page size of/ {ps=$8} /Pages free/ {f=$3} /Pages inactive/ {i=$3}
END { gsub(/\./,"",f); gsub(/\./,"",i); if (ps=="") ps=16384;
printf "%d", (f+i)*ps/1048576 }'
else
echo 0
fi
}
cpu_count() {
if command -v nproc >/dev/null 2>&1; then nproc
elif [[ "${UNAME_S}" == "Darwin" ]]; then sysctl -n hw.ncpu
else echo 4; fi
}
# BSD pgrep has no -c and uses -l where GNU uses -a, so count lines instead.
# The `|| true` is load-bearing: pgrep exits 1 when it matches nothing, which is
# the NORMAL case here, and under `set -euo pipefail` that non-zero status
# propagates out of the command substitution and kills the script — silently,
# before a single line of output, looking exactly like a build that did nothing.
# (It did exactly that once. The tell was an empty log with rc=1.)
other_builds() { { pgrep -x rustc; pgrep -x cargo; } 2>/dev/null || true; }
# `timeout` is GNU coreutils and is simply ABSENT on macOS, where the shell
# answers `command not found` — which, inside the smoke block, produced a
# one-line log that then reported "0 QML complaints" and looked like a pass.
# A smoke that never started must never read as green, so this shims it.
run_timeout() {
local secs=$1; shift
if command -v timeout >/dev/null 2>&1; then timeout "${secs}" "$@"; return $?; fi
if command -v gtimeout >/dev/null 2>&1; then gtimeout "${secs}" "$@"; return $?; fi
"$@" & local pid=$!
( sleep "${secs}"; kill "${pid}" 2>/dev/null ) & local watcher=$!
wait "${pid}" 2>/dev/null; local rc=$?
kill "${watcher}" 2>/dev/null || true
return "${rc}"
}
# cxx-qt-build finds Qt through qmake / CMAKE_PREFIX_PATH. Homebrew keeps it
# off PATH by design (qt is keg-only-ish in practice), so a plain shell has no
# qmake and the build dies in build.rs rather than in the compiler. Add it when
# it is there; say nothing when it is not, so Linux is untouched.
if [[ "${UNAME_S}" == "Darwin" ]]; then
for qtdir in /opt/homebrew/opt/qt /usr/local/opt/qt; do
if [[ -x "${qtdir}/bin/qmake" ]]; then
export PATH="${qtdir}/bin:${PATH}"
export CMAKE_PREFIX_PATH="${qtdir}${CMAKE_PREFIX_PATH:+:${CMAKE_PREFIX_PATH}}"
break
fi
done
fi
# --- Pretty helpers ----------------------------------------------------------
if [[ -t 2 ]]; then C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_GRN=$'\033[32m'
C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_RST=$'\033[0m'
else C_DIM=""; C_BOLD=""; C_GRN=""; C_RED=""; C_YEL=""; C_RST=""; fi
fmt_dur() { local s=$1; printf '%dm %02ds' $(( s / 60 )) $(( s % 60 )); }
say() { printf '[qt-run] %s\n' "$*"; }
warn() { printf '%s[qt-run] %s%s\n' "${C_YEL}" "$*" "${C_RST}" >&2; }
die() { printf '%s[qt-run] %s%s\n' "${C_RED}" "$*" "${C_RST}" >&2; exit 1; }
if [[ "${DEBUG:-0}" == 1 ]]; then
PROFILE=debug; PROFILE_ARGS=()
else
PROFILE=release; PROFILE_ARGS=(--release)
fi
# --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------
# Not paranoia: a Slint `qbz-ui` rustc in another worktree peaks 20-30 GB on a
# 30 GB box with no hibernation, and the failure mode is a swap-thrash livelock
# that needs a power-cycle — earlyoom does not reliably fire. Cheap to check.
if [[ "${FORCE:-0}" != 1 ]]; then
others=$(other_builds | wc -l | tr -d ' ')
[[ "${others}" =~ ^[0-9]+$ ]] || others=0
if (( others > 0 )); then
die "another cargo/rustc is running (${others}). Builds are serialized box-wide — wait, or FORCE=1 if you know it is harmless."
fi
fi
# --- Where this worktree builds (see the header) -----------------------------
# The PHYSICAL path: moc/cc-rs resolve includes wrongly through the crates/target
# symlink.
CARGO_TARGET_DIR="$(python3 scripts/qt-target.py resolve)" || die "could not resolve the Cargo target directory"
export CARGO_TARGET_DIR
BIN="${CARGO_TARGET_DIR}/${PROFILE}/qbz"
# --- The QML audits (fail fast; cargo cannot see QML) ------------------------
if [[ "${NO_AUDIT:-0}" != 1 ]]; then
if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then
audit_abs="$(pwd)/${QT_CRATE}"
# `qml_module_registration_audit.py` is the newest and it exists because the
# other three could not see the failure it catches: a .qml on disk that is
# missing from build.rs is not in the QML MODULE, so it resolves fine on
# disk, compiles nothing, and blanks a whole page at runtime with
# "<Component> is not a type" (2026-08-20, the media-server panel).
# qml_icon_bake_audit.py joined on 2026-08-20: QbzIcon draws PRE-BAKED
# per-tint svgs, so an icon name with no bake renders NOTHING — no error,
# no failing test, no complaint from the other four. Three menu rows
# shipped iconless through a green build before anyone looked at the menu.
# qml_on_prefixed_property_audit.py joined on 2026-09-01: a property named
# `on<Upper>` is filed as a signal handler as soon as a sibling member
# `<upper>` exists, its initializer never binds and it sits at the type
# default with no error — `onAlbums` next to `albums` blanked Purchases.
for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py qml_module_registration_audit.py qml_icon_bake_audit.py qml_on_prefixed_property_audit.py; do
[[ -r "${AUDIT_DIR}/${a}" ]] || { warn "audit ${a} not found — skipped"; continue; }
if ! python3 "${AUDIT_DIR}/${a}" "${audit_abs}"; then
die "${a} FAILED — fix it before burning a build (QML resolves lazily; this is what cargo cannot tell you)."
fi
done
say "QML audits OK"
else
warn "audit tools not found at ${AUDIT_DIR} — skipped"
fi
fi
# --- Build settings ----------------------------------------------------------
# Deliberately NOT setting CARGO_PROFILE_RELEASE_* (opt-level / codegen-units):
# they are fingerprint inputs too, and unlike the Slint track there is no memory
# reason to lower them. The package's own [profile.release] (strip="symbols")
# is the whole story.
if [[ -n "${JOBS:-}" ]]; then
export CARGO_BUILD_JOBS="${JOBS}"
else
# Leave a couple of cores for the desktop; cargo's default is all of them.
ncpu=$(cpu_count)
export CARGO_BUILD_JOBS="$(( ncpu > 3 ? ncpu - 2 : 1 ))"
fi
if [[ "${MOLD:-0}" == 1 ]]; then
command -v mold >/dev/null 2>&1 || die "MOLD=1 but mold is not installed"
warn "MOLD=1 changes RUSTFLAGS, which is a fingerprint input → this rebuilds the WHOLE workspace once. Keep using it, or never use it."
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-fuse-ld=mold"
fi
# --- Learned ETA: last successful build duration for THIS profile ------------
eta_dir="${XDG_CACHE_HOME:-$HOME/.cache}/qbz-qt"
eta_file="${eta_dir}/last-build-${PROFILE}.secs"
eta_secs=0
[[ -r "${eta_file}" ]] && eta_secs=$(cat "${eta_file}" 2>/dev/null || echo 0)
[[ "${eta_secs}" =~ ^[0-9]+$ ]] || eta_secs=0
avail_mb=$(mem_avail_mb)
say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'"
say "target=${CARGO_TARGET_DIR}"
# A worktree target's first build of a profile starts from the shared target's
# artifacts (copy-on-write, nothing duplicated) instead of from nothing. After
# the one-build guard on purpose: never clone a target another build is writing.
seed_profiles=("${PROFILE}")
[[ "${TEST:-0}" == 1 && "${PROFILE}" != debug ]] && seed_profiles+=(debug)
if [[ -z "$(other_builds)" ]]; then
python3 scripts/qt-target.py seed --target-dir "${CARGO_TARGET_DIR}" "${seed_profiles[@]}" \
|| warn "could not seed ${CARGO_TARGET_DIR}; Cargo builds it from scratch"
fi
# --- Start banner ------------------------------------------------------------
build_start=$(date +%s)
if (( eta_secs > 0 )); then eta_txt="~$(fmt_dur "${eta_secs}") (last ${PROFILE})"
else eta_txt="unknown (first ${PROFILE} build)"; fi
printf '%s[qt-run] ▶ build started %s · ETA %s%s\n' \
"${C_BOLD}" "$(date '+%H:%M:%S')" "${eta_txt}" "${C_RST}"
# --- Live ticker -------------------------------------------------------------
# 30s, not slint-run's 15s: a warm qbz-qt build is ~2 min, so 15s would print
# more ticker than build. A cold one (or the first after `cargo clean`) is long
# enough that the ticker earns its place.
tick_pid=""
if [[ "${NO_TICKER:-0}" != 1 ]] && [[ -t 2 ]]; then
(
while true; do
sleep 30
now=$(date +%s); el=$(( now - build_start ))
if (( eta_secs > 0 )); then
pct=$(( el * 100 / eta_secs )); (( pct > 99 )) && pct=99
printf '%s[qt-run] ⏱ %s elapsed · ETA ~%s · ~%d%%%s\n' \
"${C_DIM}" "$(fmt_dur "${el}")" "$(fmt_dur "${eta_secs}")" "${pct}" "${C_RST}" >&2
else
printf '%s[qt-run] ⏱ %s elapsed%s\n' "${C_DIM}" "$(fmt_dur "${el}")" "${C_RST}" >&2
fi
done
) &
tick_pid=$!
trap '[[ -n "${tick_pid}" ]] && kill "${tick_pid}" 2>/dev/null || true' EXIT
fi
# --- The build ---------------------------------------------------------------
python3 scripts/qt-cargo.py build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt
# The familiar crates/target path follows the target this worktree just built,
# and only after a successful build.
python3 scripts/qt-target.py link --target-dir "${CARGO_TARGET_DIR}" \
|| warn "crates/target was not re-pointed; the binary is ${BIN}"
# --- Stop the ticker, record the duration, print the final banner ------------
[[ -n "${tick_pid}" ]] && { kill "${tick_pid}" 2>/dev/null || true; wait "${tick_pid}" 2>/dev/null || true; }
trap - EXIT
build_secs=$(( $(date +%s) - build_start ))
mkdir -p "${eta_dir}" 2>/dev/null && printf '%s\n' "${build_secs}" > "${eta_file}" 2>/dev/null || true
printf '%s[qt-run] ✔ build finished %s · took %s (%s)%s\n' \
"${C_BOLD}${C_GRN}" "$(date '+%H:%M:%S')" "$(fmt_dur "${build_secs}")" "${PROFILE}" "${C_RST}"
say "binary=${BIN}"
# --- Tests (opt-in) ----------------------------------------------------------
if [[ "${TEST:-0}" == 1 ]]; then
say "running cargo test -p qbz-qt"
python3 scripts/qt-cargo.py test --manifest-path crates/Cargo.toml -p qbz-qt
fi
# --- Offscreen smoke gate (opt-in) -------------------------------------------
# The last step of the track's standard gate: boot the real app with no display
# and prove the QML tree actually resolves. A lazily-resolved type error only
# ever shows up here or in front of the owner.
if [[ "${SMOKE:-0}" == 1 ]]; then
if [[ "${UNAME_S}" == "Linux" ]]; then
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
python3 scripts/qt-smoke.py "${BIN}" --log "${log}"
exit 0
fi
# BSD mktemp -t takes a bare prefix, GNU takes a template; give both a full
# template path so the two agree.
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
say "offscreen smoke (75s max) → ${log}"
QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "${BIN}" > "${log}" 2>&1 || true
# A log this short means the process never really started (the macOS
# `timeout`-not-found case did exactly that). Zero complaints in an empty log
# is not a pass.
if (( $(wc -l < "${log}") < 10 )); then
cat "${log}" >&2
die "smoke produced almost no output — the app did not start; see ${log}"
fi
# `propertyCache` from SettingsButton is known-benign noise — filter it out
# rather than let it mask a real count.
errs=$(grep -av 'propertyCache' "${log}" \
| grep -aciE 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|has no' || true)
published=$(grep -ac 'home published' "${log}" || true)
say "smoke: qml complaints=${errs} (want 0) · 'home published'=${published} (want >=1)"
if (( errs > 0 )); then
grep -av 'propertyCache' "${log}" \
| grep -aiE 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|has no' | head -20 >&2
die "smoke FAILED — see ${log}"
fi
(( published >= 1 )) || die "smoke FAILED: the app never reached 'home published' — see ${log}"
printf '%s[qt-run] ✔ smoke gate PASSED%s\n' "${C_BOLD}${C_GRN}" "${C_RST}"
exit 0
fi
[[ "${NORUN:-0}" == 1 ]] && { say "build done (NORUN set)."; exit 0; }
# The Slint qbz holds the DAC exclusively — two players cannot open it at once.
if pgrep -x qbz >/dev/null 2>&1; then
warn "the Slint 'qbz' is running — close it first or the audio device stays busy (ALSA exclusive)."
fi
# exec the binary directly — no `cargo run`, so no CARGO_* env / cargo context,
# so process monitors show `qbz-qt` rather than `cargo`.
exec "${BIN}" "$@"