The class the previous commit fixed leaves no trace a reader would notice: the view renders correctly, cargo is happy, both existing audits pass, and the only symptom is that entering the section takes seconds. So it needs a static gate or it comes back. qml_eager_tab_audit.py reports a per-tab body gated only on `visible:`, and reports it only when it is genuinely heavy — it must instantiate a Repeater/ListView/GridView AND that view's model must be data rather than an inline literal. Without the second condition it flagged the sort popups in LibraryToolbar, which are Repeaters over four fixed options, and three false positives would have buried the one real finding. cc
275 lines
14 KiB
Bash
Executable File
275 lines
14 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 two 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.
|
|
#
|
|
# 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 two 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"
|
|
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
|
|
BIN="crates/target/${PROFILE}/qbz"
|
|
|
|
# --- 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
|
|
|
|
# --- 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}"
|
|
for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_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:-}'"
|
|
|
|
# --- 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 ---------------------------------------------------------------
|
|
cargo build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt
|
|
|
|
# --- 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}"
|
|
|
|
# --- Tests (opt-in) ----------------------------------------------------------
|
|
if [[ "${TEST:-0}" == 1 ]]; then
|
|
say "running cargo test -p qbz-qt"
|
|
cargo 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
|
|
# 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}" "$@"
|